From de675894e38029478f82cb77d1482285b2275bbd Mon Sep 17 00:00:00 2001 From: waseem196 Date: Thu, 15 Oct 2026 11:08:47 +0500 Subject: [PATCH 01/41] feat(portal): add an Ask AI copilot chat and a `portal copilot sync` command Portals can offer an Ask AI chat panel, set up by `portal.ai.copilot` in apimatic.json (public key, service address, starter questions). The new `apimatic portal copilot sync` command uploads a generated portal's pages to the Ask AI service and waits until they are indexed. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 35 ++ apimatic.schema.json | 24 ++ package.json | 10 +- pnpm-lock.yaml | 401 +++++++++++++++--- portal-template/src/components/copilot.tsx | 74 ++++ portal-template/src/lib/copilot-client.ts | 114 +++++ portal-template/src/lib/layout.tsx | 36 +- portal-template/src/lib/portal-types.ts | 11 + portal-template/src/styles/app.css | 1 + src/actions/portal/copilot/sync.ts | 86 ++++ src/actions/portal/generate.ts | 8 +- src/commands/portal/copilot/sync.ts | 69 +++ src/infrastructure/portal-project-service.ts | 3 + .../services/copilot-ingest-service.ts | 195 +++++++++ src/prompts/portal/copilot/sync.ts | 100 +++++ src/prompts/portal/generate.ts | 6 + .../events/portal-copilot-sync-failed.ts | 9 + src/types/portal/config/ai-config.ts | 26 +- src/types/portal/config/copilot-config.ts | 69 +++ src/types/portal/copilot/copilot-bundle.ts | 57 +++ .../copilot/generated-portal-context.ts | 43 ++ src/types/portal/copilot/ingest.ts | 21 + src/types/portal/copilot/llms-index.ts | 50 +++ src/types/portal/portal-config.ts | 6 +- test/actions/portal/copilot/sync.test.ts | 193 +++++++++ test/actions/portal/generate.test.ts | 1 + .../portal/copilot/sync-flags.test.ts | 18 + .../services/copilot-ingest-service.test.ts | 174 ++++++++ test/portal-template/copilot-client.test.ts | 113 +++++ .../copilot/generated-portal-context.test.ts | 84 ++++ test/types/portal/portal-config.test.ts | 55 ++- 31 files changed, 2015 insertions(+), 77 deletions(-) create mode 100644 portal-template/src/components/copilot.tsx create mode 100644 portal-template/src/lib/copilot-client.ts create mode 100644 src/actions/portal/copilot/sync.ts create mode 100644 src/commands/portal/copilot/sync.ts create mode 100644 src/infrastructure/services/copilot-ingest-service.ts create mode 100644 src/prompts/portal/copilot/sync.ts create mode 100644 src/types/events/portal-copilot-sync-failed.ts create mode 100644 src/types/portal/config/copilot-config.ts create mode 100644 src/types/portal/copilot/copilot-bundle.ts create mode 100644 src/types/portal/copilot/generated-portal-context.ts create mode 100644 src/types/portal/copilot/ingest.ts create mode 100644 src/types/portal/copilot/llms-index.ts create mode 100644 test/actions/portal/copilot/sync.test.ts create mode 100644 test/commands/portal/copilot/sync-flags.test.ts create mode 100644 test/infrastructure/services/copilot-ingest-service.test.ts create mode 100644 test/portal-template/copilot-client.test.ts create mode 100644 test/types/portal/copilot/generated-portal-context.test.ts diff --git a/README.md b/README.md index d2b246749..eeb390934 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ USAGE * [`apimatic help [COMMAND]`](#apimatic-help-command) * [`apimatic plugin generate`](#apimatic-plugin-generate) * [`apimatic plugin publish`](#apimatic-plugin-publish) +* [`apimatic portal copilot sync`](#apimatic-portal-copilot-sync) * [`apimatic portal generate`](#apimatic-portal-generate) * [`apimatic portal serve`](#apimatic-portal-serve) * [`apimatic publishing profile list`](#apimatic-publishing-profile-list) @@ -290,6 +291,40 @@ EXAMPLES _See code: [src/commands/plugin/publish.ts](https://github.com/apimatic/apimatic-cli/blob/v1.3.1/src/commands/plugin/publish.ts)_ +## `apimatic portal copilot sync` + +Update the portal's Ask AI chat with the portal's current pages. + +``` +USAGE + $ apimatic portal copilot sync [--wait] [-d ] [-i ] [-k ] + +FLAGS + -d, --destination= [default: /portal] path to the generated portal to sync. + -i, --input= [default: ./] path to the parent directory containing the 'src' directory, which includes + API specifications and configuration files. + -k, --auth-key= override current authentication state with an authentication key. + --[no-]wait wait until the pages are indexed; with --no-wait, return as soon as they are uploaded. + +DESCRIPTION + Update the portal's Ask AI chat with the portal's current pages. + + Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as + they are now. Run it after deploying a portal generated with 'apimatic portal generate'. + + The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json'. The pages are swapped in + at once, after all of them are indexed; until then Ask AI answers from the previous ones. + +EXAMPLES + apimatic portal copilot sync + + apimatic portal copilot sync --input=./ --destination=./portal + + apimatic portal copilot sync --no-wait +``` + +_See code: [src/commands/portal/copilot/sync.ts](https://github.com/apimatic/apimatic-cli/blob/v1.3.1/src/commands/portal/copilot/sync.ts)_ + ## `apimatic portal generate` Generate a static API Documentation Portal. diff --git a/apimatic.schema.json b/apimatic.schema.json index e305cf119..39b048d9e 100644 --- a/apimatic.schema.json +++ b/apimatic.schema.json @@ -174,6 +174,30 @@ "type": "boolean", "default": true, "description": "Whether each page offers to open itself in an external AI assistant." + }, + "copilot": { + "type": "object", + "description": "The Ask AI chat the portal offers. Left out, the portal offers none.", + "additionalProperties": false, + "required": ["key", "apiUrl"], + "properties": { + "key": { + "type": "string", + "description": "A public Ask AI key, bound to this portal and to the origins allowed to use it.", + "minLength": 1 + }, + "apiUrl": { + "type": "string", + "description": "The Ask AI service's address, for example 'https://ask.example.com'.", + "pattern": "^[hH][tT][tT][pP][sS]?://\\S+$" + }, + "suggestions": { + "type": "array", + "description": "Up to three starter questions shown on a new chat, such as 'How do I authenticate?'. Left out, the chat shows its own.", + "maxItems": 3, + "items": { "type": "string", "minLength": 1, "maxLength": 120 } + } + } } } }, diff --git a/package.json b/package.json index 6338f58eb..1faf7e933 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,13 @@ "test": "tsx node_modules/mocha/bin/_mocha --forbid-only \"test/**/*.test.ts\" --timeout 99999" }, "dependencies": { + "@ai-sdk/react": "4.0.131", "@apimatic/sdk": "0.2.0-alpha.10", "@clack/core": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1", "@fontsource-variable/geist": "5.3.0", "@fontsource-variable/geist-mono": "5.3.0", + "@fumadocs/ai-chat": "0.1.0", "@fumadocs/api-docs": "0.2.8", "@oclif/core": "^4.2.8", "@oclif/plugin-autocomplete": "^3.2.24", @@ -70,6 +72,7 @@ "@tanstack/start-static-server-functions": "1.167.33", "@vitejs/plugin-react": "6.1.1", "adm-zip": "^0.5.17", + "ai": "7.0.128", "ansis": "^3.17.0", "async-mutex": "^0.5.0", "axios": "^1.8.1", @@ -77,10 +80,10 @@ "fast-levenshtein": "^3.0.0", "form-data": "^4.0.2", "fs-extra": "^11.3.0", - "fumadocs-core": "16.15.17", + "fumadocs-core": "16.16.2", "fumadocs-mdx": "15.4.0", "fumadocs-openapi": "12.1.1", - "fumadocs-ui": "16.15.17", + "fumadocs-ui": "16.16.2", "get-port": "^7.1.0", "is-in-ci": "^2.0.0", "isomorphic-git": "^1.37.2", @@ -178,6 +181,9 @@ "portal": { "description": "Build and serve an API Documentation portal for your APIs." }, + "portal:copilot": { + "description": "Keep your portal's Ask AI chat in sync with its pages." + }, "sdk": { "description": "Generate and publish SDKs for your APIs in multiple languages." }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61cda5135..e863d7a5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: .: dependencies: + '@ai-sdk/react': + specifier: 4.0.131 + version: 4.0.131(react@19.2.8)(zod@4.5.4) '@apimatic/sdk': specifier: 0.2.0-alpha.10 version: 0.2.0-alpha.10 @@ -37,9 +40,12 @@ importers: '@fontsource-variable/geist-mono': specifier: 5.3.0 version: 5.3.0 + '@fumadocs/ai-chat': + specifier: 0.1.0 + version: 0.1.0(@ai-sdk/react@4.0.131(react@19.2.8)(zod@4.5.4))(@types/react@19.3.0)(ai@7.0.128(zod@4.5.4))(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/api-docs': specifier: 0.2.8 - version: 0.2.8(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.2.8(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@oclif/core': specifier: 4.11.0 version: 4.11.0 @@ -70,6 +76,9 @@ importers: adm-zip: specifier: ^0.5.17 version: 0.5.17 + ai: + specifier: 7.0.128 + version: 7.0.128(zod@4.5.4) ansis: specifier: ^3.17.0 version: 3.17.0 @@ -92,17 +101,17 @@ importers: specifier: ^11.3.0 version: 11.3.5 fumadocs-core: - specifier: 16.15.17 - version: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + specifier: 16.16.2 + version: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) fumadocs-mdx: specifier: 15.4.0 - version: 15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)) + version: 15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)) fumadocs-openapi: specifier: 12.1.1 - version: 12.1.1(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 12.1.1(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) fumadocs-ui: - specifier: 16.15.17 - version: 16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + specifier: 16.16.2 + version: 16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) get-port: specifier: ^7.1.0 version: 7.2.0 @@ -323,6 +332,34 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@ai-sdk/gateway@4.0.104': + resolution: {integrity: sha512-4lTyt8a4BMfVz4QGo+VO5odakmYMX5kq+xiRgdQGGY7o/jP2WSasp6LvIvYvt/lHzz1MfE+xKTOJhz7mMwjynA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mcp@2.0.67': + resolution: {integrity: sha512-cMEif3Lbvh+FozPpBM5UPy6QQhH8v8L/u1kcR2UXp+86EfIDXIaIeOaH+wYCk2CfloFccUn1K8LJuPXqZMZsfw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.54': + resolution: {integrity: sha512-amqDxFnw9+dpVrDACRDGmExUUNOH9C1D2bhg/DqWJnamEOieDTXBBXLHujSby/0MqHdAFksnpE8Pzfqx6ABMBw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.22': + resolution: {integrity: sha512-1Jtmn36VNMOBo9CMUQrPqsR744qtqRloEiXpWCYS8ffPlhS2CrSNmoho5lPjp3R7EnkzBsGI6p51TA7fQJQZdA==} + engines: {node: '>=22'} + + '@ai-sdk/react@4.0.131': + resolution: {integrity: sha512-DE2vKOAXkntnxClHEZoAVTys3eE1Ypm+SMCRhveQeTxYyJMszbRIEN5vW+mV5FjRH/UQWt1SeaPaXviCMYLWFA==} + engines: {node: '>=22'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@apimatic/authentication-adapters@0.5.14': resolution: {integrity: sha512-V7nhHShPrU8LfjKKHoVJNS50SveSL77CexVuS4aeQyXx99HwdQVJwl2MK0KAYM6/b2ufQbJ7Eee2fzQT0TVXSQ==} engines: {node: '>=14.15.0 || >=16.0.0'} @@ -1107,6 +1144,20 @@ packages: '@types/react': optional: true + '@fumadocs/ai-chat@0.1.0': + resolution: {integrity: sha512-HLTyqhxVxLS6gTBFn7e87HwGTXib2h5L2/myH588gl3HDUSrvYUSjZlRkb80GV3kFmnkBdK48CdHMVfSGTbcPA==} + peerDependencies: + '@ai-sdk/react': ^4.0.0 + '@types/react': '*' + ai: ^7.0.0 + fumadocs-core: ^16.10.0 + fumadocs-ui: ^16.10.0 + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@fumadocs/api-docs@0.2.8': resolution: {integrity: sha512-oJv1Y1TkyPGs6AFuogSgaH+wgebPRXPH9V2S0ZO/gGyt+pfFltP4XdmO4hoiVeuLiPMsSkm/FunuimeyscsQYA==} peerDependencies: @@ -2027,30 +2078,58 @@ packages: resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} + '@shikijs/core@4.5.0': + resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==} + engines: {node: '>=20'} + '@shikijs/engine-javascript@4.4.3': resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} engines: {node: '>=20'} + '@shikijs/engine-javascript@4.5.0': + resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==} + engines: {node: '>=20'} + '@shikijs/engine-oniguruma@4.4.3': resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} engines: {node: '>=20'} + '@shikijs/engine-oniguruma@4.5.0': + resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==} + engines: {node: '>=20'} + '@shikijs/langs@4.4.3': resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} engines: {node: '>=20'} + '@shikijs/langs@4.5.0': + resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==} + engines: {node: '>=20'} + '@shikijs/primitive@4.4.3': resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} engines: {node: '>=20'} + '@shikijs/primitive@4.5.0': + resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==} + engines: {node: '>=20'} + '@shikijs/themes@4.4.3': resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} engines: {node: '>=20'} + '@shikijs/themes@4.5.0': + resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==} + engines: {node: '>=20'} + '@shikijs/types@4.4.3': resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} engines: {node: '>=20'} + '@shikijs/types@4.5.0': + resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -2838,6 +2917,10 @@ packages: '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2854,10 +2937,14 @@ packages: oxc-transform-react: optional: true + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@yuku-analyzer/binding-android-arm64@0.9.5': resolution: {integrity: sha512-Rvr0/EkPfPgc0C1wx8dz4bv6o4I4yeYI06tcHUL0f4ZdwSyIreABoLHNW51Ap0VNA7aDRVQ4izRNQh/LuykofA==} cpu: [arm64] os: [android] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-darwin-arm64@0.9.5': resolution: {integrity: sha512-sh9DaXzkVmedM9aWvjCAAluRtvz/trk5VSosF7dPhqDf3IhrJW2DOh9iuA+YQzWSktP/ILsKfKili2UZ/ZjRzw==} @@ -2957,6 +3044,12 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ai@7.0.128: + resolution: {integrity: sha512-WMKN83DodYtH52ETPHq0jrSwj3wU0aHkhLHXMrwMRabC/aMpIqeWsXJgjkUwrOqkaKHWONdupk/2ttPRvAyFcg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3312,6 +3405,11 @@ packages: engines: {node: '>=20'} hasBin: true + cn@0.4.0: + resolution: {integrity: sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg==} + engines: {node: '>=20'} + hasBin: true + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -3816,6 +3914,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -3958,8 +4060,8 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - framer-motion@13.4.4: - resolution: {integrity: sha512-lbsZO95NGbulo6apz70zEt6Vxf/anoRwQI2ECEc/EQYWO5CGbITtl7plDW8P/VXgXw6e8wrbhH4a9i+ygke0Mg==} + framer-motion@14.0.0: + resolution: {integrity: sha512-QAtHmybrDFSliyW1sexMtSQTQd+1jw2ajibEmzpuI4XdPVzQkn+dAzJMm2sW3pl9MJuSS4xN51djQ7huhlP/VA==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -3985,8 +4087,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.15.17: - resolution: {integrity: sha512-Pexn60R+S5F33UccW9WoLN+LHAOIraA/VvQt1xAXmfLxitx5rIPR7B9M+5mrqECnuSPltoX843NZ//Z5j/HAOg==} + fumadocs-core@16.16.2: + resolution: {integrity: sha512-nC7NMDGbfgLa4NuA2X30QJt+zcE+xIiuATCrbWZD742fC6+DccgTQCBP9Ry0/OXdYrFtlsIcpO4MhzV9NkBvRw==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -4001,6 +4103,7 @@ packages: algoliasearch: 5.x.x flexsearch: '*' lucide-react: '*' + meilisearch: '>=0.50.0 <1.0.0' next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -4034,6 +4137,8 @@ packages: optional: true lucide-react: optional: true + meilisearch: + optional: true next: optional: true react: @@ -4102,12 +4207,12 @@ packages: json-schema-typed: optional: true - fumadocs-ui@16.15.17: - resolution: {integrity: sha512-Q0Tzfte6Bzz8JCVU0yMISWjEpoTzGKfdeG8WoRxktdv30JblQ+G8n87MG11auMh6Li0CuO5oPqH5otyQ2mQ5Yw==} + fumadocs-ui@16.16.2: + resolution: {integrity: sha512-pP8+B8cZ20WYYlwg1LsrN/ZowcivK24c9/RMzRx1inPZbjV66ga76NwWEhBmK7VX5OC/TSxGpDpxQ/bcI2XiqQ==} peerDependencies: '@types/mdx': '*' '@types/react': '*' - fumadocs-core: 16.15.17 + fumadocs-core: 16.16.2 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 @@ -4747,6 +4852,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5031,6 +5139,15 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.52.0: + resolution: {integrity: sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -5225,9 +5342,6 @@ packages: micromark-util-symbol@2.0.1: resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - micromark-util-types@2.0.3: resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} @@ -5297,14 +5411,14 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true - motion-dom@13.4.4: - resolution: {integrity: sha512-z2qN3RUABSci4G7cr5aHTFhqPNCWJsEMMfRlzxqtANQsSCIbVJmHvMV288m5x7doEQBTYbRTWbKdztmquCn4Sw==} + motion-dom@14.0.0: + resolution: {integrity: sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw==} - motion-utils@13.3.0: - resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} + motion-utils@14.0.0: + resolution: {integrity: sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ==} - motion@13.4.4: - resolution: {integrity: sha512-lyX5kpAum2MmigecKWrFdIj3Sjn7pHek2EUrXY0WGZbOjJ1f5NfX0g4KQAb2Bp2EqL0a6dUzse11s1n7OYr5jg==} + motion@14.0.0: + resolution: {integrity: sha512-oGc2lkge2lkRZCP+J8/JsnYu4NFjqP1MtXirNdetn3QbvK2JqwM9A200EdT4C598KfzDhjLT12zF21xDM4Oilw==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -5737,6 +5851,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} @@ -5947,6 +6065,9 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + remend@1.4.0: + resolution: {integrity: sha512-mHiYhhwphs1IgIZmkToKLwJn0U99IjPXuutkA2acrNcG0vlEq2G4TGnd2eT0saIHwh2KjaCpvleHcsd5SAz96A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -6103,6 +6224,10 @@ packages: resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} + shiki@4.5.0: + resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==} + engines: {node: '>=20'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -6335,6 +6460,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swr@2.5.1: + resolution: {integrity: sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6365,6 +6495,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -6893,8 +7027,8 @@ packages: yuku-ast@0.9.5: resolution: {integrity: sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ==} - zbsearch@4.0.0: - resolution: {integrity: sha512-gm4zfO31n2ZdruTTRQoWVWO4Q2+zrDt2GlrvIc+5JulRQNAm4IanCxER82vQ7Ug96FYkGqWUJBXFe1kGAcDWaQ==} + zbsearch@4.0.1: + resolution: {integrity: sha512-lmnEEnb93QQcNv7qARDr7jBVPxYdZbZ9XdZ0dbZ0qaOvGne4jJMW4fjwPfVaUKQXv3FNWvGbDwRDgyaSb1pc8w==} engines: {node: '>= 20.0.0'} zod@4.5.4: @@ -6921,6 +7055,46 @@ snapshots: '@actions/io@3.0.2': {} + '@ai-sdk/gateway@4.0.104(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.5.4) + '@vercel/oidc': 3.2.0 + zod: 4.5.4 + + '@ai-sdk/mcp@2.0.67(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.5.4) + cross-spawn: 7.0.6 + pkce-challenge: 5.0.1 + zod: 4.5.4 + + '@ai-sdk/provider-utils@5.0.54(zod@4.5.4)': + dependencies: + '@ai-sdk/provider': 4.0.22 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.29.1 + zod: 4.5.4 + + '@ai-sdk/provider@4.0.22': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@4.0.131(react@19.2.8)(zod@4.5.4)': + dependencies: + '@ai-sdk/mcp': 2.0.67(zod@4.5.4) + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.5.4) + ai: 7.0.128(zod@4.5.4) + react: 19.2.8 + swr: 2.5.1(react@19.2.8) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + '@apimatic/authentication-adapters@0.5.14': dependencies: '@apimatic/core-interfaces': 0.2.14 @@ -7999,7 +8173,32 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@fumadocs/api-docs@0.2.8(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@fumadocs/ai-chat@0.1.0(@ai-sdk/react@4.0.131(react@19.2.8)(zod@4.5.4))(@types/react@19.3.0)(ai@7.0.128(zod@4.5.4))(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@ai-sdk/react': 4.0.131(react@19.2.8)(zod@4.5.4) + '@base-ui/react': 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + ai: 7.0.128(zod@4.5.4) + cn: 0.4.0 + fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + fumadocs-ui: 16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + hast-util-to-jsx-runtime: 2.3.6 + lucide-react: 1.52.0(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + remark: 15.0.1 + remark-gfm: 4.0.1 + remark-rehype: 11.1.2 + remend: 1.4.0 + unist-util-visit: 5.1.0 + optionalDependencies: + '@types/react': 19.3.0 + transitivePeerDependencies: + - '@date-fns/tz' + - date-fns + - supports-color + + '@fumadocs/api-docs@0.2.8(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@base-ui/react': 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -8007,8 +8206,8 @@ snapshots: '@scalar/json-magic': 0.13.4 class-variance-authority: 0.7.1 cn: 0.2.6 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) - fumadocs-ui: 16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + fumadocs-ui: 16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) github-slugger: 2.0.0 lucide-react: 1.47.0(react@19.2.8) react: 19.2.8 @@ -8982,36 +9181,74 @@ snapshots: '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 + '@shikijs/core@4.5.0': + dependencies: + '@shikijs/primitive': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@4.4.3': dependencies: '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 + '@shikijs/engine-javascript@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + '@shikijs/engine-oniguruma@4.4.3': dependencies: '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@4.4.3': dependencies: '@shikijs/types': 4.4.3 + '@shikijs/langs@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/primitive@4.4.3': dependencies: '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + '@shikijs/primitive@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + '@shikijs/themes@4.4.3': dependencies: '@shikijs/types': 4.4.3 + '@shikijs/themes@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/types@4.4.3': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + '@shikijs/types@4.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + '@shikijs/vscode-textmate@10.0.2': {} '@simple-git/args-pathspec@1.0.3': {} @@ -9913,11 +10150,15 @@ snapshots: '@ungap/structured-clone@1.4.0': {} + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1) + '@workflow/serde@4.1.0': {} + '@yuku-analyzer/binding-android-arm64@0.9.5': optional: true @@ -9982,6 +10223,13 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 + ai@7.0.128(zod@4.5.4): + dependencies: + '@ai-sdk/gateway': 4.0.104(zod@4.5.4) + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.5.4) + zod: 4.5.4 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -10379,6 +10627,8 @@ snapshots: cn@0.3.3: {} + cn@0.4.0: {} + collapse-white-space@2.1.0: {} color-convert@1.9.3: @@ -10988,6 +11238,8 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.1: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -11148,10 +11400,10 @@ snapshots: hasown: 2.0.3 mime-types: 2.1.35 - framer-motion@13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + framer-motion@14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - motion-dom: 13.4.4 - motion-utils: 13.3.0 + motion-dom: 14.0.0 + motion-utils: 14.0.0 tslib: 2.8.1 optionalDependencies: react: 19.2.8 @@ -11174,7 +11426,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4): + fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4): dependencies: '@fumari/image-size': 0.1.1 estree-util-value-to-estree: 3.5.0 @@ -11188,13 +11440,13 @@ snapshots: remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 4.4.3 + shiki: 4.5.0 tinyglobby: 0.2.17 unified: 11.0.5 unist-util-visit: 5.1.0 vfile: 6.0.3 yaml: 2.9.1 - zbsearch: 4.0.0 + zbsearch: 4.0.1 optionalDependencies: '@mdx-js/mdx': 3.1.1 '@tanstack/react-router': 1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -11209,14 +11461,14 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-mdx@15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)): + fumadocs-mdx@15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react@19.2.8)(rolldown@1.2.8)(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) github-slugger: 2.0.0 magic-string: 1.2.3 mdast-util-mdx: 3.0.0 @@ -11241,7 +11493,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-openapi@12.1.1(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + fumadocs-openapi@12.1.1(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@base-ui/react': 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -11253,8 +11505,8 @@ snapshots: chokidar: 5.0.0 class-variance-authority: 0.7.1 cn: 0.3.3 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) - fumadocs-ui: 16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + fumadocs-ui: 16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) github-slugger: 2.0.0 hast-util-to-jsx-runtime: 2.3.6 lucide-react: 1.47.0(react@19.2.8) @@ -11271,7 +11523,7 @@ snapshots: - date-fns - supports-color - fumadocs-ui@16.15.17(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.16.2(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.2(tailwindcss@4.3.3) @@ -11286,17 +11538,17 @@ snapshots: '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.2.8) '@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 - cn: 0.3.3 - fumadocs-core: 16.15.17(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) - lucide-react: 1.47.0(react@19.2.8) - motion: 13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + cn: 0.4.0 + fumadocs-core: 16.16.2(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.47.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.5.4) + lucide-react: 1.52.0(@types/react@19.3.0)(react@19.2.8) + motion: 14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.2.8) rehype-raw: 7.0.0 scroll-into-view-if-needed: 3.1.0 - shiki: 4.4.3 + shiki: 4.5.0 unist-util-visit: 5.1.0 optionalDependencies: '@types/mdx': 2.0.14 @@ -11983,6 +12235,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -12224,6 +12478,12 @@ snapshots: dependencies: react: 19.2.8 + lucide-react@1.52.0(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -12458,7 +12718,7 @@ snapshots: micromark-util-character: 2.1.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-footnote@2.1.0: dependencies: @@ -12469,7 +12729,7 @@ snapshots: micromark-util-normalize-identifier: 2.0.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-strikethrough@2.1.0: dependencies: @@ -12478,7 +12738,7 @@ snapshots: micromark-util-classify-character: 2.0.1 micromark-util-resolve-all: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-table@2.1.1: dependencies: @@ -12486,11 +12746,11 @@ snapshots: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-tagfilter@2.0.0: dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-task-list-item@2.1.0: dependencies: @@ -12498,7 +12758,7 @@ snapshots: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm@3.0.0: dependencies: @@ -12509,7 +12769,7 @@ snapshots: micromark-extension-gfm-tagfilter: 2.0.0 micromark-extension-gfm-task-list-item: 2.1.0 micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-mdx-expression@3.0.1: dependencies: @@ -12674,8 +12934,6 @@ snapshots: micromark-util-symbol@2.0.1: {} - micromark-util-types@2.0.2: {} - micromark-util-types@2.0.3: {} micromark@4.0.2: @@ -12769,15 +13027,15 @@ snapshots: yargs-parser: 21.1.1 yargs-unparser: 2.0.0 - motion-dom@13.4.4: + motion-dom@14.0.0: dependencies: - motion-utils: 13.3.0 + motion-utils: 14.0.0 - motion-utils@13.3.0: {} + motion-utils@14.0.0: {} - motion@13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + motion@14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - framer-motion: 13.4.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + framer-motion: 14.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tslib: 2.8.1 optionalDependencies: react: 19.2.8 @@ -13187,6 +13445,8 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + pkg-conf@2.1.0: dependencies: find-up: 2.1.0 @@ -13464,6 +13724,8 @@ snapshots: transitivePeerDependencies: - supports-color + remend@1.4.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -13668,6 +13930,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + shiki@4.5.0: + dependencies: + '@shikijs/core': 4.5.0 + '@shikijs/engine-javascript': 4.5.0 + '@shikijs/engine-oniguruma': 4.5.0 + '@shikijs/langs': 4.5.0 + '@shikijs/themes': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -13937,6 +14210,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swr@2.5.1(react@19.2.8): + dependencies: + dequal: 2.0.3 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + tagged-tag@1.0.0: {} tailwindcss@4.3.3: {} @@ -13966,6 +14245,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttleit@2.1.0: {} + through2@2.0.5: dependencies: readable-stream: 2.3.8 @@ -14500,7 +14781,7 @@ snapshots: dependencies: '@yuku-toolchain/types': 0.9.5 - zbsearch@4.0.0: {} + zbsearch@4.0.1: {} zod@4.5.4: {} diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx new file mode 100644 index 000000000..98abbbc97 --- /dev/null +++ b/portal-template/src/components/copilot.tsx @@ -0,0 +1,74 @@ +// The Ask AI copilot: Fumadocs' AI chat panel, answered by the Ask AI service (the portal is static). +// `@fumadocs/ai-chat` is 0.x and needs fumadocs-ui >= 16.16: keep it pinned and check the chat on every upgrade. +import { useMemo, useState, type ReactNode } from 'react'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport, type UIMessage } from 'ai'; +import { AIChatPanel, AIChatProvider, AIChatSearch, AIChatSources, AIChatTrigger, useAIChat } from '@fumadocs/ai-chat'; +import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; +import { withBasePath } from '@/lib/base-path'; +import { CopilotClient } from '@/lib/copilot-client'; +import type { PortalCopilot } from '@/lib/portal-types'; + +/** One part of a chat message: text, a tool call, a data part, and so on. */ +type Part = UIMessage['parts'][number]; +// Taken from the components themselves, so they cannot drift. +type SearchPart = Parameters[0]['part']; +type Sources = Parameters[0]['sources']; + +/** The chat state and the "Ask AI" button; wraps the layout so a conversation survives navigation. */ +export function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilot; children: ReactNode }>) { + // Turned away by the service (disabled, unknown key, another website): the portal shows no Ask AI at all. + const [available, setAvailable] = useState(true); + const client = useMemo(() => new CopilotClient(copilot, () => setAvailable(false)), [copilot]); + const transport = useMemo(() => chatTransport(client), [client]); + const chat = useChat({ transport, onData: client.noteData }); + const suggestions = copilot.suggestions.length > 0 ? copilot.suggestions : undefined; + return ( + + {children} + {available && } + + ); +} + +/** The docs layout's `aiChat` option: the panel and whether it is open. Call inside `CopilotProvider`. */ +export function useCopilotPanel(): AIChatOptions { + const { open, setOpen } = useAIChat(); + return { open, onOpenChange: setOpen, panel: }; +} + +/** + * Draws the non-text parts the service sends; the panel draws text itself. + * A new part type from the service needs a branch here, or it is not shown. + */ +function renderPart(part: Part, live: boolean): ReactNode { + // "Searched ยท N results", expandable to the pages the answer was based on. + if (part.type === 'tool-search') return ; + // The numbered source chips, matching the [1], [2] citations in the answer. + if (part.type === 'data-sources') return ; + return null; +} + +// The service links pages by their path within the portal; the host serves them under the base path. +function servedSearch(part: SearchPart): SearchPart { + return { + ...part, + output: part.output?.map((found) => ({ doc: found.doc && { ...found.doc, url: withBasePath(found.doc.url) } })) + }; +} + +function servedSources(sources: Sources): Sources { + return sources.map((source) => ({ ...source, url: withBasePath(source.url) })); +} + +function chatTransport(client: CopilotClient): DefaultChatTransport { + // The token is the only credential; the portal's cookies are never sent. + return new DefaultChatTransport({ + credentials: 'omit', + fetch: client.fetch, + prepareSendMessagesRequest: ({ messages, trigger }) => ({ + api: client.chatAddress(), + body: client.questionBody(messages, trigger) + }) + }); +} diff --git a/portal-template/src/lib/copilot-client.ts b/portal-template/src/lib/copilot-client.ts new file mode 100644 index 000000000..6fff630e4 --- /dev/null +++ b/portal-template/src/lib/copilot-client.ts @@ -0,0 +1,114 @@ +import type { PortalCopilot } from './portal-types'; + +/** Why Ask AI stays hidden: the service turned this portal, its key or this website away. */ +const TURNED_AWAY = new Set(['copilot_disabled', 'invalid_key', 'origin_not_allowed']); +/** A session is renewed this long before it expires, so a question never meets a stale one. */ +const RENEW_EARLY_MS = 30_000; + +interface Problem { + code?: string; + message?: string; +} + +/** + * The Ask AI service as the chat panel sees it. The public key opens a short session; questions + * carry the session's token, never the key, and the service names the conversation's thread. + * These paths ship inside published portals, so the service must keep serving them; a breaking + * change needs a new `/v2` path. + */ +export class CopilotClient { + private session: Promise<{ token: string; expiresAt: number }> | null = null; + private thread: string | null = null; + + constructor( + private readonly copilot: Pick, + private readonly turnedAway: () => void, + private readonly send: typeof fetch = (input, init) => fetch(input, init) + ) {} + + readonly chatAddress = (): string => `${this.copilot.apiUrl}/v1/chat`; + + /** The body for one question: only the latest message, since the service keeps the thread. */ + questionBody(messages: readonly unknown[], trigger: string): Record { + if (messages.length <= 1) this.thread = null; + return { threadId: this.thread, trigger, message: messages.at(-1) }; + } + + /** Remembers the thread the service started, from its `data-thread` part. */ + readonly noteData = (part: { type: string; data?: unknown }): void => { + if (part.type !== 'data-thread') return; + const threadId = (part.data as { threadId?: unknown } | undefined)?.threadId; + if (typeof threadId === 'string') this.thread = threadId; + }; + + /** + * The chat transport's fetch: adds the session token, renews an expired session once, and turns + * the service's error bodies into the message the panel shows. + */ + readonly fetch = async (input: RequestInfo | URL, init: RequestInit = {}): Promise => { + let response = await this.send(input, await this.authorised(init)); + if (response.status === 401) { + this.session = null; + response = await this.send(input, await this.authorised(init)); + } + if (response.ok) return response; + const problem = await problemOf(response); + if (TURNED_AWAY.has(problem.code ?? '')) this.turnedAway(); + return new Response(problem.message ?? 'Ask AI could not answer just now. Please try again shortly.', { + status: response.status + }); + }; + + private async authorised(init: RequestInit): Promise { + const { token } = await this.currentSession(); + const headers = new Headers(init.headers); + headers.set('authorization', `Bearer ${token}`); + return { ...init, headers, credentials: 'omit' }; + } + + private currentSession(): Promise<{ token: string; expiresAt: number }> { + const session = this.session; + if (session) { + return session.then((current) => (current.expiresAt - RENEW_EARLY_MS > Date.now() ? current : this.renew())); + } + return this.renew(); + } + + private renew(): Promise<{ token: string; expiresAt: number }> { + const opened = this.open(); + this.session = opened; + opened.catch(() => { + if (this.session === opened) this.session = null; + }); + return opened; + } + + private async open(): Promise<{ token: string; expiresAt: number }> { + let response: Response; + try { + response = await this.send(`${this.copilot.apiUrl}/v1/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key: this.copilot.key }), + credentials: 'omit' + }); + } catch { + throw new Error('Ask AI is unavailable right now. Please try again later.'); + } + if (!response.ok) { + const problem = await problemOf(response); + if (TURNED_AWAY.has(problem.code ?? '')) this.turnedAway(); + throw new Error(problem.message ?? 'Ask AI is unavailable for this portal.'); + } + const { token, expiresAt } = (await response.json()) as { token: string; expiresAt: string }; + return { token, expiresAt: Date.parse(expiresAt) }; + } +} + +async function problemOf(response: Response): Promise { + try { + return (await response.json()) as Problem; + } catch { + return {}; + } +} diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index 34a01b494..bc971ffbe 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -1,15 +1,47 @@ import { useMemo, type ReactNode } from 'react'; import type * as PageTree from 'fumadocs-core/page-tree'; import { DocsLayout as NotebookLayout } from 'fumadocs-ui/layouts/notebook'; +import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; +import { CopilotProvider, useCopilotPanel } from '@/components/copilot'; import { baseOptions } from './layout.shared'; +import { portal } from './portal'; import { portalTabs } from './tabs'; -export function PortalLayout({ tree, children }: Readonly<{ tree: PageTree.Root; children: ReactNode }>) { +type LayoutProps = Readonly<{ tree: PageTree.Root; children: ReactNode }>; + +// Adds the Ask AI panel when `portal.ai.copilot` is set. The chat code is imported statically, +// so it ships in every portal; load it lazily if bundle size matters. +export function PortalLayout({ tree, children }: LayoutProps) { + if (portal.copilot === null) return {children}; + return ( + + {children} + + ); +} + +// Separate, because the panel state exists only inside `CopilotProvider`. +function NotebookWithCopilot({ tree, children }: LayoutProps) { + return ( + + {children} + + ); +} + +function Notebook({ tree, aiChat, children }: LayoutProps & { aiChat?: AIChatOptions }) { const base = baseOptions(); const tabs = useMemo(() => portalTabs(tree), [tree]); // The header spans the page, and `navbar` puts the tab switcher in it rather than the sidebar. return ( - + {children} ); diff --git a/portal-template/src/lib/portal-types.ts b/portal-template/src/lib/portal-types.ts index 8762b2eb2..5da901ef5 100644 --- a/portal-template/src/lib/portal-types.ts +++ b/portal-template/src/lib/portal-types.ts @@ -33,4 +33,15 @@ export interface Portal { links: PortalLink[]; /** Whether each page offers to open itself in an external AI assistant. */ pageActions: boolean; + /** The Ask AI chat, or null when the portal offers none. */ + copilot: PortalCopilot | null; +} + +export interface PortalCopilot { + /** A public key, bound to this portal and to the origins allowed to use it. */ + key: string; + /** The Ask AI service's origin. */ + apiUrl: string; + /** Starter questions on a new chat; empty for the chat's own. */ + suggestions: string[]; } diff --git a/portal-template/src/styles/app.css b/portal-template/src/styles/app.css index 6770b3918..8e44f39e0 100644 --- a/portal-template/src/styles/app.css +++ b/portal-template/src/styles/app.css @@ -2,6 +2,7 @@ @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css'; @import 'fumadocs-openapi/css/preset.css'; +@import '@fumadocs/ai-chat/css/preset.css'; /* Served with the site: a cross-origin font stylesheet would hold up first paint and hydration. */ @import '@fontsource-variable/geist'; @import '@fontsource-variable/geist-mono'; diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts new file mode 100644 index 000000000..b5787fdbf --- /dev/null +++ b/src/actions/portal/copilot/sync.ts @@ -0,0 +1,86 @@ +import { envInfo } from '../../../infrastructure/env-info.js'; +import { CopilotIngestService } from '../../../infrastructure/services/copilot-ingest-service.js'; +import { PortalCopilotSyncPrompts } from '../../../prompts/portal/copilot/sync.js'; +import { CommandMetadata } from '../../../types/common/command-metadata.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { PLACEHOLDER_SITE } from '../../../types/portal/config/site-config.js'; +import { GeneratedPortalContext } from '../../../types/portal/copilot/generated-portal-context.js'; +import { ProjectContext } from '../../../types/project-context.js'; +import { ActionResult } from '../../action-result.js'; + +export class PortalCopilotSyncAction { + private readonly prompts = new PortalCopilotSyncPrompts(); + private readonly ingestService = new CopilotIngestService(); + + constructor( + private readonly configDir: DirectoryPath, + private readonly commandMetadata: CommandMetadata, + private readonly authKey: string | null = null + ) {} + + public readonly execute = async ( + project: ProjectContext, + portalDirectory: DirectoryPath, + wait: boolean + ): Promise => { + // The site's name is never synced, so the specifications need not be read to default it. + const settings = await project.portalSource().resolveSettings(PLACEHOLDER_SITE); + if (settings.isErr()) { + this.prompts.sourceProblem(settings.error, project.sourceDirectory()); + return ActionResult.failed(); + } + const { config } = settings.value; + + const copilot = config.identity().copilot; + if (copilot === null) { + this.prompts.copilotNotConfigured(project.sourceDirectory()); + return ActionResult.failed(); + } + + const bundle = await new GeneratedPortalContext(portalDirectory).bundle( + config.siteAddress(), + envInfo.getCLIVersion(), + new Date() + ); + if (bundle === null) { + this.prompts.portalNotGenerated(portalDirectory); + return ActionResult.failed(); + } + if (bundle.pageCount() === 0) { + this.prompts.noPages(portalDirectory); + return ActionResult.failed(); + } + + const { shell } = this.commandMetadata; + const receipt = await this.prompts.upload( + this.ingestService.upload(copilot, bundle, this.configDir, shell, this.authKey), + bundle.pageCount() + ); + if (receipt.isErr()) { + this.prompts.syncFailed(receipt.error, copilot.apiUrl); + return ActionResult.failed(); + } + if (receipt.value.status === 'unchanged') { + this.prompts.alreadyUpToDate(receipt.value.pageCount); + return ActionResult.success(); + } + if (!wait) { + this.prompts.indexingQueued(); + return ActionResult.success(); + } + + const indexed = await this.prompts.awaitIndexing( + this.ingestService.awaitIndexing(copilot, receipt.value.versionId, this.configDir, shell, this.authKey) + ); + if (indexed.isErr()) { + this.prompts.syncFailed(indexed.error, copilot.apiUrl); + return ActionResult.failed(); + } + if (indexed.value.status === 'superseded') { + this.prompts.superseded(); + } else { + this.prompts.indexed(indexed.value); + } + return ActionResult.success(); + }; +} diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 2a1f42b3b..7646dc9ce 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -44,7 +44,9 @@ export class GenerateAction { return await new PreparePortalProjectAction(this.configDir, this.commandMetadata, this.authKey).execute(project, { confirm: () => this.confirmOverwrite(portalContext, portalDirectory, force), onPrepared: (portalProject, source) => - this.build(portalProject, source.config.siteAddress(), portalContext, portalDirectory, zipPortal) + this.build(portalProject, source.config.siteAddress(), portalContext, portalDirectory, zipPortal, { + copilot: source.config.identity().copilot !== null + }) }); }; @@ -65,7 +67,8 @@ export class GenerateAction { site: SiteAddress | null, portalContext: PortalContext, portalDirectory: DirectoryPath, - zipPortal: boolean + zipPortal: boolean, + { copilot }: { copilot: boolean } ): Promise { const build = await this.prompts.buildPortal(this.buildService.build(portalProject)); @@ -83,6 +86,7 @@ export class GenerateAction { this.prompts.portalGenerated(portalDirectory); this.prompts.nextSteps(portalDirectory, zipPortal, site); + if (copilot) this.prompts.copilotSyncNextStep(); return ActionResult.success(); } diff --git a/src/commands/portal/copilot/sync.ts b/src/commands/portal/copilot/sync.ts new file mode 100644 index 000000000..3f9de377b --- /dev/null +++ b/src/commands/portal/copilot/sync.ts @@ -0,0 +1,69 @@ +import { Command, Flags } from '@oclif/core'; +import { PortalCopilotSyncAction } from '../../../actions/portal/copilot/sync.js'; +import { TelemetryService } from '../../../infrastructure/services/telemetry-service.js'; +import { format, intro, outro } from '../../../prompts/format.js'; +import { CommandMetadata } from '../../../types/common/command-metadata.js'; +import { PortalCopilotSyncFailedEvent } from '../../../types/events/portal-copilot-sync-failed.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { FlagsProvider } from '../../../types/flags-provider.js'; +import { ProjectContext } from '../../../types/project-context.js'; + +export default class PortalCopilotSync extends Command { + static readonly summary = "Update the portal's Ask AI chat with the portal's current pages."; + + static readonly description = `Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as they are now. Run it after deploying a portal generated with 'apimatic portal generate'. + +The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json'. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones.`; + + static readonly cmdTxt = format.cmd('apimatic', 'portal', 'copilot', 'sync'); + + static readonly examples = [ + PortalCopilotSync.cmdTxt, + `${PortalCopilotSync.cmdTxt} ${format.flag('input', './')} ${format.flag('destination', './portal')}`, + `${PortalCopilotSync.cmdTxt} ${format.flag('no-wait')}` + ]; + + static flags = { + wait: Flags.boolean({ + default: true, + allowNo: true, + description: 'wait until the pages are indexed; with --no-wait, return as soon as they are uploaded.' + }), + destination: Flags.string({ + char: 'd', + description: '[default: /portal] path to the generated portal to sync.' + }), + ...FlagsProvider.input, + ...FlagsProvider.authKey + }; + + async run(): Promise { + const { flags } = await this.parse(PortalCopilotSync); + const { input, destination, wait, 'auth-key': authKey } = flags; + + const project = ProjectContext.at(input); + const commandMetadata: CommandMetadata = { + commandName: PortalCopilotSync.id, + shell: this.config.shell + }; + + intro('Sync Ask AI'); + const action = new PortalCopilotSyncAction(this.getConfigDir(), commandMetadata, authKey); + const result = await action.execute(project, project.portalDirectory(destination), wait); + outro(result); + + await result.mapAll( + async () => {}, + async () => + await new TelemetryService(this.getConfigDir()).trackEvent( + new PortalCopilotSyncFailedEvent(result.getMessage(), PortalCopilotSync.id, flags), + commandMetadata.shell + ), + async () => {} + ); + } + + private readonly getConfigDir = () => { + return new DirectoryPath(this.config.configDir); + }; +} diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index 6a53f3b0f..c11d80671 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -27,6 +27,9 @@ export const COPIED_DEPENDENCIES = ['@fontsource-variable/geist', '@fontsource-v // pnpm global install, `npx` or `pnpm dlx` the package has no nested `node_modules`, and a // single link also lets Vite write its scratch files into the CLI's own install directory. export const LINKED_DEPENDENCIES = [ + '@ai-sdk/react', + '@fumadocs/ai-chat', + 'ai', '@fumadocs/api-docs', '@scalar/json-magic', '@tailwindcss/vite', diff --git a/src/infrastructure/services/copilot-ingest-service.ts b/src/infrastructure/services/copilot-ingest-service.ts new file mode 100644 index 000000000..9df37caa6 --- /dev/null +++ b/src/infrastructure/services/copilot-ingest-service.ts @@ -0,0 +1,195 @@ +import axios, { AxiosResponse } from 'axios'; +import { err, ok, Result } from 'neverthrow'; +import { getAuthInfo } from '../../client-utils/auth-manager.js'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { PortalCopilot } from '../../types/portal/config/copilot-config.js'; +import { CopilotBundle } from '../../types/portal/copilot/copilot-bundle.js'; +import { IndexedVersion, IndexingState, IngestReceipt, IngestStatus } from '../../types/portal/copilot/ingest.js'; +import { isJsonObject } from '../../utils/json-utils.js'; +import { envInfo } from '../env-info.js'; +import { handleServiceError, ServiceError } from '../service-error.js'; +import { sleep } from '../timer-extensions.js'; + +export const COPILOT_INGEST_TIMINGS = { + pollIntervalMs: 2000, + indexTimeoutMs: 10 * 60 * 1000, + uploadTimeoutMs: 120_000, + statusTimeoutMs: 15_000 +}; + +export type CopilotIngestTimings = Partial; + +/** All the service needs of the portal's Ask AI settings: where it is, and which portal's chat it is. */ +export type CopilotTarget = Pick; + +const STATES: readonly IndexingState[] = ['queued', 'building', 'active', 'superseded', 'failed']; + +/** Uploads a portal's pages to the Ask AI service, which swaps them in once it has indexed them all. */ +export class CopilotIngestService { + private readonly timings: typeof COPILOT_INGEST_TIMINGS; + + constructor(timings: CopilotIngestTimings = {}) { + this.timings = { ...COPILOT_INGEST_TIMINGS, ...timings }; + } + + public async upload( + copilot: CopilotTarget, + bundle: CopilotBundle, + configDir: DirectoryPath, + shell: string, + authKey: string | null + ): Promise> { + const token = await this.token(configDir, authKey); + if (token === null) { + return err(ServiceError.unauthorizedWithHint(null)); + } + + try { + const response = await this.axiosInstance(copilot, shell, token, this.timings.uploadTimeoutMs).post( + '/v1/ingest', + bundle.gzipped(), + { + headers: { 'Content-Type': 'application/gzip' }, + // The service owns the size limit, and answers 413 past it. + maxBodyLength: Infinity + } + ); + if (response.status !== 200 && response.status !== 202) { + return err(failureOf(response)); + } + const receipt = response.data as unknown; + return isReceipt(receipt) ? ok(receipt) : err(ServiceError.InvalidResponse); + } catch (error) { + return err(transportFailure(error)); + } + } + + /** Waits for the uploaded version to finish indexing; a failed one is an error carrying the service's reason. */ + public async awaitIndexing( + copilot: CopilotTarget, + versionId: string, + configDir: DirectoryPath, + shell: string, + authKey: string | null + ): Promise> { + const token = await this.token(configDir, authKey); + if (token === null) { + return err(ServiceError.unauthorizedWithHint(null)); + } + + const deadline = Date.now() + this.timings.indexTimeoutMs; + for (;;) { + await sleep(this.timings.pollIntervalMs); + + const current = await this.status(copilot, versionId, shell, token); + if (current.isErr()) { + return err(current.error); + } + const { status, error } = current.value; + if (status === 'active' || status === 'superseded') { + return ok({ ...current.value, status }); + } + if (status === 'failed') { + return err(ServiceError.badRequest(error ?? 'The Ask AI service could not index the pages.', {})); + } + // Read after the status, so a version that finished during the last wait still counts. + if (Date.now() >= deadline) { + const minutes = Math.max(1, Math.round(this.timings.indexTimeoutMs / 60_000)); + return err(ServiceError.timeout(`Indexing did not finish within ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`)); + } + } + } + + private async status( + copilot: CopilotTarget, + versionId: string, + shell: string, + token: string + ): Promise> { + try { + const response = await this.axiosInstance(copilot, shell, token, this.timings.statusTimeoutMs).get( + `/v1/ingest/${encodeURIComponent(versionId)}` + ); + if (response.status !== 200) { + return err(failureOf(response)); + } + const status = statusOf(response.data); + return status === null ? err(ServiceError.InvalidResponse) : ok(status); + } catch (error) { + return err(transportFailure(error)); + } + } + + private async token(configDir: DirectoryPath, authKey: string | null): Promise { + // `auth logout` blanks the stored key rather than deleting the file, so the key is checked, not the file. + return authKey || (await getAuthInfo(configDir.toString()))?.authKey || null; + } + + private axiosInstance(copilot: CopilotTarget, shell: string, token: string, timeout: number) { + return axios.create({ + baseURL: copilot.apiUrl, + timeout, + validateStatus: () => true, + headers: { + 'User-Agent': envInfo.getUserAgent(shell), + Authorization: `X-Auth-Key ${token}`, + 'X-Copilot-Key': copilot.key + } + }); + } +} + +/** The service explains every refusal as `{ code, message }`; the message is what the user is shown. */ +function failureOf(response: AxiosResponse): ServiceError { + const body = response.data as unknown; + const message = isJsonObject(body) && typeof body.message === 'string' ? body.message : null; + switch (response.status) { + case 400: + case 413: + return message === null ? ServiceError.InvalidResponse : ServiceError.badRequest(message, {}); + case 401: + return ServiceError.unauthorizedWithHint(message); + case 403: + return ServiceError.forbidden(message ?? 'Access denied.'); + case 404: + return message === null ? ServiceError.NotFound : ServiceError.notFound(message); + default: + return response.status >= 500 ? ServiceError.ServerError : ServiceError.InvalidResponse; + } +} + +// Every request answers with its status, so an axios error here is one that never got an answer. +function transportFailure(error: unknown): ServiceError { + return axios.isAxiosError(error) && error.response === undefined + ? ServiceError.NetworkError + : handleServiceError(error); +} + +function isReceipt(body: unknown): body is IngestReceipt { + return ( + isJsonObject(body) && + typeof body.versionId === 'string' && + (body.status === 'queued' || body.status === 'unchanged') && + typeof body.pageCount === 'number' + ); +} + +// Only the state is needed to decide; the counts are reported, and a missing one reads as none. +function statusOf(body: unknown): IngestStatus | null { + if (!isJsonObject(body) || typeof body.versionId !== 'string' || !STATES.includes(body.status as IndexingState)) { + return null; + } + const count = (key: string): number => { + const value = body[key]; + return typeof value === 'number' ? value : 0; + }; + return { + versionId: body.versionId, + status: body.status as IndexingState, + pageCount: count('pageCount'), + changedPages: count('changedPages'), + removedPages: count('removedPages'), + chunkCount: count('chunkCount'), + error: typeof body.error === 'string' && body.error !== '' ? body.error : null + }; +} diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts new file mode 100644 index 000000000..10a49ae88 --- /dev/null +++ b/src/prompts/portal/copilot/sync.ts @@ -0,0 +1,100 @@ +import { log } from '@clack/prompts'; +import { Result } from 'neverthrow'; +import { ServiceError, ServiceErrorCode } from '../../../infrastructure/service-error.js'; +import { APIMATIC_CONFIG_FILE_NAME } from '../../../types/apimatic-config/document.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { FileName } from '../../../types/file/fileName.js'; +import { FilePath } from '../../../types/file/filePath.js'; +import { IndexedVersion, IngestReceipt } from '../../../types/portal/copilot/ingest.js'; +import { PortalSourceProblem } from '../../../types/portal/portal-source.js'; +import { format as f } from '../../format.js'; +import { withSpinner } from '../../prompt.js'; +import { reportSourceProblem } from '../source.js'; + +const COPILOT_EXAMPLE = '"ai": { "copilot": { "key": "pk_โ€ฆ", "apiUrl": "https://โ€ฆ" } }'; + +const pages = (count: number): string => `${count} ${count === 1 ? 'page' : 'pages'}`; + +export class PortalCopilotSyncPrompts { + public sourceProblem(problem: PortalSourceProblem, sourceDirectory: DirectoryPath) { + reportSourceProblem(problem, sourceDirectory); + } + + public copilotNotConfigured(sourceDirectory: DirectoryPath) { + const config = new FilePath(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME)); + const message = + `The portal has no Ask AI chat to sync. Add ${f.var(COPILOT_EXAMPLE)} to the ${f.var('portal')} block of ` + + `${f.path(config)}, then generate and deploy the portal again.`; + log.error(message); + } + + public portalNotGenerated(portalDirectory: DirectoryPath) { + const message = + `No generated portal was found at ${f.path(portalDirectory)}. ` + + `Generate the portal first: ${f.cmdAlt('apimatic', 'portal', 'generate')}.`; + log.error(message); + } + + public noPages(portalDirectory: DirectoryPath) { + const message = + `The portal at ${f.path(portalDirectory)} has no pages Ask AI can read. ` + + `Generate it again with ${f.cmdAlt('apimatic', 'portal', 'generate')}.`; + log.error(message); + } + + public upload(fn: Promise>, pageCount: number) { + return withSpinner( + `Uploading ${pages(pageCount)} to Ask AI`, + ({ status }) => (status === 'unchanged' ? 'Uploaded; nothing has changed.' : 'Uploaded.'), + 'Upload failed.', + fn + ); + } + + public awaitIndexing(fn: Promise>) { + return withSpinner( + 'Indexing the pages', + ({ status }) => (status === 'active' ? 'Indexed.' : 'Replaced by a newer sync.'), + 'Indexing did not finish.', + fn, + { indicator: 'timer' } + ); + } + + public syncFailed(error: ServiceError, apiUrl: string) { + switch (error.code) { + case ServiceErrorCode.NetworkError: + log.error(`The Copilot service at ${f.link(apiUrl)} could not be reached. Check the address and try again.`); + return; + case ServiceErrorCode.Forbidden: + log.error( + `${error.errorMessage} The account of the auth key must own the portal that the Copilot key belongs to.` + ); + return; + case ServiceErrorCode.Timeout: + log.error( + `${error.errorMessage} The service keeps indexing, and Ask AI answers from the previous pages until it ` + + `is done; check the portal's Ask AI again in a few minutes.` + ); + return; + default: + log.error(error.errorMessage); + } + } + + public alreadyUpToDate(pageCount: number) { + log.info(`Ask AI is already up to date with the portal's ${pages(pageCount)}.`); + } + + public indexingQueued() { + log.info('Ask AI answers from the new pages once the service has indexed them.'); + } + + public indexed({ pageCount, changedPages, removedPages }: IndexedVersion) { + log.info(`Ask AI now answers from ${pages(pageCount)} (${changedPages} changed, ${removedPages} removed).`); + } + + public superseded() { + log.warn('A newer sync replaced this one before it was indexed, so Ask AI answers from that one.'); + } +} diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 58f4d49e6..e639ab772 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -113,4 +113,10 @@ export class PortalGeneratePrompts { ].join('\n'); noteWrapped(message, 'Next steps'); } + + public copilotSyncNextStep() { + log.info( + `Deploy the portal, then run ${f.cmdAlt('apimatic', 'portal', 'copilot', 'sync')} so Ask AI answers from it.` + ); + } } diff --git a/src/types/events/portal-copilot-sync-failed.ts b/src/types/events/portal-copilot-sync-failed.ts new file mode 100644 index 000000000..3d1e1d6f3 --- /dev/null +++ b/src/types/events/portal-copilot-sync-failed.ts @@ -0,0 +1,9 @@ +import { DomainEvent } from './domain-event.js'; + +export class PortalCopilotSyncFailedEvent extends DomainEvent { + protected readonly eventName = PortalCopilotSyncFailedEvent.name; + + constructor(message: string, commandName: string, flags: Record) { + super(message, commandName, flags); + } +} diff --git a/src/types/portal/config/ai-config.ts b/src/types/portal/config/ai-config.ts index d188c0552..e76db938e 100644 --- a/src/types/portal/config/ai-config.ts +++ b/src/types/portal/config/ai-config.ts @@ -1,21 +1,23 @@ import { Result } from 'neverthrow'; -import { allOf, namespace, optionalBoolean, Parsed, unknownKeys } from './fields.js'; +import { CopilotConfig, PortalCopilot } from './copilot-config.js'; +import { allOf, namespace, optional, optionalBoolean, Parsed, unknownKeys } from './fields.js'; -const KNOWN = ['pageActions']; +const KNOWN = ['pageActions', 'copilot']; export class AiConfig { - private constructor(private readonly pageActions: boolean) {} + private constructor(private readonly pageActions: boolean, private readonly copilotSettings: CopilotConfig | null) {} - public static readonly defaults = new AiConfig(true); + public static readonly defaults = new AiConfig(true, null); public static parse(value: unknown, path: string): Parsed { return namespace(value, path).andThen((data) => allOf( unknownKeys(data, KNOWN, path), Result.combineWithAllErrors([ - optionalBoolean(data.pageActions, `${path}.pageActions`, AiConfig.defaults.pageActions) + optionalBoolean(data.pageActions, `${path}.pageActions`, AiConfig.defaults.pageActions), + optional(data.copilot, (present) => CopilotConfig.parse(present, `${path}.copilot`)) ]) - ).map(([pageActions]) => new AiConfig(pageActions)) + ).map(([pageActions, copilot]) => new AiConfig(pageActions, copilot)) ); } @@ -27,7 +29,15 @@ export class AiConfig { return this.pageActions; } - public toJSON(): { pageActions: boolean } { - return { pageActions: this.pageActions }; + /** The Ask AI chat the portal offers, or null when it offers none. */ + public copilot(): PortalCopilot | null { + return this.copilotSettings?.identity() ?? null; + } + + public toJSON(): { pageActions: boolean; copilot?: ReturnType } { + return { + pageActions: this.pageActions, + ...(this.copilotSettings === null ? {} : { copilot: this.copilotSettings.toJSON() }) + }; } } diff --git a/src/types/portal/config/copilot-config.ts b/src/types/portal/config/copilot-config.ts new file mode 100644 index 000000000..7e87ca210 --- /dev/null +++ b/src/types/portal/config/copilot-config.ts @@ -0,0 +1,69 @@ +import { err, ok, Result } from 'neverthrow'; +import { allOf, isWebAddress, namespace, nonEmptyString, optional, Parsed, unknownKeys } from './fields.js'; + +const KNOWN = ['key', 'apiUrl', 'suggestions']; +const MAX_SUGGESTIONS = 3; +const MAX_SUGGESTION_LENGTH = 120; + +export interface PortalCopilot { + key: string; + /** The Ask AI service's origin. */ + apiUrl: string; + /** Starter questions on a new chat; empty for the chat's own. */ + suggestions: string[]; +} + +type CopilotBlock = Omit & { suggestions?: string[] }; + +export class CopilotConfig { + private constructor( + private readonly key: string, + private readonly apiOrigin: string, + private readonly suggestions: string[] + ) {} + + public static parse(value: unknown, path: string): Parsed { + return namespace(value, path).andThen((data) => + allOf( + unknownKeys(data, KNOWN, path), + // The key is not checked here: the Ask AI service validates it, and its design may change. + Result.combineWithAllErrors([ + nonEmptyString(data.key, `${path}.key`), + serviceOrigin(data.apiUrl, `${path}.apiUrl`), + optional(data.suggestions, (present) => suggestionList(present, `${path}.suggestions`)) + ]) + ).map(([key, apiOrigin, suggestions]) => new CopilotConfig(key, apiOrigin, suggestions ?? [])) + ); + } + + public identity(): PortalCopilot { + return { key: this.key, apiUrl: this.apiOrigin, suggestions: this.suggestions }; + } + + public toJSON(): CopilotBlock { + return { + key: this.key, + apiUrl: this.apiOrigin, + ...(this.suggestions.length > 0 ? { suggestions: this.suggestions } : {}) + }; + } +} + +// Required while there is no hosted service; once there is, default to its address and keep this as an override. +function serviceOrigin(value: unknown, path: string): Parsed { + return typeof value === 'string' && isWebAddress(value) + ? ok(new URL(value).origin) + : err([`'${path}' must be the Ask AI service's address, starting with 'https://' or 'http://'.`]); +} + +function suggestionList(value: unknown, path: string): Parsed { + const valid = + Array.isArray(value) && + value.length <= MAX_SUGGESTIONS && + value.every((item) => typeof item === 'string' && item.trim().length > 0 && item.length <= MAX_SUGGESTION_LENGTH); + return valid + ? ok(value.map((item: string) => item.trim())) + : err([ + `'${path}' must be a list of up to ${MAX_SUGGESTIONS} questions, each a non-empty string of at most ${MAX_SUGGESTION_LENGTH} characters.` + ]); +} diff --git a/src/types/portal/copilot/copilot-bundle.ts b/src/types/portal/copilot/copilot-bundle.ts new file mode 100644 index 000000000..eb715441d --- /dev/null +++ b/src/types/portal/copilot/copilot-bundle.ts @@ -0,0 +1,57 @@ +import { Buffer } from 'node:buffer'; +import { gzipSync } from 'node:zlib'; +import { SiteAddress } from '../config/site-address.js'; +import { ListedPage } from './llms-index.js'; + +export interface BundlePage extends ListedPage { + markdown: string; +} + +interface BundleDocument { + formatVersion: 1; + generator: { name: string; version: string }; + generatedAt: string; + siteUrl: string | null; + basePath: string; + pages: { + path: string; + title: string; + description?: string; + markdown: string; + }[]; +} + +const GENERATOR_NAME = 'apimatic-cli'; + +/** The portal's pages as the Ask AI service ingests them, in its format version 1. */ +export class CopilotBundle { + private constructor(private readonly document: BundleDocument) {} + + public static of(pages: BundlePage[], site: SiteAddress | null, cliVersion: string, at: Date): CopilotBundle { + return new CopilotBundle({ + formatVersion: 1, + generator: { name: GENERATOR_NAME, version: cliVersion }, + generatedAt: at.toISOString(), + siteUrl: site === null ? null : site.toString(), + basePath: site?.path() ?? '/', + pages: pages.map(({ path, title, description, markdown }) => ({ + path, + title, + ...(description === null ? {} : { description }), + markdown + })) + }); + } + + public pageCount(): number { + return this.document.pages.length; + } + + public gzipped(): Buffer { + return gzipSync(JSON.stringify(this.document)); + } + + public toJSON(): BundleDocument { + return this.document; + } +} diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts new file mode 100644 index 000000000..888775370 --- /dev/null +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -0,0 +1,43 @@ +import { FileService } from '../../../infrastructure/file-service.js'; +import { DirectoryPath } from '../../file/directoryPath.js'; +import { FileName } from '../../file/fileName.js'; +import { FilePath } from '../../file/filePath.js'; +import { SiteAddress } from '../config/site-address.js'; +import { BundlePage, CopilotBundle } from './copilot-bundle.js'; +import { ListedPage, listedPages } from './llms-index.js'; + +/** A portal `portal generate` wrote, read for the pages Ask AI answers from. */ +export class GeneratedPortalContext { + private readonly fileService = new FileService(); + + constructor(private readonly portalDirectory: DirectoryPath) {} + + private get index(): FilePath { + return new FilePath(this.portalDirectory, new FileName('llms.txt')); + } + + /** Null when the directory holds no generated portal. A listed page without its Markdown twin is left out. */ + public async bundle(site: SiteAddress | null, cliVersion: string, at: Date): Promise { + if (!(await this.fileService.fileExists(this.index))) { + return null; + } + const listed = listedPages(await this.fileService.getContents(this.index), site); + const pages: BundlePage[] = []; + for (const page of listed) { + const markdown = this.markdownOf(page); + if (await this.fileService.fileExists(markdown)) { + pages.push({ ...page, markdown: await this.fileService.getContents(markdown) }); + } + } + return CopilotBundle.of(pages, site, cliVersion, at); + } + + private markdownOf({ path }: ListedPage): FilePath { + if (path === '/') { + return new FilePath(this.portalDirectory, new FileName('index.md')); + } + const segments = path.slice(1).split('/'); + const page = segments.pop()!; + return new FilePath(this.portalDirectory.join(...segments), new FileName(`${page}.md`)); + } +} diff --git a/src/types/portal/copilot/ingest.ts b/src/types/portal/copilot/ingest.ts new file mode 100644 index 000000000..edd825b08 --- /dev/null +++ b/src/types/portal/copilot/ingest.ts @@ -0,0 +1,21 @@ +/** What the Ask AI service answers an upload with: `unchanged` when the pages match the version it already serves. */ +export interface IngestReceipt { + versionId: string; + status: 'queued' | 'unchanged'; + pageCount: number; +} + +export type IndexingState = 'queued' | 'building' | 'active' | 'superseded' | 'failed'; + +export interface IngestStatus { + versionId: string; + status: IndexingState; + pageCount: number; + changedPages: number; + removedPages: number; + chunkCount: number; + error: string | null; +} + +/** How an upload's indexing ended: serving its pages, or replaced by a newer upload before it could. */ +export type IndexedVersion = IngestStatus & { status: 'active' | 'superseded' }; diff --git a/src/types/portal/copilot/llms-index.ts b/src/types/portal/copilot/llms-index.ts new file mode 100644 index 000000000..d4c34e45b --- /dev/null +++ b/src/types/portal/copilot/llms-index.ts @@ -0,0 +1,50 @@ +import { SiteAddress } from '../config/site-address.js'; + +/** One page `llms.txt` lists, addressed from the portal's root: `/` for the home page, `/guides/intro` for one under it. */ +export interface ListedPage { + path: string; + title: string; + description: string | null; +} + +const LINK = /^\s*[-*]\s+\[(.*)\]\(([^)\s]+)\)(?::\s*(.*?))?\s*$/; + +const DOWNLOADS = '/__downloads/'; + +/** The pages `llms.txt` lists under the portal's base path, each once, in the order it lists them. */ +export function listedPages(llmsTxt: string, site: SiteAddress | null): ListedPage[] { + const basePath = site?.path() ?? '/'; + const pages = new Map(); + for (const line of llmsTxt.split(/\r?\n/)) { + const match = LINK.exec(line); + if (match === null) { + continue; + } + const [, title, href, description] = match; + const path = portalPath(href, basePath); + if (path !== null && !pages.has(path)) { + pages.set(path, { path, title: title.trim(), description: description ? description : null }); + } + } + return [...pages.values()]; +} + +function portalPath(href: string, basePath: string): string | null { + const [address] = href.split(/[?#]/); + if (!address.startsWith('/') || address.includes(DOWNLOADS)) { + return null; + } + const withSlash = address.endsWith('/') ? address : `${address}/`; + if (!withSlash.startsWith(basePath)) { + return null; + } + const segments = withSlash + .slice(basePath.length) + .split('/') + .filter((segment) => segment !== ''); + // A segment that climbs would name a file outside the portal. + if (segments.some((segment) => segment === '.' || segment === '..')) { + return null; + } + return `/${segments.join('/')}`; +} diff --git a/src/types/portal/portal-config.ts b/src/types/portal/portal-config.ts index 56cf2ce4b..fa4ac7876 100644 --- a/src/types/portal/portal-config.ts +++ b/src/types/portal/portal-config.ts @@ -2,6 +2,7 @@ import { err, ok, Result } from 'neverthrow'; import { isJsonObject } from '../../utils/json-utils.js'; import { UrlPath } from '../file/urlPath.js'; import { AiConfig } from './config/ai-config.js'; +import { PortalCopilot } from './config/copilot-config.js'; import { BrandConfig, ColorMode } from './config/brand-config.js'; import { allOf, optional, Parsed, unknownKeys } from './config/fields.js'; import { Link } from './config/link.js'; @@ -35,6 +36,8 @@ export interface PortalIdentity { links: PortalLink[]; /** Whether each page offers to open itself in an external AI assistant. */ pageActions: boolean; + /** The Ask AI chat, or null when the portal offers none. */ + copilot: PortalCopilot | null; } const BLOCK = 'portal'; @@ -125,7 +128,8 @@ export class PortalConfig { favicon: favicon === null ? null : { url: favicon.portalPath(), type: favicon.imageType() }, colorMode: this.brand.mode(), links: this.navigation.headerLinks().map(portalLink), - pageActions: this.ai.offersPageActions() + pageActions: this.ai.offersPageActions(), + copilot: this.ai.copilot() }; } diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts new file mode 100644 index 000000000..0797323ad --- /dev/null +++ b/test/actions/portal/copilot/sync.test.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { err, ok } from 'neverthrow'; +import { PortalCopilotSyncAction } from '../../../../src/actions/portal/copilot/sync'; +import { CopilotIngestService } from '../../../../src/infrastructure/services/copilot-ingest-service'; +import { ServiceError, ServiceErrorCode } from '../../../../src/infrastructure/service-error'; +import { PortalCopilotSyncPrompts } from '../../../../src/prompts/portal/copilot/sync'; +import { CommandMetadata } from '../../../../src/types/common/command-metadata'; +import { DirectoryPath } from '../../../../src/types/file/directoryPath'; +import { CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; +import { ProjectContext } from '../../../../src/types/project-context'; + +const COMMAND_METADATA: CommandMetadata = { commandName: 'portal:copilot:sync', shell: 'test' }; +const COPILOT = { key: 'pk_test', apiUrl: 'http://copilot.test' }; + +const indexed = (status: 'active' | 'superseded') => ({ + versionId: 'v-1', + status, + pageCount: 2, + changedPages: 1, + removedPages: 0, + chunkCount: 5, + error: null +}); + +describe('PortalCopilotSyncAction', () => { + let root: string; + let prompts: sinon.SinonStubbedInstance; + let upload: sinon.SinonStub; + let awaitIndexing: sinon.SinonStub; + + const write = (relative: string, contents: string) => { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + }; + + const writeConfig = (ai: unknown) => + write( + 'src/apimatic.json', + JSON.stringify({ + portal: { site: { name: 'Acme', url: 'https://example.com/docs' }, ...(ai ? { ai } : {}) }, + languages: { typescript: {} } + }) + ); + + const writePortal = () => { + write('portal/llms.txt', ['- [Docs](/docs)', ' - [Intro](/docs/guides/intro): Start'].join('\n')); + write('portal/index.md', '# Docs'); + write('portal/guides/intro.md', '# Intro'); + }; + + const execute = (wait = true) => { + const project = ProjectContext.in(new DirectoryPath(root)); + return new PortalCopilotSyncAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute( + project, + project.portalDirectory(), + wait + ); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-sync-')); + prompts = sinon.stub(PortalCopilotSyncPrompts.prototype); + // The spinners would render to stdout; pass the underlying promise straight through. + prompts.upload.callsFake((fn) => fn); + prompts.awaitIndexing.callsFake((fn) => fn); + upload = sinon + .stub(CopilotIngestService.prototype, 'upload') + .resolves(ok({ versionId: 'v-1', status: 'queued', pageCount: 2 })); + awaitIndexing = sinon.stub(CopilotIngestService.prototype, 'awaitIndexing').resolves(ok(indexed('active'))); + writeConfig({ copilot: COPILOT }); + writePortal(); + }); + + afterEach(() => { + sinon.restore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('uploads the portal under its base path and waits until Ask AI answers from it', async () => { + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + const [copilot, bundle, , , authKey] = upload.firstCall.args as [unknown, CopilotBundle, unknown, unknown, string]; + expect(copilot).to.deep.include(COPILOT); + expect(authKey).to.equal('auth-key'); + expect(bundle.toJSON()).to.deep.include({ basePath: '/docs/', siteUrl: 'https://example.com/docs' }); + expect(bundle.toJSON().pages.map(({ path: page }) => page)).to.deep.equal(['/', '/guides/intro']); + expect(awaitIndexing.firstCall.args[1]).to.equal('v-1'); + expect(prompts.indexed.calledOnceWith(indexed('active'))).to.be.true; + }); + + it('reports a portal Ask AI already answers from, without waiting', async () => { + upload.resolves(ok({ versionId: 'v-0', status: 'unchanged', pageCount: 2 })); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(prompts.alreadyUpToDate.calledOnceWith(2)).to.be.true; + expect(awaitIndexing.called).to.be.false; + }); + + it('returns once uploaded with --no-wait', async () => { + const result = await execute(false); + + expect(result.isSuccess()).to.be.true; + expect(prompts.indexingQueued.calledOnce).to.be.true; + expect(awaitIndexing.called).to.be.false; + }); + + it('succeeds, saying so, when a newer sync superseded this one', async () => { + awaitIndexing.resolves(ok(indexed('superseded'))); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(prompts.superseded.calledOnce).to.be.true; + }); + + it("fails with the service's refusal, naming the service", async () => { + const forbidden = ServiceError.forbidden('This key cannot sync this portal.'); + upload.resolves(err(forbidden)); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.syncFailed.calledOnceWith(forbidden, COPILOT.apiUrl)).to.be.true; + }); + + it('fails when the service cannot be reached', async () => { + upload.resolves(err(ServiceError.NetworkError)); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.syncFailed.firstCall.args[0].code).to.equal(ServiceErrorCode.NetworkError); + }); + + it('fails when indexing fails', async () => { + const failed = ServiceError.badRequest('Embedding provider unavailable.', {}); + awaitIndexing.resolves(err(failed)); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.syncFailed.calledOnceWith(failed, COPILOT.apiUrl)).to.be.true; + }); + + it('fails without uploading when the portal has no Ask AI chat', async () => { + writeConfig(null); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.copilotNotConfigured.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); + + it('fails without uploading when apimatic.json is missing', async () => { + fs.rmSync(path.join(root, 'src', 'apimatic.json')); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.sourceProblem.firstCall.args[0]).to.deep.equal({ kind: 'missingConfig' }); + expect(upload.called).to.be.false; + }); + + it('fails without uploading when the portal has not been generated', async () => { + fs.rmSync(path.join(root, 'portal'), { recursive: true }); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.portalNotGenerated.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); + + it('fails without uploading when no listed page has its Markdown', async () => { + fs.rmSync(path.join(root, 'portal', 'index.md')); + fs.rmSync(path.join(root, 'portal', 'guides'), { recursive: true }); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.noPages.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); +}); diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index 66e347500..bac899535 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -239,6 +239,7 @@ describe('GenerateAction', () => { expect(inPortal('404.html')).to.be.true; expect(prompts.portalGenerated.calledOnceWith(portalDirectory)).to.be.true; expect(prompts.nextSteps.calledOnceWith(portalDirectory, false)).to.be.true; + expect(prompts.copilotSyncNextStep.called, 'no Ask AI to sync').to.be.false; }); it('writes an archive instead when asked', async () => { diff --git a/test/commands/portal/copilot/sync-flags.test.ts b/test/commands/portal/copilot/sync-flags.test.ts new file mode 100644 index 000000000..c919d7db8 --- /dev/null +++ b/test/commands/portal/copilot/sync-flags.test.ts @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import PortalCopilotSync from '../../../../src/commands/portal/copilot/sync.js'; +import { flagParser } from '../../flag-parser.js'; + +const { parse } = flagParser(PortalCopilotSync); + +describe('portal copilot sync flags', () => { + it('waits for indexing unless told not to', async () => { + expect((await parse([])).flags.wait).to.be.true; + expect((await parse(['--no-wait'])).flags.wait).to.be.false; + }); + + it('takes the project, the portal and the auth key by their short names', async () => { + const { flags } = await parse(['-i', './project', '-d', './site', '-k', 'key']); + + expect(flags).to.include({ input: './project', destination: './site', 'auth-key': 'key' }); + }); +}); diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts new file mode 100644 index 000000000..98e9a2e24 --- /dev/null +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -0,0 +1,174 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; +import { stripVTControlCharacters } from 'node:util'; +import { gunzipSync } from 'node:zlib'; +import nock from 'nock'; +import { expect } from 'chai'; +import { CopilotIngestService } from '../../../src/infrastructure/services/copilot-ingest-service'; +import { ServiceErrorCode } from '../../../src/infrastructure/service-error'; +import { DirectoryPath } from '../../../src/types/file/directoryPath'; +import { CopilotBundle } from '../../../src/types/portal/copilot/copilot-bundle'; + +const API_URL = 'http://copilot.test'; +const COPILOT = { key: 'pk_test', apiUrl: API_URL }; +const VERSION_ID = 'v-1'; + +const bundle = CopilotBundle.of( + [{ path: '/', title: 'Home', description: null, markdown: '# Home' }], + null, + '1.0.0', + new Date('2026-10-14T00:00:00.000Z') +); + +const statusBody = (status: string, extra: Record = {}) => ({ + versionId: VERSION_ID, + status, + pageCount: 12, + changedPages: 3, + removedPages: 1, + chunkCount: 40, + error: null, + ...extra +}); + +/** A port nothing listens on, so a connection to it is refused. */ +const closedPort = async (): Promise => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as net.AddressInfo; + await new Promise((resolve) => server.close(() => resolve())); + return port; +}; + +describe('CopilotIngestService', () => { + let configRoot: string; + let configDir: DirectoryPath; + const service = new CopilotIngestService({ pollIntervalMs: 1, indexTimeoutMs: 5000 }); + + const upload = (authKey: string | null = 'auth-key') => service.upload(COPILOT, bundle, configDir, 'bash', authKey); + const awaitIndexing = () => service.awaitIndexing(COPILOT, VERSION_ID, configDir, 'bash', 'auth-key'); + + beforeEach(() => { + configRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-ingest-')); + configDir = new DirectoryPath(configRoot); + nock.cleanAll(); + }); + + afterEach(() => { + fs.rmSync(configRoot, { recursive: true, force: true }); + nock.cleanAll(); + }); + + describe('upload', () => { + it('posts the gzipped bundle with both keys and answers with the queued version', async () => { + let posted: unknown; + const scope = nock(API_URL, { + reqheaders: { + authorization: 'X-Auth-Key auth-key', + 'x-copilot-key': 'pk_test', + 'content-type': 'application/gzip' + } + }) + .post('/v1/ingest', (body: string) => { + posted = JSON.parse(gunzipSync(Buffer.from(body, 'hex')).toString('utf8')); + return true; + }) + .reply(202, { versionId: VERSION_ID, status: 'queued', pageCount: 1 }); + + const result = await upload(); + + expect(result._unsafeUnwrap()).to.deep.equal({ versionId: VERSION_ID, status: 'queued', pageCount: 1 }); + expect(posted).to.deep.equal(bundle.toJSON()); + expect(scope.isDone()).to.be.true; + }); + + it('uses the stored key when no flag overrides it', async () => { + fs.writeFileSync(path.join(configRoot, 'config.json'), JSON.stringify({ email: 'a@b.c', authKey: 'stored' })); + nock(API_URL, { reqheaders: { authorization: 'X-Auth-Key stored' } }) + .post('/v1/ingest') + .reply(200, { versionId: VERSION_ID, status: 'unchanged', pageCount: 1 }); + + expect((await upload(null))._unsafeUnwrap().status).to.equal('unchanged'); + }); + + it('asks the user to sign in, without a request, when there is no key', async () => { + const result = await upload(null); + + expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.UnAuthorized); + expect(stripVTControlCharacters(result._unsafeUnwrapErr().errorMessage)).to.contain('apimatic auth login'); + }); + + it("surfaces the service's message for a refused upload", async () => { + nock(API_URL) + .post('/v1/ingest') + .reply(403, { code: 'sync_forbidden', message: 'This key cannot sync this portal.' }); + + const error = (await upload())._unsafeUnwrapErr(); + + expect(error.code).to.equal(ServiceErrorCode.Forbidden); + expect(error.errorMessage).to.equal('This key cannot sync this portal.'); + }); + + it('surfaces the message of a bundle that is invalid or too large', async () => { + nock(API_URL).post('/v1/ingest').reply(413, { code: 'too_large', message: 'The bundle is over 50 MB.' }); + + expect((await upload())._unsafeUnwrapErr().errorMessage).to.equal('The bundle is over 50 MB.'); + }); + + it('reports a service that cannot be reached as a network error', async () => { + const unreachable = { ...COPILOT, apiUrl: `http://127.0.0.1:${await closedPort()}` }; + + const result = await service.upload(unreachable, bundle, configDir, 'bash', 'auth-key'); + + expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.NetworkError); + }); + }); + + describe('awaitIndexing', () => { + it('polls until the version is active', async () => { + const scope = nock(API_URL, { reqheaders: { 'x-copilot-key': 'pk_test' } }) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('queued')) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('building')) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('active')); + + const result = await awaitIndexing(); + + expect(result._unsafeUnwrap()).to.deep.include({ status: 'active', pageCount: 12, changedPages: 3 }); + expect(scope.isDone()).to.be.true; + }); + + it('ends on a version a newer sync superseded', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).reply(200, statusBody('superseded')); + + expect((await awaitIndexing())._unsafeUnwrap().status).to.equal('superseded'); + }); + + it("fails with the service's reason when indexing failed", async () => { + nock(API_URL) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('failed', { error: 'Embedding provider unavailable.' })); + + expect((await awaitIndexing())._unsafeUnwrapErr().errorMessage).to.equal('Embedding provider unavailable.'); + }); + + it('times out on a version that never finishes', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).times(1000).reply(200, statusBody('building')); + const impatient = new CopilotIngestService({ pollIntervalMs: 1, indexTimeoutMs: 20 }); + + const result = await impatient.awaitIndexing(COPILOT, VERSION_ID, configDir, 'bash', 'auth-key'); + + expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.Timeout); + }); + + it('refuses a status body it cannot read', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).reply(200, { status: 'done' }); + + expect((await awaitIndexing())._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.InvalidResponse); + }); + }); +}); diff --git a/test/portal-template/copilot-client.test.ts b/test/portal-template/copilot-client.test.ts new file mode 100644 index 000000000..53ce067c2 --- /dev/null +++ b/test/portal-template/copilot-client.test.ts @@ -0,0 +1,113 @@ +import { expect } from 'chai'; +import { CopilotClient } from '../../portal-template/src/lib/copilot-client'; + +const COPILOT = { key: 'pk_live_abc', apiUrl: 'https://ask.example.com' }; + +interface Call { + url: string; + init: RequestInit; +} + +/** A service that answers each call in turn, recording what it was sent. */ +function service(...replies: (Response | (() => Response))[]) { + const calls: Call[] = []; + const send = (async (input: RequestInfo | URL, init: RequestInit = {}) => { + calls.push({ url: String(input), init }); + const reply = replies.shift(); + if (!reply) throw new Error('no more replies'); + return typeof reply === 'function' ? reply() : reply; + }) as typeof fetch; + return { calls, send }; +} + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const session = (token: string, inMs = 900_000) => json({ token, expiresAt: new Date(Date.now() + inMs).toISOString() }); + +describe('the Ask AI client', () => { + it('opens a session with the public key, then asks with the session token only', async () => { + const { calls, send } = service(session('t1'), new Response('stream')); + const client = new CopilotClient(COPILOT, () => undefined, send); + const response = await client.fetch(client.chatAddress(), { method: 'POST', body: '{}' }); + expect(await response.text()).to.equal('stream'); + expect(calls[0]!.url).to.equal('https://ask.example.com/v1/sessions'); + expect(JSON.parse(calls[0]!.init.body as string)).to.deep.equal({ key: 'pk_live_abc' }); + expect(calls[1]!.url).to.equal('https://ask.example.com/v1/chat'); + expect(new Headers(calls[1]!.init.headers).get('authorization')).to.equal('Bearer t1'); + expect(calls[1]!.init.credentials).to.equal('omit'); + expect(calls[1]!.init.body).to.not.contain('pk_live_abc'); + }); + + it('reuses a session, and renews one that has expired', async () => { + const { calls, send } = service(session('t1'), new Response('a'), new Response('b')); + const client = new CopilotClient(COPILOT, () => undefined, send); + await client.fetch(client.chatAddress(), {}); + await client.fetch(client.chatAddress(), {}); + expect(calls.map((call) => call.url.split('/v1/')[1])).to.deep.equal(['sessions', 'chat', 'chat']); + + const renewing = service(session('old', 10_000), new Response('a'), session('new'), new Response('b')); + const renewed = new CopilotClient(COPILOT, () => undefined, renewing.send); + await renewed.fetch(renewed.chatAddress(), {}); + await renewed.fetch(renewed.chatAddress(), {}); + expect(new Headers(renewing.calls[3]!.init.headers).get('authorization')).to.equal('Bearer new'); + }); + + it('opens a new session once when the service says the token expired', async () => { + const { calls, send } = service( + session('t1'), + json({ code: 'session_expired', message: 'expired' }, 401), + session('t2'), + new Response('ok') + ); + const client = new CopilotClient(COPILOT, () => undefined, send); + expect(await (await client.fetch(client.chatAddress(), {})).text()).to.equal('ok'); + expect(new Headers(calls[3]!.init.headers).get('authorization')).to.equal('Bearer t2'); + }); + + it("shows the service's own message when it refuses a question", async () => { + const { send } = service(session('t1'), json({ code: 'quota_exceeded', message: 'Limit reached. Search still works.' }, 429)); + const client = new CopilotClient(COPILOT, () => undefined, send); + const response = await client.fetch(client.chatAddress(), {}); + expect(response.status).to.equal(429); + expect(await response.text()).to.equal('Limit reached. Search still works.'); + }); + + it('hides Ask AI when the service turns the portal away', async () => { + for (const code of ['copilot_disabled', 'invalid_key', 'origin_not_allowed']) { + let hidden = false; + const { send } = service(json({ code, message: 'no' }, 403)); + const client = new CopilotClient(COPILOT, () => (hidden = true), send); + let failed = false; + await client.fetch(client.chatAddress(), {}).catch(() => (failed = true)); + expect(failed, code).to.be.true; + expect(hidden, code).to.be.true; + } + }); + + it('says the service is unavailable when it cannot be reached, and tries again next time', async () => { + const { calls, send } = service( + () => { + throw new TypeError('Failed to fetch'); + }, + session('t1'), + new Response('ok') + ); + const client = new CopilotClient(COPILOT, () => undefined, send); + let message = ''; + await client.fetch(client.chatAddress(), {}).catch((error: Error) => (message = error.message)); + expect(message).to.contain('unavailable'); + expect(await (await client.fetch(client.chatAddress(), {})).text()).to.equal('ok'); + expect(calls).to.have.length(3); + }); + + it('continues the thread the service started, and starts afresh with a new chat', () => { + const client = new CopilotClient(COPILOT, () => undefined, service().send); + expect(client.questionBody([{ id: 'q1' }], 'submit-message')).to.deep.equal({ + threadId: null, + trigger: 'submit-message', + message: { id: 'q1' } + }); + client.noteData({ type: 'data-thread', data: { threadId: 'th-1' } }); + expect(client.questionBody([{ id: 'q1' }, { id: 'a1' }, { id: 'q2' }], 'submit-message').threadId).to.equal('th-1'); + expect(client.questionBody([{ id: 'q9' }], 'submit-message').threadId).to.equal(null); + }); +}); diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts new file mode 100644 index 000000000..92ed85c75 --- /dev/null +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; +import { expect } from 'chai'; +import { DirectoryPath } from '../../../../src/types/file/directoryPath'; +import { SiteAddress } from '../../../../src/types/portal/config/site-address'; +import { GeneratedPortalContext } from '../../../../src/types/portal/copilot/generated-portal-context'; + +const AT = new Date('2026-10-14T12:00:00.000Z'); + +describe('GeneratedPortalContext', () => { + let root: string; + + const write = (relative: string, contents: string) => { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + }; + + const bundle = (site: SiteAddress | null = null) => + new GeneratedPortalContext(new DirectoryPath(root)).bundle(site, '9.9.9', AT); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-portal-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('is null for a directory with no generated portal in it', async () => { + expect(await bundle()).to.be.null; + }); + + it("bundles each listed page with its Markdown twin, the home page's being index.md", async () => { + write( + 'llms.txt', + [ + '- [Welcome](/): Start here', + ' - [Intro](/guides/intro): Install', + ' - [Missing](/guides/missing)', + ' - [SDK](/__downloads/sdk.zip)' + ].join('\n') + ); + write('index.md', '# Welcome'); + write('guides/intro.md', '# Intro'); + + const built = (await bundle())!; + + expect(built.pageCount()).to.equal(2); + expect(JSON.parse(gunzipSync(built.gzipped()).toString('utf8'))).to.deep.equal({ + formatVersion: 1, + generator: { name: 'apimatic-cli', version: '9.9.9' }, + generatedAt: '2026-10-14T12:00:00.000Z', + siteUrl: null, + basePath: '/', + pages: [ + { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, + { path: '/guides/intro', title: 'Intro', description: 'Install', markdown: '# Intro' } + ] + }); + }); + + it('reads a portal served under a path, with its pages addressed from that path', async () => { + write('llms.txt', ['- [Docs](/docs)', '- [Intro](/docs/guides/intro)'].join('\n')); + write('index.md', '# Docs'); + write('guides/intro.md', '# Intro'); + + const built = (await bundle(SiteAddress.parse('https://example.com/docs', 'url')._unsafeUnwrap()))!; + + const document = built.toJSON(); + expect(document.siteUrl).to.equal('https://example.com/docs'); + expect(document.basePath).to.equal('/docs/'); + expect(document.pages.map(({ path: page }) => page)).to.deep.equal(['/', '/guides/intro']); + expect(document.pages[0]).to.not.have.property('description'); + }); + + it('has no pages when none of the listed ones has its Markdown twin', async () => { + write('llms.txt', '- [Intro](/guides/intro)'); + + expect((await bundle())!.pageCount()).to.equal(0); + }); +}); diff --git a/test/types/portal/portal-config.test.ts b/test/types/portal/portal-config.test.ts index 6f0e692f3..8b9ea623f 100644 --- a/test/types/portal/portal-config.test.ts +++ b/test/types/portal/portal-config.test.ts @@ -393,6 +393,55 @@ describe('PortalConfig', () => { expect(config({ ai: { pageActions: false } }).identity().pageActions).to.be.false; expect(errorsOf({ ai: { pageActions: 'no' } })).to.deep.equal(["'portal.ai.pageActions' must be true or false."]); }); + + describe('copilot', () => { + const PUBLIC_KEY = `askai_pk_ab12cd34_${'A'.repeat(43)}`; + + it('offers the Ask AI chat only when the block sets one up', () => { + expect(config({}).identity().copilot).to.be.null; + const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.example.com/v1' }; + expect(config({ ai: { copilot } }).identity().copilot).to.deep.equal({ + key: PUBLIC_KEY, + apiUrl: 'https://ask.example.com', + suggestions: [] + }); + expect(config({ ai: { copilot } }).toJSON().ai.copilot).to.deep.equal({ + key: PUBLIC_KEY, + apiUrl: 'https://ask.example.com' + }); + expect(config({}).toJSON().ai).to.not.have.property('copilot'); + }); + + it('offers up to three starter questions, and writes them back only when set', () => { + const suggestions = [' How do I authenticate? ', 'How do I create an order?']; + const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.example.com', suggestions }; + expect(config({ ai: { copilot } }).identity().copilot?.suggestions).to.deep.equal([ + 'How do I authenticate?', + 'How do I create an order?' + ]); + expect(config({ ai: { copilot } }).toJSON().ai.copilot?.suggestions).to.have.length(2); + const tooMany = { key: PUBLIC_KEY, apiUrl: 'https://ask.example.com', suggestions: ['a', 'b', 'c', 'd'] }; + expect(errorsOf({ ai: { copilot: tooMany } })).to.deep.equal([ + "'portal.ai.copilot.suggestions' must be a list of up to 3 questions, each a non-empty string of at most 120 characters." + ]); + expect(errorsOf({ ai: { copilot: { ...tooMany, suggestions: [''] } } })).to.have.length(1); + }); + + // The service validates the key and owns its design, so any non-empty key is passed through. + it('accepts any key, whatever its layout', () => { + const copilot = { key: ' v2-key_Zx9 ', apiUrl: 'https://ask.example.com' }; + expect(config({ ai: { copilot } }).identity().copilot?.key).to.equal('v2-key_Zx9'); + }); + + it('names every missing or malformed setting', () => { + expect(errorsOf({ ai: { copilot: { apiUrl: 'javascript:alert(1)', extra: true } } })).to.deep.equal([ + "'portal.ai.copilot.extra' is not a 'portal' setting.", + "'portal.ai.copilot.key' must be a non-empty string.", + "'portal.ai.copilot.apiUrl' must be the Ask AI service's address, starting with 'https://' or 'http://'." + ]); + expect(errorsOf({ ai: { copilot: 'yes' } })).to.deep.equal(["'portal.ai.copilot' must be a JSON object."]); + }); + }); }); describe('pluginUrl', () => { @@ -486,7 +535,8 @@ describe('PortalConfig', () => { { label: 'Status', url: 'https://status.example.com', external: true }, { label: 'Changelog', url: '/changelog', external: false } ], - pageActions: false + pageActions: false, + copilot: null }); }); @@ -499,7 +549,8 @@ describe('PortalConfig', () => { favicon: null, colorMode: 'both', links: [], - pageActions: true + pageActions: true, + copilot: null }); }); From 7ca4d2059e9bdab3bbac42f243c2d16251733a89 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Thu, 15 Oct 2026 12:50:42 +0500 Subject: [PATCH 02/41] test(portal): cover llms.txt parsing and make the copilot tests pass lint Co-Authored-By: Claude Opus 5.5 (1M context) --- eslint.config.js | 8 ++- .../services/copilot-ingest-service.test.ts | 1 + test/types/portal/copilot/llms-index.test.ts | 53 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 test/types/portal/copilot/llms-index.test.ts diff --git a/eslint.config.js b/eslint.config.js index c82907cca..22146f6f9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,7 +40,13 @@ export default [ before: 'readonly', beforeEach: 'readonly', after: 'readonly', - afterEach: 'readonly' + afterEach: 'readonly', + // The portal template's browser code is tested against Node's own fetch API. + fetch: 'readonly', + Headers: 'readonly', + Response: 'readonly', + RequestInfo: 'readonly', + RequestInit: 'readonly' } }, rules: { diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts index 98e9a2e24..40b093d48 100644 --- a/test/infrastructure/services/copilot-ingest-service.test.ts +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import net from 'node:net'; import { stripVTControlCharacters } from 'node:util'; +import { Buffer } from 'node:buffer'; import { gunzipSync } from 'node:zlib'; import nock from 'nock'; import { expect } from 'chai'; diff --git a/test/types/portal/copilot/llms-index.test.ts b/test/types/portal/copilot/llms-index.test.ts new file mode 100644 index 000000000..c479263df --- /dev/null +++ b/test/types/portal/copilot/llms-index.test.ts @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import { SiteAddress } from '../../../../src/types/portal/config/site-address'; +import { listedPages } from '../../../../src/types/portal/copilot/llms-index'; + +const underDocs = SiteAddress.parse('https://example.com/docs', 'url')._unsafeUnwrap(); + +describe('listedPages', () => { + it('reads every linked page with its title and description, in the order llms.txt lists them', () => { + const llmsTxt = [ + '# Acme', + '', + '> The Acme API.', + '- [Welcome](/): Start here', + ' - [ Intro ](/guides/intro)', + '* [Errors](/api/errors): What can go wrong' + ].join('\r\n'); + + expect(listedPages(llmsTxt, null)).to.deep.equal([ + { path: '/', title: 'Welcome', description: 'Start here' }, + { path: '/guides/intro', title: 'Intro', description: null }, + { path: '/api/errors', title: 'Errors', description: 'What can go wrong' } + ]); + }); + + it('addresses pages from the base path, leaving out those outside it', () => { + const llmsTxt = [ + '- [Docs](/docs)', + '- [Intro](/docs/guides/intro/)', + '- [Blog](/blog/post)', + '- [Search](/docsearch)' + ].join('\n'); + + expect(listedPages(llmsTxt, underDocs).map((page) => page.path)).to.deep.equal(['/', '/guides/intro']); + }); + + it('lists a page once, by its address without query or fragment', () => { + const llmsTxt = ['- [Intro](/guides/intro?tab=ts)', '- [Intro again](/guides/intro#install)'].join('\n'); + + expect(listedPages(llmsTxt, null)).to.deep.equal([{ path: '/guides/intro', title: 'Intro', description: null }]); + }); + + it('leaves out downloads, other websites and addresses that climb out of the portal', () => { + const llmsTxt = [ + '- [SDK](/__downloads/sdk.zip)', + '- [Status](https://status.example.com)', + '- [Relative](guides/intro)', + '- [Climb](/guides/../../secret)', + '- [Here](/guides/./intro)' + ].join('\n'); + + expect(listedPages(llmsTxt, null)).to.deep.equal([]); + }); +}); From 7d039c5cce59197995b4652a72e276366312660b Mon Sep 17 00:00:00 2001 From: waseem196 Date: Thu, 15 Oct 2026 14:58:31 +0500 Subject: [PATCH 03/41] fix(portal): keep the saved APIMatic login from Ask AI services it does not trust `apiUrl` must be HTTPS, except for a service on this machine. `portal copilot sync` sends the saved login only to APIMatic's own services or one on this machine; any other needs the key passed with --auth-key. Co-Authored-By: Claude Opus 5.5 (1M context) --- apimatic.schema.json | 4 +-- src/actions/portal/copilot/sync.ts | 5 ++++ src/prompts/portal/copilot/sync.ts | 9 ++++++ src/types/portal/config/copilot-config.ts | 22 +++++++++++++-- test/actions/portal/copilot/sync.test.ts | 26 +++++++++++++++-- test/types/portal/portal-config.test.ts | 34 ++++++++++++++++++++++- 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/apimatic.schema.json b/apimatic.schema.json index 39b048d9e..baaf9d2c1 100644 --- a/apimatic.schema.json +++ b/apimatic.schema.json @@ -188,8 +188,8 @@ }, "apiUrl": { "type": "string", - "description": "The Ask AI service's address, for example 'https://ask.example.com'.", - "pattern": "^[hH][tT][tT][pP][sS]?://\\S+$" + "description": "The Ask AI service's address, for example 'https://ask.example.com'. Plain 'http://' is only accepted for a service on this machine, such as 'http://localhost:8787'.", + "pattern": "^([hH][tT][tT][pP][sS]://\\S+|[hH][tT][tT][pP]://(localhost|127\\.0\\.0\\.1|\\[::1\\]|[^/:\\s]+\\.localhost)([:/]\\S*)?)$" }, "suggestions": { "type": "array", diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts index b5787fdbf..9e7ac5c26 100644 --- a/src/actions/portal/copilot/sync.ts +++ b/src/actions/portal/copilot/sync.ts @@ -3,6 +3,7 @@ import { CopilotIngestService } from '../../../infrastructure/services/copilot-i import { PortalCopilotSyncPrompts } from '../../../prompts/portal/copilot/sync.js'; import { CommandMetadata } from '../../../types/common/command-metadata.js'; import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { receivesSavedLogin } from '../../../types/portal/config/copilot-config.js'; import { PLACEHOLDER_SITE } from '../../../types/portal/config/site-config.js'; import { GeneratedPortalContext } from '../../../types/portal/copilot/generated-portal-context.js'; import { ProjectContext } from '../../../types/project-context.js'; @@ -36,6 +37,10 @@ export class PortalCopilotSyncAction { this.prompts.copilotNotConfigured(project.sourceDirectory()); return ActionResult.failed(); } + if (this.authKey === null && !receivesSavedLogin(copilot.apiUrl)) { + this.prompts.savedLoginWithheld(copilot.apiUrl); + return ActionResult.failed(); + } const bundle = await new GeneratedPortalContext(portalDirectory).bundle( config.siteAddress(), diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index 10a49ae88..46a569460 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -28,6 +28,15 @@ export class PortalCopilotSyncPrompts { log.error(message); } + public savedLoginWithheld(apiUrl: string) { + const message = + `The Ask AI service at ${f.link( + apiUrl + )} is not run by APIMatic, so your saved APIMatic login is not sent to it. ` + + `If you trust this service, pass your auth key with ${f.flag('auth-key', '{api-key}')}.`; + log.error(message); + } + public portalNotGenerated(portalDirectory: DirectoryPath) { const message = `No generated portal was found at ${f.path(portalDirectory)}. ` + diff --git a/src/types/portal/config/copilot-config.ts b/src/types/portal/config/copilot-config.ts index 7e87ca210..d8b891b60 100644 --- a/src/types/portal/config/copilot-config.ts +++ b/src/types/portal/config/copilot-config.ts @@ -49,11 +49,27 @@ export class CopilotConfig { } } +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; +const APIMATIC_DOMAIN = 'apimatic.io'; + +function isLoopback(url: URL): boolean { + return LOOPBACK_HOSTS.includes(url.hostname) || url.hostname.endsWith('.localhost'); +} + +/** Whether sync may send the saved APIMatic login to the service: only APIMatic's own, or one on this machine. */ +export function receivesSavedLogin(apiUrl: string): boolean { + const url = new URL(apiUrl); + const { hostname } = url; + return isLoopback(url) || hostname === APIMATIC_DOMAIN || hostname.endsWith(`.${APIMATIC_DOMAIN}`); +} + // Required while there is no hosted service; once there is, default to its address and keep this as an override. +// Plain HTTP would expose the keys in transit, so it is only accepted for a service on this machine. function serviceOrigin(value: unknown, path: string): Parsed { - return typeof value === 'string' && isWebAddress(value) - ? ok(new URL(value).origin) - : err([`'${path}' must be the Ask AI service's address, starting with 'https://' or 'http://'.`]); + const url = typeof value === 'string' && isWebAddress(value) ? new URL(value) : null; + return url !== null && (url.protocol === 'https:' || isLoopback(url)) + ? ok(url.origin) + : err([`'${path}' must be the Ask AI service's address, starting with 'https://' ('http://' only for localhost).`]); } function suggestionList(value: unknown, path: string): Parsed { diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index 0797323ad..f71b1569f 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -14,7 +14,7 @@ import { CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bund import { ProjectContext } from '../../../../src/types/project-context'; const COMMAND_METADATA: CommandMetadata = { commandName: 'portal:copilot:sync', shell: 'test' }; -const COPILOT = { key: 'pk_test', apiUrl: 'http://copilot.test' }; +const COPILOT = { key: 'pk_test', apiUrl: 'https://copilot.test' }; const indexed = (status: 'active' | 'superseded') => ({ versionId: 'v-1', @@ -53,9 +53,9 @@ describe('PortalCopilotSyncAction', () => { write('portal/guides/intro.md', '# Intro'); }; - const execute = (wait = true) => { + const execute = (wait = true, authKey: string | null = 'auth-key') => { const project = ProjectContext.in(new DirectoryPath(root)); - return new PortalCopilotSyncAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute( + return new PortalCopilotSyncAction(new DirectoryPath(root), COMMAND_METADATA, authKey).execute( project, project.portalDirectory(), wait @@ -150,6 +150,26 @@ describe('PortalCopilotSyncAction', () => { expect(prompts.syncFailed.calledOnceWith(failed, COPILOT.apiUrl)).to.be.true; }); + it('keeps the saved login from a service APIMatic does not run, unless a key is passed', async () => { + const withheld = await execute(true, null); + + expect(withheld.isFailed()).to.be.true; + expect(prompts.savedLoginWithheld.calledOnceWith(COPILOT.apiUrl)).to.be.true; + expect(upload.called).to.be.false; + + expect((await execute()).isSuccess()).to.be.true; + }); + + it("uses the saved login with APIMatic's own service and one on this machine", async () => { + for (const apiUrl of ['https://ask.apimatic.io', 'http://localhost:8787']) { + writeConfig({ copilot: { ...COPILOT, apiUrl } }); + + expect((await execute(true, null)).isSuccess(), apiUrl).to.be.true; + } + expect(upload.callCount).to.equal(2); + expect(prompts.savedLoginWithheld.called).to.be.false; + }); + it('fails without uploading when the portal has no Ask AI chat', async () => { writeConfig(null); diff --git a/test/types/portal/portal-config.test.ts b/test/types/portal/portal-config.test.ts index 8b9ea623f..2033fc7bb 100644 --- a/test/types/portal/portal-config.test.ts +++ b/test/types/portal/portal-config.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { PortalConfig } from '../../../src/types/portal/portal-config'; +import { receivesSavedLogin } from '../../../src/types/portal/config/copilot-config'; import { SuggestedSite } from '../../../src/types/portal/config/site-config'; describe('PortalConfig', () => { @@ -437,10 +438,41 @@ describe('PortalConfig', () => { expect(errorsOf({ ai: { copilot: { apiUrl: 'javascript:alert(1)', extra: true } } })).to.deep.equal([ "'portal.ai.copilot.extra' is not a 'portal' setting.", "'portal.ai.copilot.key' must be a non-empty string.", - "'portal.ai.copilot.apiUrl' must be the Ask AI service's address, starting with 'https://' or 'http://'." + "'portal.ai.copilot.apiUrl' must be the Ask AI service's address, starting with 'https://' ('http://' only for localhost)." ]); expect(errorsOf({ ai: { copilot: 'yes' } })).to.deep.equal(["'portal.ai.copilot' must be a JSON object."]); }); + + // Plain HTTP would expose the keys in transit, except to a service on this machine. + it('accepts plain HTTP only for a service on this machine', () => { + const at = (apiUrl: string) => ({ ai: { copilot: { key: PUBLIC_KEY, apiUrl } } }); + for (const local of [ + 'http://localhost:8787', + 'http://127.0.0.1:8787', + 'http://[::1]:8787', + 'http://ask.localhost' + ]) { + expect(config(at(local)).identity().copilot?.apiUrl, local).to.equal(new URL(local).origin); + } + expect(errorsOf(at('http://ask.example.com'))).to.have.length(1); + expect(errorsOf(at('http://localhost.example.com'))).to.have.length(1); + }); + }); + + describe('receivesSavedLogin', () => { + it("trusts only APIMatic's own services and those on this machine with the saved login", () => { + for (const trusted of [ + 'https://ask.apimatic.io', + 'https://apimatic.io', + 'http://localhost:8787', + 'http://[::1]' + ]) { + expect(receivesSavedLogin(trusted), trusted).to.be.true; + } + for (const other of ['https://ask.example.com', 'https://apimatic.io.example.com', 'https://evilapimatic.io']) { + expect(receivesSavedLogin(other), other).to.be.false; + } + }); }); }); From 08dffb5c15b2a46a1dfa202feb575a6b40ccf9ff Mon Sep 17 00:00:00 2001 From: waseem196 Date: Thu, 15 Oct 2026 14:58:31 +0500 Subject: [PATCH 04/41] perf(portal): load the Ask AI chat only in portals that offer it Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/components/copilot.tsx | 24 +++++++- portal-template/src/lib/layout.tsx | 31 +++++----- test/e2e/portal-build.test.ts | 59 +++++++++++++++++++ .../portal-inputs/copilot/src/apimatic.json | 17 ++++++ .../portal-inputs/copilot/src/spec/pets.json | 42 +++++++++++++ 5 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 test/resources/portal-inputs/copilot/src/apimatic.json create mode 100644 test/resources/portal-inputs/copilot/src/spec/pets.json diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index 98abbbc97..e89ee1d6d 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -15,8 +15,28 @@ type Part = UIMessage['parts'][number]; type SearchPart = Parameters[0]['part']; type Sources = Parameters[0]['sources']; +/** + * The docs layout with the Ask AI chat around it. The module's one export, so the layout can load + * it on its own, and only for a portal that offers the chat. + */ +export default function CopilotLayout({ + copilot, + layout +}: Readonly<{ copilot: PortalCopilot; layout: (aiChat: AIChatOptions) => ReactNode }>) { + return ( + + + + ); +} + +// Separate, because the panel state exists only inside `CopilotProvider`. +function WithPanel({ layout }: Readonly<{ layout: (aiChat: AIChatOptions) => ReactNode }>) { + return layout(useCopilotPanel()); +} + /** The chat state and the "Ask AI" button; wraps the layout so a conversation survives navigation. */ -export function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilot; children: ReactNode }>) { +function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilot; children: ReactNode }>) { // Turned away by the service (disabled, unknown key, another website): the portal shows no Ask AI at all. const [available, setAvailable] = useState(true); const client = useMemo(() => new CopilotClient(copilot, () => setAvailable(false)), [copilot]); @@ -32,7 +52,7 @@ export function CopilotProvider({ copilot, children }: Readonly<{ copilot: Porta } /** The docs layout's `aiChat` option: the panel and whether it is open. Call inside `CopilotProvider`. */ -export function useCopilotPanel(): AIChatOptions { +function useCopilotPanel(): AIChatOptions { const { open, setOpen } = useAIChat(); return { open, onOpenChange: setOpen, panel: }; } diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index bc971ffbe..a6874ef26 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -1,31 +1,30 @@ -import { useMemo, type ReactNode } from 'react'; +import { lazy, Suspense, useMemo, type ReactNode } from 'react'; import type * as PageTree from 'fumadocs-core/page-tree'; import { DocsLayout as NotebookLayout } from 'fumadocs-ui/layouts/notebook'; import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; -import { CopilotProvider, useCopilotPanel } from '@/components/copilot'; import { baseOptions } from './layout.shared'; import { portal } from './portal'; import { portalTabs } from './tabs'; type LayoutProps = Readonly<{ tree: PageTree.Root; children: ReactNode }>; -// Adds the Ask AI panel when `portal.ai.copilot` is set. The chat code is imported statically, -// so it ships in every portal; load it lazily if bundle size matters. +// Its own chunk, so a portal without `portal.ai.copilot` never downloads the chat. +const CopilotLayout = lazy(() => import('@/components/copilot')); + export function PortalLayout({ tree, children }: LayoutProps) { if (portal.copilot === null) return {children}; + // The prerender waits for the chunk, so the fallback only shows if it is slow on a client-side navigation. return ( - - {children} - - ); -} - -// Separate, because the panel state exists only inside `CopilotProvider`. -function NotebookWithCopilot({ tree, children }: LayoutProps) { - return ( - - {children} - + {children}}> + ( + + {children} + + )} + /> + ); } diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index ac8011e92..65d27422c 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -175,6 +175,28 @@ const scriptsOf = (output: DirectoryPath) => .filter((name) => name.endsWith('.js')) .map((name) => ({ name, text: fs.readFileSync(path.join(output.toString(), 'assets', name), 'utf8') })); +/** Code only the Ask AI chat carries: the address it opens a session at. */ +const CHAT_CODE = '/v1/sessions'; + +/** + * The scripts a page loads before anything asks for more: those its HTML names, and every one + * they import in turn. A chunk reached only through `import()` is left out. + */ +function scriptsLoadedBy(output: DirectoryPath, page: string): string[] { + const assets = path.join(output.toString(), 'assets'); + const named = (text: string, pattern: RegExp) => + [...text.matchAll(pattern)].map((match) => path.basename(match[1]!)).filter((name) => name.endsWith('.js')); + const loaded = new Set(); + const pending = named(page, /(?:src|href)="[^"]*\/assets\/([^"]+)"/g); + while (pending.length > 0) { + const name = pending.pop()!; + if (loaded.has(name) || !fs.existsSync(path.join(assets, name))) continue; + loaded.add(name); + pending.push(...named(fs.readFileSync(path.join(assets, name), 'utf8'), /(?:from|import)\s*["']\.\/([^"']+)["']/g)); + } + return [...loaded]; +} + const stylesheetOf = (output: DirectoryPath) => { const assets = path.join(output.toString(), 'assets'); return fs @@ -476,6 +498,15 @@ const stylesheetOf = (output: DirectoryPath) => { ).to.deep.equal([]); }); + it('loads no Ask AI chat code in a portal that offers no chat', () => { + const output = portal.output(); + const loaded = scriptsLoadedBy(output, read('index.html')); + + expect(loaded, 'the page names its scripts').to.not.be.empty; + expect(loaded.filter((name) => read('assets/' + name).includes(CHAT_CODE))).to.deep.equal([]); + expect(read('index.html')).to.not.contain('>Ask AI<'); + }); + it('bundles Geist and loads the neutral theme', () => { const css = stylesheetOf(portal.output()); @@ -884,3 +915,31 @@ const stylesheetOf = (output: DirectoryPath) => { expect(locations.filter((location) => !location.startsWith('https://docs.test/api/'))).to.deep.equal([]); }); }); + +(enabled ? describe : describe.skip)('portal build, with the Ask AI chat (end to end)', function () { + this.timeout(10 * 60 * 1000); + + const portal = portalBuilt('copilot'); + const { read } = portal; + + // The chat wraps the whole layout, so a prerender that did not wait for its chunk would leave the button out. + it('prerenders each page with the chat button', () => { + expect(read('index.html')).to.contain('>Ask AI<'); + }); + + it('keeps the chat in its own chunk, out of what a page loads before it asks for the chat', () => { + const output = portal.output(); + const chat = scriptsOf(output).filter((script) => script.text.includes(CHAT_CODE)); + const loaded = scriptsLoadedBy(output, read('index.html')); + + expect(chat, 'a chunk carries the chat').to.not.be.empty; + expect(loaded.filter((name) => chat.some((script) => script.name === name))).to.deep.equal([]); + }); + + it('tells the browser the public key and the service, and no more', () => { + const scripts = scriptsOf(portal.output()); + + expect(scripts.some((script) => script.text.includes('pk_test_e2e'))).to.be.true; + expect(scripts.some((script) => script.text.includes('https://ask.apimatic.io'))).to.be.true; + }); +}); diff --git a/test/resources/portal-inputs/copilot/src/apimatic.json b/test/resources/portal-inputs/copilot/src/apimatic.json new file mode 100644 index 000000000..7407717cf --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/apimatic.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://cdn.jsdelivr.net/npm/@apimatic/cli@beta/apimatic.schema.json", + "schemaVersion": 1, + "portal": { + "site": { "name": "Ask AI Pets" }, + "ai": { + "copilot": { + "key": "pk_test_e2e", + "apiUrl": "https://ask.apimatic.io", + "suggestions": ["How do I list pets?"] + } + } + }, + "languages": { + "typescript": {} + } +} diff --git a/test/resources/portal-inputs/copilot/src/spec/pets.json b/test/resources/portal-inputs/copilot/src/spec/pets.json new file mode 100644 index 000000000..6bcd22492 --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/spec/pets.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.0.3", + "info": { "title": "Pets", "version": "1.0.0", "description": "Pets, with a brand of their own." }, + "servers": [{ "url": "https://pets.example.com" }], + "tags": [{ "name": "pets" }], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "tags": ["pets"], + "summary": "List pets", + "responses": { "200": { "description": "The pets." } } + }, + "post": { + "operationId": "createPet", + "tags": ["pets"], + "summary": "Create a pet", + "deprecated": true, + "responses": { "201": { "description": "The pet, created." } } + } + }, + "/pets/{petId}/photo": { + "put": { + "operationId": "uploadPetPhoto", + "tags": ["pets"], + "summary": "Upload a pet's photo", + "parameters": [{ "name": "petId", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { "required": true, "content": { "image/*": { "schema": { "type": "string", "format": "binary" } } } }, + "responses": { "204": { "description": "The photo, stored." } } + } + }, + "/pets/audit": { + "get": { + "operationId": "auditPets", + "tags": ["pets"], + "summary": "Audit the pets", + "x-internal": true, + "responses": { "200": { "description": "The audit." } } + } + } + } +} From 48c4d1714386ae13be17cb99c77ff46f0359a01e Mon Sep 17 00:00:00 2001 From: waseem196 Date: Thu, 15 Oct 2026 15:14:10 +0500 Subject: [PATCH 05/41] refactor(portal): tidy the Ask AI copilot and test how its chat links pages - Name the service "Ask AI" in every message, and explain in the help when sync needs --auth-key. - Move the chat's link rewriting into its own module, leave full addresses alone, and test it under a base path. - Drop CopilotBundle.toJSON, which only tests used, and keep the linked dependencies in order. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 3 ++ portal-template/src/components/copilot.tsx | 14 +----- portal-template/src/lib/copilot-links.ts | 29 +++++++++++ src/commands/portal/copilot/sync.ts | 4 +- src/infrastructure/portal-project-service.ts | 2 +- src/prompts/portal/copilot/sync.ts | 4 +- src/types/portal/copilot/copilot-bundle.ts | 4 -- test/actions/portal/copilot/sync.test.ts | 13 ++++- .../services/copilot-ingest-service.test.ts | 2 +- test/portal-template/copilot-links.test.ts | 49 +++++++++++++++++++ .../copilot/generated-portal-context.test.ts | 6 ++- 11 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 portal-template/src/lib/copilot-links.ts create mode 100644 test/portal-template/copilot-links.test.ts diff --git a/README.md b/README.md index eeb390934..88124710a 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,9 @@ DESCRIPTION The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json'. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones. + Your saved APIMatic login is sent only to an Ask AI service APIMatic runs, or to one on this machine. For any other + service, pass your auth key with --auth-key. + EXAMPLES apimatic portal copilot sync diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index e89ee1d6d..4f40dd06a 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -5,8 +5,8 @@ import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport, type UIMessage } from 'ai'; import { AIChatPanel, AIChatProvider, AIChatSearch, AIChatSources, AIChatTrigger, useAIChat } from '@fumadocs/ai-chat'; import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; -import { withBasePath } from '@/lib/base-path'; import { CopilotClient } from '@/lib/copilot-client'; +import { servedSearch, servedSources } from '@/lib/copilot-links'; import type { PortalCopilot } from '@/lib/portal-types'; /** One part of a chat message: text, a tool call, a data part, and so on. */ @@ -69,18 +69,6 @@ function renderPart(part: Part, live: boolean): ReactNode { return null; } -// The service links pages by their path within the portal; the host serves them under the base path. -function servedSearch(part: SearchPart): SearchPart { - return { - ...part, - output: part.output?.map((found) => ({ doc: found.doc && { ...found.doc, url: withBasePath(found.doc.url) } })) - }; -} - -function servedSources(sources: Sources): Sources { - return sources.map((source) => ({ ...source, url: withBasePath(source.url) })); -} - function chatTransport(client: CopilotClient): DefaultChatTransport { // The token is the only credential; the portal's cookies are never sent. return new DefaultChatTransport({ diff --git a/portal-template/src/lib/copilot-links.ts b/portal-template/src/lib/copilot-links.ts new file mode 100644 index 000000000..007c9fff2 --- /dev/null +++ b/portal-template/src/lib/copilot-links.ts @@ -0,0 +1,29 @@ +import { withBasePath } from './base-path'; + +interface FoundPages { + output?: { doc?: { url: string } | null }[]; +} + +/** + * The service links pages by their path within the portal; the host serves them under the base + * path. A full address already says where it is, so it is left alone. + */ +export function servedUrl(url: string, base?: string): string { + return url.startsWith('/') && !url.startsWith('//') ? withBasePath(url, base) : url; +} + +/** The numbered source chips, linked where the host serves each page. */ +export function servedSources(sources: readonly T[], base?: string): T[] { + return sources.map((source) => ({ ...source, url: servedUrl(source.url, base) })); +} + +/** A search's found pages, linked where the host serves each one. */ +export function servedSearch(part: T, base?: string): T { + return { + ...part, + output: part.output?.map((found) => ({ + ...found, + doc: found.doc && { ...found.doc, url: servedUrl(found.doc.url, base) } + })) + }; +} diff --git a/src/commands/portal/copilot/sync.ts b/src/commands/portal/copilot/sync.ts index 3f9de377b..fb791e888 100644 --- a/src/commands/portal/copilot/sync.ts +++ b/src/commands/portal/copilot/sync.ts @@ -13,7 +13,9 @@ export default class PortalCopilotSync extends Command { static readonly description = `Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as they are now. Run it after deploying a portal generated with 'apimatic portal generate'. -The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json'. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones.`; +The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json'. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones. + +Your saved APIMatic login is sent only to an Ask AI service APIMatic runs, or to one on this machine. For any other service, pass your auth key with --auth-key.`; static readonly cmdTxt = format.cmd('apimatic', 'portal', 'copilot', 'sync'); diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index c11d80671..ee5692776 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -29,7 +29,6 @@ export const COPIED_DEPENDENCIES = ['@fontsource-variable/geist', '@fontsource-v export const LINKED_DEPENDENCIES = [ '@ai-sdk/react', '@fumadocs/ai-chat', - 'ai', '@fumadocs/api-docs', '@scalar/json-magic', '@tailwindcss/vite', @@ -37,6 +36,7 @@ export const LINKED_DEPENDENCIES = [ '@tanstack/react-start', '@tanstack/start-static-server-functions', '@vitejs/plugin-react', + 'ai', 'fumadocs-core', 'fumadocs-mdx', 'fumadocs-openapi', diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index 46a569460..bbaa84338 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -73,11 +73,11 @@ export class PortalCopilotSyncPrompts { public syncFailed(error: ServiceError, apiUrl: string) { switch (error.code) { case ServiceErrorCode.NetworkError: - log.error(`The Copilot service at ${f.link(apiUrl)} could not be reached. Check the address and try again.`); + log.error(`The Ask AI service at ${f.link(apiUrl)} could not be reached. Check the address and try again.`); return; case ServiceErrorCode.Forbidden: log.error( - `${error.errorMessage} The account of the auth key must own the portal that the Copilot key belongs to.` + `${error.errorMessage} The account of the auth key must own the portal that the Ask AI key belongs to.` ); return; case ServiceErrorCode.Timeout: diff --git a/src/types/portal/copilot/copilot-bundle.ts b/src/types/portal/copilot/copilot-bundle.ts index eb715441d..c03fc9ea9 100644 --- a/src/types/portal/copilot/copilot-bundle.ts +++ b/src/types/portal/copilot/copilot-bundle.ts @@ -50,8 +50,4 @@ export class CopilotBundle { public gzipped(): Buffer { return gzipSync(JSON.stringify(this.document)); } - - public toJSON(): BundleDocument { - return this.document; - } } diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index f71b1569f..0aadeb0c2 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import sinon from 'sinon'; import { expect } from 'chai'; +import { gunzipSync } from 'node:zlib'; import { err, ok } from 'neverthrow'; import { PortalCopilotSyncAction } from '../../../../src/actions/portal/copilot/sync'; import { CopilotIngestService } from '../../../../src/infrastructure/services/copilot-ingest-service'; @@ -13,6 +14,14 @@ import { DirectoryPath } from '../../../../src/types/file/directoryPath'; import { CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; import { ProjectContext } from '../../../../src/types/project-context'; +/** The bundle as the service receives it. */ +const uploaded = (bundle: CopilotBundle) => + JSON.parse(gunzipSync(bundle.gzipped()).toString('utf8')) as { + basePath: string; + siteUrl: string | null; + pages: { path: string }[]; + }; + const COMMAND_METADATA: CommandMetadata = { commandName: 'portal:copilot:sync', shell: 'test' }; const COPILOT = { key: 'pk_test', apiUrl: 'https://copilot.test' }; @@ -88,8 +97,8 @@ describe('PortalCopilotSyncAction', () => { const [copilot, bundle, , , authKey] = upload.firstCall.args as [unknown, CopilotBundle, unknown, unknown, string]; expect(copilot).to.deep.include(COPILOT); expect(authKey).to.equal('auth-key'); - expect(bundle.toJSON()).to.deep.include({ basePath: '/docs/', siteUrl: 'https://example.com/docs' }); - expect(bundle.toJSON().pages.map(({ path: page }) => page)).to.deep.equal(['/', '/guides/intro']); + expect(uploaded(bundle)).to.deep.include({ basePath: '/docs/', siteUrl: 'https://example.com/docs' }); + expect(uploaded(bundle).pages.map(({ path: page }) => page)).to.deep.equal(['/', '/guides/intro']); expect(awaitIndexing.firstCall.args[1]).to.equal('v-1'); expect(prompts.indexed.calledOnceWith(indexed('active'))).to.be.true; }); diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts index 40b093d48..8f2aa4676 100644 --- a/test/infrastructure/services/copilot-ingest-service.test.ts +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -81,7 +81,7 @@ describe('CopilotIngestService', () => { const result = await upload(); expect(result._unsafeUnwrap()).to.deep.equal({ versionId: VERSION_ID, status: 'queued', pageCount: 1 }); - expect(posted).to.deep.equal(bundle.toJSON()); + expect(posted).to.deep.equal(JSON.parse(gunzipSync(bundle.gzipped()).toString('utf8'))); expect(scope.isDone()).to.be.true; }); diff --git a/test/portal-template/copilot-links.test.ts b/test/portal-template/copilot-links.test.ts new file mode 100644 index 000000000..cd00cd70a --- /dev/null +++ b/test/portal-template/copilot-links.test.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { servedSearch, servedSources, servedUrl } from '../../portal-template/src/lib/copilot-links'; + +describe("the Ask AI chat's links", () => { + it("puts a portal page's path under the base path the host serves it at", () => { + expect(servedUrl('/guides/intro', '/docs/')).to.equal('/docs/guides/intro'); + expect(servedUrl('/', '/docs/')).to.equal('/docs/'); + expect(servedUrl('/guides/intro')).to.equal('/guides/intro'); + }); + + it('leaves a full address, or one on another host, as it is', () => { + expect(servedUrl('https://docs.example.com/guides/intro', '/docs/')).to.equal( + 'https://docs.example.com/guides/intro' + ); + expect(servedUrl('//cdn.example.com/page', '/docs/')).to.equal('//cdn.example.com/page'); + }); + + it('links each source chip under the base path, keeping its title and label', () => { + const sources = [ + { url: '/guides/auth', title: 'Authentication', label: '1' }, + { url: 'https://status.example.com', title: 'Status' } + ]; + + expect(servedSources(sources, '/docs/')).to.deep.equal([ + { url: '/docs/guides/auth', title: 'Authentication', label: '1' }, + { url: 'https://status.example.com', title: 'Status' } + ]); + }); + + it("links a search's found pages under the base path, keeping the rest of the search", () => { + const part = { + type: 'tool-search', + state: 'output-available', + input: { query: 'auth' }, + output: [{ doc: { url: '/guides/auth', title: 'Authentication' } }, { doc: null }] + }; + + expect(servedSearch(part, '/docs/')).to.deep.equal({ + ...part, + output: [{ doc: { url: '/docs/guides/auth', title: 'Authentication' } }, { doc: null }] + }); + }); + + it('leaves a search that has found nothing yet as it is', () => { + const running = { type: 'tool-search', state: 'input-streaming', output: undefined }; + + expect(servedSearch(running, '/docs/')).to.deep.equal(running); + }); +}); diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index 92ed85c75..353c96750 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -69,7 +69,11 @@ describe('GeneratedPortalContext', () => { const built = (await bundle(SiteAddress.parse('https://example.com/docs', 'url')._unsafeUnwrap()))!; - const document = built.toJSON(); + const document = JSON.parse(gunzipSync(built.gzipped()).toString('utf8')) as { + siteUrl: string | null; + basePath: string; + pages: { path: string; description?: string }[]; + }; expect(document.siteUrl).to.equal('https://example.com/docs'); expect(document.basePath).to.equal('/docs/'); expect(document.pages.map(({ path: page }) => page)).to.deep.equal(['/', '/guides/intro']); From 1e77599987d169ad33d90706e8c4898e26cb82ac Mon Sep 17 00:00:00 2001 From: waseem196 Date: Fri, 16 Oct 2026 10:49:14 +0500 Subject: [PATCH 06/41] feat(portal): publish the Ask AI bundle with the generated portal When `ai.copilot` is set, `portal generate` writes `__copilot/bundle.json` and `__copilot/build.json` into the portal, so the Ask AI service can refresh itself from the deployed site instead of waiting for `portal copilot sync`. The build ID is the sha256 of the bundle's bytes. The bundle is written before the portal is saved, so zipped portals carry it too. If it cannot be written, generate warns and points to `sync`. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/actions/portal/generate.ts | 23 +++++++- src/prompts/portal/generate.ts | 17 +++++- src/types/portal/copilot/copilot-bundle.ts | 19 ++++++- .../copilot/generated-portal-context.ts | 18 +++++++ test/actions/portal/generate.test.ts | 53 ++++++++++++++++++- test/e2e/portal-build.test.ts | 8 +++ .../copilot/generated-portal-context.test.ts | 41 ++++++++++++++ 7 files changed, 172 insertions(+), 7 deletions(-) diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 7646dc9ce..6afbc6b07 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -1,3 +1,4 @@ +import { envInfo } from '../../infrastructure/env-info.js'; import { PortalBuildService } from '../../infrastructure/portal-build-service.js'; import { PortalProjectPaths } from '../../infrastructure/portal-project-service.js'; import { PortalGeneratePrompts } from '../../prompts/portal/generate.js'; @@ -5,6 +6,7 @@ import { CommandMetadata } from '../../types/common/command-metadata.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { PortalContext } from '../../types/portal-context.js'; import { SiteAddress } from '../../types/portal/config/site-address.js'; +import { GeneratedPortalContext } from '../../types/portal/copilot/generated-portal-context.js'; import { ProjectContext } from '../../types/project-context.js'; import { ActionResult } from '../action-result.js'; import { PreparePortalProjectAction } from './prepare-project.js'; @@ -79,6 +81,9 @@ export class GenerateAction { return ActionResult.failed(); } + // Added before saving, so a zipped portal carries the bundle too. + const copilotPublished = copilot && (await this.publishCopilotBundle(build.value.output, site)); + const saved = await this.prompts.savePortal(portalContext.save(build.value.output, zipPortal)); if (saved.isErr()) { return ActionResult.failed(); @@ -86,8 +91,24 @@ export class GenerateAction { this.prompts.portalGenerated(portalDirectory); this.prompts.nextSteps(portalDirectory, zipPortal, site); - if (copilot) this.prompts.copilotSyncNextStep(); + if (copilotPublished) this.prompts.copilotUpdatesAfterDeploy(); return ActionResult.success(); } + + /** The portal still saves without the bundle; Ask AI is then updated by `portal copilot sync`. */ + private async publishCopilotBundle(builtDirectory: DirectoryPath, site: SiteAddress | null): Promise { + const portal = new GeneratedPortalContext(builtDirectory); + const bundle = await portal.bundle(site, envInfo.getCLIVersion(), new Date()); + if (bundle === null || bundle.pageCount() === 0) { + this.prompts.copilotPagesNotFound(); + return false; + } + const published = await portal.publish(bundle); + if (published.isErr()) { + this.prompts.copilotBundleNotWritten(published.error); + return false; + } + return true; + } } diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index e639ab772..9bc548b32 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -114,9 +114,22 @@ export class PortalGeneratePrompts { noteWrapped(message, 'Next steps'); } - public copilotSyncNextStep() { + public copilotUpdatesAfterDeploy() { log.info( - `Deploy the portal, then run ${f.cmdAlt('apimatic', 'portal', 'copilot', 'sync')} so Ask AI answers from it.` + 'Ask AI picks up these pages by itself once the portal is deployed. ' + + `If the Ask AI service cannot reach the portal, run ${syncCommand()} after deploying instead.` ); } + + public copilotPagesNotFound() { + log.warn(`The portal lists no pages for Ask AI to answer from. ${deployThenSync()}`); + } + + public copilotBundleNotWritten(reason: string) { + log.warn(`The pages for Ask AI could not be added to the portal (${reason}). ${deployThenSync()}`); + } } + +const syncCommand = () => f.cmdAlt('apimatic', 'portal', 'copilot', 'sync'); + +const deployThenSync = () => `Deploy the portal, then run ${syncCommand()} so Ask AI answers from it.`; diff --git a/src/types/portal/copilot/copilot-bundle.ts b/src/types/portal/copilot/copilot-bundle.ts index c03fc9ea9..6d5f8b626 100644 --- a/src/types/portal/copilot/copilot-bundle.ts +++ b/src/types/portal/copilot/copilot-bundle.ts @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; import { gzipSync } from 'node:zlib'; import { SiteAddress } from '../config/site-address.js'; import { ListedPage } from './llms-index.js'; @@ -25,7 +26,11 @@ const GENERATOR_NAME = 'apimatic-cli'; /** The portal's pages as the Ask AI service ingests them, in its format version 1. */ export class CopilotBundle { - private constructor(private readonly document: BundleDocument) {} + private readonly json: string; + + private constructor(private readonly document: BundleDocument) { + this.json = JSON.stringify(document); + } public static of(pages: BundlePage[], site: SiteAddress | null, cliVersion: string, at: Date): CopilotBundle { return new CopilotBundle({ @@ -48,6 +53,16 @@ export class CopilotBundle { } public gzipped(): Buffer { - return gzipSync(JSON.stringify(this.document)); + return gzipSync(this.json); + } + + /** As the deployed portal serves it for the Ask AI service to fetch: plain, since hosts compress it themselves. */ + public toJson(): string { + return this.json; + } + + /** Names these exact bytes, so the Ask AI service fetches the bundle only when a deploy changed it. */ + public buildManifest(): string { + return JSON.stringify({ formatVersion: 1, buildId: createHash('sha256').update(this.json).digest('hex') }); } } diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts index 888775370..0b1a75ba2 100644 --- a/src/types/portal/copilot/generated-portal-context.ts +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -1,3 +1,4 @@ +import { err, ok, Result } from 'neverthrow'; import { FileService } from '../../../infrastructure/file-service.js'; import { DirectoryPath } from '../../file/directoryPath.js'; import { FileName } from '../../file/fileName.js'; @@ -5,6 +6,10 @@ import { FilePath } from '../../file/filePath.js'; import { SiteAddress } from '../config/site-address.js'; import { BundlePage, CopilotBundle } from './copilot-bundle.js'; import { ListedPage, listedPages } from './llms-index.js'; +import { errorMessage } from '../../../utils/error-utils.js'; + +/** Where the deployed portal serves its bundle, under the site's address, for the Ask AI service to fetch. */ +export const COPILOT_DIRECTORY_NAME = '__copilot'; /** A portal `portal generate` wrote, read for the pages Ask AI answers from. */ export class GeneratedPortalContext { @@ -32,6 +37,19 @@ export class GeneratedPortalContext { return CopilotBundle.of(pages, site, cliVersion, at); } + /** Writes the bundle into the portal, so it is deployed with the pages it holds. */ + public async publish(bundle: CopilotBundle): Promise> { + const directory = this.portalDirectory.join(COPILOT_DIRECTORY_NAME); + try { + await this.fileService.createDirectoryIfNotExists(directory); + await this.fileService.writeContents(new FilePath(directory, new FileName('bundle.json')), bundle.toJson()); + await this.fileService.writeContents(new FilePath(directory, new FileName('build.json')), bundle.buildManifest()); + return ok(undefined); + } catch (error) { + return err(errorMessage(error)); + } + } + private markdownOf({ path }: ListedPage): FilePath { if (path === '/') { return new FilePath(this.portalDirectory, new FileName('index.md')); diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index bac899535..2bb1c4143 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -21,6 +22,7 @@ const COMMAND_METADATA: CommandMetadata = { commandName: 'portal generate', shel const FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/default'); const CODE_SAMPLES_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/code-samples'); const SUBPATH_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/subpath'); +const COPILOT_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/copilot'); /** The catalogs the merged fixture expects, read the way the service reads them. */ const samplesFromFixture = (): CodeSampleCatalogs => { @@ -37,6 +39,7 @@ describe('GenerateAction', () => { let shared: ReturnType; let authorize: sinon.SinonStub; let build: sinon.SinonStub; + let builtSite: DirectoryPath; const execute = (projectDirectory = FIXTURE, force = false, zip = false) => new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute( @@ -58,7 +61,7 @@ describe('GenerateAction', () => { portalDirectory = new DirectoryPath(root).join('portal'); // What a successful build hands back: a directory holding the finished site. - const builtSite = new DirectoryPath(root).join('site'); + builtSite = new DirectoryPath(root).join('site'); fs.mkdirSync(builtSite.toString(), { recursive: true }); fs.writeFileSync(path.join(builtSite.toString(), 'index.html'), ''); fs.writeFileSync(path.join(builtSite.toString(), '_shell.html'), ''); @@ -239,7 +242,53 @@ describe('GenerateAction', () => { expect(inPortal('404.html')).to.be.true; expect(prompts.portalGenerated.calledOnceWith(portalDirectory)).to.be.true; expect(prompts.nextSteps.calledOnceWith(portalDirectory, false)).to.be.true; - expect(prompts.copilotSyncNextStep.called, 'no Ask AI to sync').to.be.false; + expect(prompts.copilotUpdatesAfterDeploy.called, 'no Ask AI to update').to.be.false; + expect(inPortal('__copilot')).to.be.false; + }); + + describe('with Ask AI', () => { + const readPortal = (relative: string) => fs.readFileSync(path.join(portalDirectory.toString(), relative), 'utf8'); + + const listPages = () => { + fs.writeFileSync(path.join(builtSite.toString(), 'llms.txt'), '- [Welcome](/): Start here'); + fs.writeFileSync(path.join(builtSite.toString(), 'index.md'), '# Welcome'); + }; + + it('deploys the pages with the portal, named by a build ID the service compares', async () => { + listPages(); + + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + const bundle = readPortal('__copilot/bundle.json'); + const { pages } = JSON.parse(bundle) as { pages: unknown[] }; + expect(pages).to.deep.equal([{ path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }]); + expect(JSON.parse(readPortal('__copilot/build.json'))).to.deep.equal({ + formatVersion: 1, + buildId: createHash('sha256').update(bundle).digest('hex') + }); + expect(prompts.copilotUpdatesAfterDeploy.calledOnce).to.be.true; + }); + + it('puts the pages in the archive when the portal is zipped', async () => { + listPages(); + + const result = await execute(COPILOT_FIXTURE, false, true); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(fs.existsSync(path.join(builtSite.toString(), '__copilot', 'build.json'))).to.be.true; + }); + + it('still saves the portal when it lists no pages, pointing to sync instead', async () => { + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotPagesNotFound.calledOnce).to.be.true; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + }); }); it('writes an archive instead when asked', async () => { diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 65d27422c..85d8d81ae 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -11,6 +11,7 @@ import { DirectoryPath } from '../../src/types/file/directoryPath'; import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalog, CodeSampleCatalogs } from '../../src/types/portal/code-samples'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; +import { GeneratedPortalContext } from '../../src/types/portal/copilot/generated-portal-context'; import { Language } from '../../src/types/sdk/generate'; import { ensurePortalProjectDirectoryBase, @@ -936,6 +937,13 @@ const stylesheetOf = (output: DirectoryPath) => { expect(loaded.filter((name) => chat.some((script) => script.name === name))).to.deep.equal([]); }); + // `generate` publishes these pages for the service to fetch, so a real build must list them with their Markdown. + it('lists the pages Ask AI answers from, each with its Markdown copy', async () => { + const bundle = await new GeneratedPortalContext(portal.output()).bundle(null, '0.0.0', new Date()); + + expect(bundle?.pageCount()).to.be.greaterThan(1); + }); + it('tells the browser the public key and the service, and no more', () => { const scripts = scriptsOf(portal.output()); diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index 353c96750..1598c666b 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { gunzipSync } from 'node:zlib'; import { expect } from 'chai'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; @@ -85,4 +86,44 @@ describe('GeneratedPortalContext', () => { expect((await bundle())!.pageCount()).to.equal(0); }); + + describe('publish', () => { + const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + + beforeEach(() => { + write('llms.txt', '- [Welcome](/)'); + write('index.md', '# Welcome'); + }); + + it('writes the bundle as plain JSON beside a build ID of its exact bytes', async () => { + const built = (await bundle())!; + + expect((await new GeneratedPortalContext(new DirectoryPath(root)).publish(built)).isOk()).to.be.true; + + const json = read('__copilot/bundle.json'); + expect(JSON.parse(json)).to.deep.equal(JSON.parse(gunzipSync(built.gzipped()).toString('utf8'))); + expect(JSON.parse(read('__copilot/build.json'))).to.deep.equal({ + formatVersion: 1, + buildId: createHash('sha256').update(json).digest('hex') + }); + }); + + it('names the same pages with the same build ID, and changed pages with another', async () => { + const buildId = async () => (JSON.parse((await bundle())!.buildManifest()) as { buildId: string }).buildId; + const first = await buildId(); + + expect(await buildId()).to.equal(first); + write('index.md', '# Welcome back'); + expect(await buildId()).to.not.equal(first); + }); + + it('reports a portal it cannot write to rather than throwing', async () => { + const built = (await bundle())!; + write('__copilot', 'a file where the directory goes'); + + const published = await new GeneratedPortalContext(new DirectoryPath(root)).publish(built); + + expect(published.isErr()).to.be.true; + }); + }); }); From 9f1927ab82c381525d91f8b141a2544789c4ddba Mon Sep 17 00:00:00 2001 From: waseem196 Date: Fri, 16 Oct 2026 14:48:17 +0500 Subject: [PATCH 07/41] test(portal): restore the Ask AI e2e suite the dev merge folded into another block The merge of dev dropped the end of the merged-spec-folders suite and the start of the Ask AI one, so the chat's checks ran against a portal that offers no chat. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/e2e/portal-build.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 368ad3441..df040e390 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -972,6 +972,13 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read(`${LIST_ORDERS}/index.html`)).to.contain('grandTotalCents'); expect(read(`${LIST_ORDERS}.md`)).to.contain('grandTotalCents'); }); +}); + +(enabled ? describe : describe.skip)('portal build, with the Ask AI chat (end to end)', function () { + this.timeout(10 * 60 * 1000); + + const portal = portalBuilt('copilot'); + const { read } = portal; // The chat wraps the whole layout, so a prerender that did not wait for its chunk would leave the button out. it('prerenders each page with the chat button', () => { @@ -1000,5 +1007,4 @@ const stylesheetOf = (output: DirectoryPath) => { expect(scripts.some((script) => script.text.includes('pk_test_e2e'))).to.be.true; expect(scripts.some((script) => script.text.includes('https://ask.apimatic.io'))).to.be.true; }); - }); From 635b2f10e8b2837563dda094458ecfdf6dc8a67d Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 12:45:56 +0500 Subject: [PATCH 08/41] refactor(portal): hand the Ask AI chat the docs layout as a component Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/components/copilot.tsx | 22 ++++++++++++++-------- portal-template/src/lib/layout.tsx | 11 +++-------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index 4f40dd06a..f047cf9ba 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -1,6 +1,7 @@ // The Ask AI copilot: Fumadocs' AI chat panel, answered by the Ask AI service (the portal is static). // `@fumadocs/ai-chat` is 0.x and needs fumadocs-ui >= 16.16: keep it pinned and check the chat on every upgrade. -import { useMemo, useState, type ReactNode } from 'react'; +import { useMemo, useState, type ComponentType, type ReactNode } from 'react'; +import type * as PageTree from 'fumadocs-core/page-tree'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport, type UIMessage } from 'ai'; import { AIChatPanel, AIChatProvider, AIChatSearch, AIChatSources, AIChatTrigger, useAIChat } from '@fumadocs/ai-chat'; @@ -15,24 +16,29 @@ type Part = UIMessage['parts'][number]; type SearchPart = Parameters[0]['part']; type Sources = Parameters[0]['sources']; +/** The docs layout the chat goes around, handed the chat's `aiChat` option. */ +type ChatLayout = ComponentType>; +type LayoutProps = Readonly<{ layout: ChatLayout; tree: PageTree.Root; children: ReactNode }>; + /** * The docs layout with the Ask AI chat around it. The module's one export, so the layout can load * it on its own, and only for a portal that offers the chat. */ -export default function CopilotLayout({ - copilot, - layout -}: Readonly<{ copilot: PortalCopilot; layout: (aiChat: AIChatOptions) => ReactNode }>) { +export default function CopilotLayout({ copilot, ...layout }: LayoutProps & Readonly<{ copilot: PortalCopilot }>) { return ( - + ); } // Separate, because the panel state exists only inside `CopilotProvider`. -function WithPanel({ layout }: Readonly<{ layout: (aiChat: AIChatOptions) => ReactNode }>) { - return layout(useCopilotPanel()); +function WithPanel({ layout: Layout, tree, children }: LayoutProps) { + return ( + + {children} + + ); } /** The chat state and the "Ask AI" button; wraps the layout so a conversation survives navigation. */ diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index a6874ef26..c56c495bf 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -16,14 +16,9 @@ export function PortalLayout({ tree, children }: LayoutProps) { // The prerender waits for the chunk, so the fallback only shows if it is slow on a client-side navigation. return ( {children}}> - ( - - {children} - - )} - /> + + {children} + ); } From 05a04d5e8ad76b6c70b1b8f79ea7589df8327eb5 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 12:48:41 +0500 Subject: [PATCH 09/41] refactor(portal): keep the loopback hosts in a set Co-Authored-By: Claude Opus 5.5 (1M context) --- src/types/portal/config/copilot-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/portal/config/copilot-config.ts b/src/types/portal/config/copilot-config.ts index d8b891b60..657cb911e 100644 --- a/src/types/portal/config/copilot-config.ts +++ b/src/types/portal/config/copilot-config.ts @@ -49,11 +49,11 @@ export class CopilotConfig { } } -const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); const APIMATIC_DOMAIN = 'apimatic.io'; function isLoopback(url: URL): boolean { - return LOOPBACK_HOSTS.includes(url.hostname) || url.hostname.endsWith('.localhost'); + return LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith('.localhost'); } /** Whether sync may send the saved APIMatic login to the service: only APIMatic's own, or one on this machine. */ From dc23931700c36825d99bd572c99d7e9132e7f656 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 12:50:20 +0500 Subject: [PATCH 10/41] refactor(portal): keep the Ask AI indexing states in a set Co-Authored-By: Claude Opus 5.5 (1M context) --- src/infrastructure/services/copilot-ingest-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/services/copilot-ingest-service.ts b/src/infrastructure/services/copilot-ingest-service.ts index 9df37caa6..975397f39 100644 --- a/src/infrastructure/services/copilot-ingest-service.ts +++ b/src/infrastructure/services/copilot-ingest-service.ts @@ -22,7 +22,7 @@ export type CopilotIngestTimings = Partial; /** All the service needs of the portal's Ask AI settings: where it is, and which portal's chat it is. */ export type CopilotTarget = Pick; -const STATES: readonly IndexingState[] = ['queued', 'building', 'active', 'superseded', 'failed']; +const STATES = new Set(['queued', 'building', 'active', 'superseded', 'failed']); /** Uploads a portal's pages to the Ask AI service, which swaps them in once it has indexed them all. */ export class CopilotIngestService { @@ -176,7 +176,7 @@ function isReceipt(body: unknown): body is IngestReceipt { // Only the state is needed to decide; the counts are reported, and a missing one reads as none. function statusOf(body: unknown): IngestStatus | null { - if (!isJsonObject(body) || typeof body.versionId !== 'string' || !STATES.includes(body.status as IndexingState)) { + if (!isJsonObject(body) || typeof body.versionId !== 'string' || !STATES.has(body.status as IndexingState)) { return null; } const count = (key: string): number => { From 82b26e70db7cba1ba4bc3e17f8564c0009ef0837 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 12:51:15 +0500 Subject: [PATCH 11/41] refactor(portal): make the Ask AI sync command's flags readonly Co-Authored-By: Claude Opus 5.5 (1M context) --- src/commands/portal/copilot/sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/portal/copilot/sync.ts b/src/commands/portal/copilot/sync.ts index fb791e888..fda969d73 100644 --- a/src/commands/portal/copilot/sync.ts +++ b/src/commands/portal/copilot/sync.ts @@ -25,7 +25,7 @@ Your saved APIMatic login is sent only to an Ask AI service APIMatic runs, or to `${PortalCopilotSync.cmdTxt} ${format.flag('no-wait')}` ]; - static flags = { + static readonly flags = { wait: Flags.boolean({ default: true, allowNo: true, From 13233b03e2119e13c93b7effc100ae1de787aafa Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 12:53:40 +0500 Subject: [PATCH 12/41] refactor(portal): read the Ask AI pages together rather than one by one Co-Authored-By: Claude Opus 5.5 (1M context) --- .../portal/copilot/generated-portal-context.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts index 0b1a75ba2..b1777fc5e 100644 --- a/src/types/portal/copilot/generated-portal-context.ts +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -27,13 +27,8 @@ export class GeneratedPortalContext { return null; } const listed = listedPages(await this.fileService.getContents(this.index), site); - const pages: BundlePage[] = []; - for (const page of listed) { - const markdown = this.markdownOf(page); - if (await this.fileService.fileExists(markdown)) { - pages.push({ ...page, markdown: await this.fileService.getContents(markdown) }); - } - } + const read = await Promise.all(listed.map((page) => this.read(page))); + const pages = read.filter((page) => page !== null); return CopilotBundle.of(pages, site, cliVersion, at); } @@ -50,6 +45,15 @@ export class GeneratedPortalContext { } } + /** The page with its Markdown twin, or null when it has none. */ + private async read(page: ListedPage): Promise { + const markdown = this.markdownOf(page); + if (!(await this.fileService.fileExists(markdown))) { + return null; + } + return { ...page, markdown: await this.fileService.getContents(markdown) }; + } + private markdownOf({ path }: ListedPage): FilePath { if (path === '/') { return new FilePath(this.portalDirectory, new FileName('index.md')); From b127416f09003d5f294a7a448eb1d6eea0d1e8da Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 12:54:51 +0500 Subject: [PATCH 13/41] refactor(portal): read llms.txt links with a regex that cannot backtrack on the description Co-Authored-By: Claude Opus 5.5 (1M context) --- src/types/portal/copilot/llms-index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/portal/copilot/llms-index.ts b/src/types/portal/copilot/llms-index.ts index d4c34e45b..cbe617765 100644 --- a/src/types/portal/copilot/llms-index.ts +++ b/src/types/portal/copilot/llms-index.ts @@ -7,7 +7,7 @@ export interface ListedPage { description: string | null; } -const LINK = /^\s*[-*]\s+\[(.*)\]\(([^)\s]+)\)(?::\s*(.*?))?\s*$/; +const LINK = /^\s*[-*]\s+\[(.*)\]\(([^)\s]+)\)(?::(.*)|\s*)$/; const DOWNLOADS = '/__downloads/'; @@ -23,7 +23,7 @@ export function listedPages(llmsTxt: string, site: SiteAddress | null): ListedPa const [, title, href, description] = match; const path = portalPath(href, basePath); if (path !== null && !pages.has(path)) { - pages.set(path, { path, title: title.trim(), description: description ? description : null }); + pages.set(path, { path, title: title.trim(), description: description?.trim() || null }); } } return [...pages.values()]; From 4585be32325187ab3316096cc595d488e1920c06 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 12:55:35 +0500 Subject: [PATCH 14/41] test(portal): assert the new chat's thread with the null matcher Co-Authored-By: Claude Opus 5.5 (1M context) --- test/portal-template/copilot-client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/portal-template/copilot-client.test.ts b/test/portal-template/copilot-client.test.ts index 53ce067c2..759215c0b 100644 --- a/test/portal-template/copilot-client.test.ts +++ b/test/portal-template/copilot-client.test.ts @@ -108,6 +108,6 @@ describe('the Ask AI client', () => { }); client.noteData({ type: 'data-thread', data: { threadId: 'th-1' } }); expect(client.questionBody([{ id: 'q1' }, { id: 'a1' }, { id: 'q2' }], 'submit-message').threadId).to.equal('th-1'); - expect(client.questionBody([{ id: 'q9' }], 'submit-message').threadId).to.equal(null); + expect(client.questionBody([{ id: 'q9' }], 'submit-message').threadId).to.be.null; }); }); From 08c2977b115db4c02ca69293295c3c1967712ad4 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 16:15:32 +0500 Subject: [PATCH 15/41] fix(portal): treat a blank --auth-key as no key, so sync never sends the saved login `--auth-key ""` passed the action's null check, then `authKey || saved` in the service sent the saved APIMatic login to any service address. The action now decides the key in one place: a passed key with content, else the saved login only for APIMatic's own service or one on this machine. The service takes the key it is given. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/actions/portal/copilot/sync.ts | 29 +++++++++++-- .../services/copilot-ingest-service.ts | 31 ++++---------- src/prompts/portal/copilot/sync.ts | 4 ++ test/actions/portal/copilot/sync.test.ts | 32 +++++++++++++- .../services/copilot-ingest-service.test.ts | 42 +++---------------- 5 files changed, 72 insertions(+), 66 deletions(-) diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts index 9e7ac5c26..d426303be 100644 --- a/src/actions/portal/copilot/sync.ts +++ b/src/actions/portal/copilot/sync.ts @@ -1,3 +1,4 @@ +import { getAuthInfo } from '../../../client-utils/auth-manager.js'; import { envInfo } from '../../../infrastructure/env-info.js'; import { CopilotIngestService } from '../../../infrastructure/services/copilot-ingest-service.js'; import { PortalCopilotSyncPrompts } from '../../../prompts/portal/copilot/sync.js'; @@ -37,8 +38,8 @@ export class PortalCopilotSyncAction { this.prompts.copilotNotConfigured(project.sourceDirectory()); return ActionResult.failed(); } - if (this.authKey === null && !receivesSavedLogin(copilot.apiUrl)) { - this.prompts.savedLoginWithheld(copilot.apiUrl); + const authKey = await this.authKeyFor(copilot.apiUrl); + if (authKey === null) { return ActionResult.failed(); } @@ -58,7 +59,7 @@ export class PortalCopilotSyncAction { const { shell } = this.commandMetadata; const receipt = await this.prompts.upload( - this.ingestService.upload(copilot, bundle, this.configDir, shell, this.authKey), + this.ingestService.upload(copilot, bundle, shell, authKey), bundle.pageCount() ); if (receipt.isErr()) { @@ -75,7 +76,7 @@ export class PortalCopilotSyncAction { } const indexed = await this.prompts.awaitIndexing( - this.ingestService.awaitIndexing(copilot, receipt.value.versionId, this.configDir, shell, this.authKey) + this.ingestService.awaitIndexing(copilot, receipt.value.versionId, shell, authKey) ); if (indexed.isErr()) { this.prompts.syncFailed(indexed.error, copilot.apiUrl); @@ -88,4 +89,24 @@ export class PortalCopilotSyncAction { } return ActionResult.success(); }; + + /** The only place that decides which key the service receives: the passed one, else the saved login if it may have it. */ + private async authKeyFor(apiUrl: string): Promise { + // A blank `--auth-key` (say, from an unset variable) is no key, so it never lets the saved login through. + const passed = this.authKey?.trim() ?? ''; + if (passed !== '') { + return passed; + } + if (!receivesSavedLogin(apiUrl)) { + this.prompts.savedLoginWithheld(apiUrl); + return null; + } + // `auth logout` blanks the stored key rather than deleting the file, so the key is checked, not the file. + const saved = (await getAuthInfo(this.configDir.toString()))?.authKey; + if (!saved) { + this.prompts.notLoggedIn(); + return null; + } + return saved; + } } diff --git a/src/infrastructure/services/copilot-ingest-service.ts b/src/infrastructure/services/copilot-ingest-service.ts index 975397f39..83f1881df 100644 --- a/src/infrastructure/services/copilot-ingest-service.ts +++ b/src/infrastructure/services/copilot-ingest-service.ts @@ -1,7 +1,5 @@ import axios, { AxiosResponse } from 'axios'; import { err, ok, Result } from 'neverthrow'; -import { getAuthInfo } from '../../client-utils/auth-manager.js'; -import { DirectoryPath } from '../../types/file/directoryPath.js'; import { PortalCopilot } from '../../types/portal/config/copilot-config.js'; import { CopilotBundle } from '../../types/portal/copilot/copilot-bundle.js'; import { IndexedVersion, IndexingState, IngestReceipt, IngestStatus } from '../../types/portal/copilot/ingest.js'; @@ -35,17 +33,11 @@ export class CopilotIngestService { public async upload( copilot: CopilotTarget, bundle: CopilotBundle, - configDir: DirectoryPath, shell: string, - authKey: string | null + authKey: string ): Promise> { - const token = await this.token(configDir, authKey); - if (token === null) { - return err(ServiceError.unauthorizedWithHint(null)); - } - try { - const response = await this.axiosInstance(copilot, shell, token, this.timings.uploadTimeoutMs).post( + const response = await this.axiosInstance(copilot, shell, authKey, this.timings.uploadTimeoutMs).post( '/v1/ingest', bundle.gzipped(), { @@ -68,20 +60,14 @@ export class CopilotIngestService { public async awaitIndexing( copilot: CopilotTarget, versionId: string, - configDir: DirectoryPath, shell: string, - authKey: string | null + authKey: string ): Promise> { - const token = await this.token(configDir, authKey); - if (token === null) { - return err(ServiceError.unauthorizedWithHint(null)); - } - const deadline = Date.now() + this.timings.indexTimeoutMs; for (;;) { await sleep(this.timings.pollIntervalMs); - const current = await this.status(copilot, versionId, shell, token); + const current = await this.status(copilot, versionId, shell, authKey); if (current.isErr()) { return err(current.error); } @@ -95,7 +81,9 @@ export class CopilotIngestService { // Read after the status, so a version that finished during the last wait still counts. if (Date.now() >= deadline) { const minutes = Math.max(1, Math.round(this.timings.indexTimeoutMs / 60_000)); - return err(ServiceError.timeout(`Indexing did not finish within ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`)); + return err( + ServiceError.timeout(`Indexing did not finish within ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`) + ); } } } @@ -120,11 +108,6 @@ export class CopilotIngestService { } } - private async token(configDir: DirectoryPath, authKey: string | null): Promise { - // `auth logout` blanks the stored key rather than deleting the file, so the key is checked, not the file. - return authKey || (await getAuthInfo(configDir.toString()))?.authKey || null; - } - private axiosInstance(copilot: CopilotTarget, shell: string, token: string, timeout: number) { return axios.create({ baseURL: copilot.apiUrl, diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index bbaa84338..82ae15b54 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -37,6 +37,10 @@ export class PortalCopilotSyncPrompts { log.error(message); } + public notLoggedIn() { + log.error(ServiceError.unauthorizedWithHint(null).errorMessage); + } + public portalNotGenerated(portalDirectory: DirectoryPath) { const message = `No generated portal was found at ${f.path(portalDirectory)}. ` + diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index 0aadeb0c2..5edd7eb73 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -56,6 +56,8 @@ describe('PortalCopilotSyncAction', () => { }) ); + const writeSavedLogin = (authKey: string) => write('config.json', JSON.stringify({ email: 'a@b.c', authKey })); + const writePortal = () => { write('portal/llms.txt', ['- [Docs](/docs)', ' - [Intro](/docs/guides/intro): Start'].join('\n')); write('portal/index.md', '# Docs'); @@ -94,7 +96,7 @@ describe('PortalCopilotSyncAction', () => { const result = await execute(); expect(result.isSuccess()).to.be.true; - const [copilot, bundle, , , authKey] = upload.firstCall.args as [unknown, CopilotBundle, unknown, unknown, string]; + const [copilot, bundle, , authKey] = upload.firstCall.args as [unknown, CopilotBundle, unknown, string]; expect(copilot).to.deep.include(COPILOT); expect(authKey).to.equal('auth-key'); expect(uploaded(bundle)).to.deep.include({ basePath: '/docs/', siteUrl: 'https://example.com/docs' }); @@ -169,16 +171,42 @@ describe('PortalCopilotSyncAction', () => { expect((await execute()).isSuccess()).to.be.true; }); + it('treats a blank --auth-key as no key, so the saved login stays withheld', async () => { + writeSavedLogin('saved-key'); + + for (const blank of ['', ' ']) { + expect((await execute(true, blank)).isFailed(), JSON.stringify(blank)).to.be.true; + } + expect(prompts.savedLoginWithheld.callCount).to.equal(2); + expect(upload.called).to.be.false; + }); + it("uses the saved login with APIMatic's own service and one on this machine", async () => { + writeSavedLogin('saved-key'); + for (const apiUrl of ['https://ask.apimatic.io', 'http://localhost:8787']) { writeConfig({ copilot: { ...COPILOT, apiUrl } }); expect((await execute(true, null)).isSuccess(), apiUrl).to.be.true; + expect((await execute(true, '')).isSuccess(), apiUrl).to.be.true; } - expect(upload.callCount).to.equal(2); + expect(upload.args.map((args) => args[3])).to.deep.equal(Array(4).fill('saved-key')); + expect(awaitIndexing.args.map((args) => args[3])).to.deep.equal(Array(4).fill('saved-key')); expect(prompts.savedLoginWithheld.called).to.be.false; }); + it('asks the user to log in, without uploading, when no key is passed or saved', async () => { + writeConfig({ copilot: { ...COPILOT, apiUrl: 'https://ask.apimatic.io' } }); + // `auth logout` leaves the file with a blank key. + writeSavedLogin(''); + + const result = await execute(true, null); + + expect(result.isFailed()).to.be.true; + expect(prompts.notLoggedIn.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); + it('fails without uploading when the portal has no Ask AI chat', async () => { writeConfig(null); diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts index 8f2aa4676..d7b05a83b 100644 --- a/test/infrastructure/services/copilot-ingest-service.test.ts +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -1,15 +1,10 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import net from 'node:net'; -import { stripVTControlCharacters } from 'node:util'; import { Buffer } from 'node:buffer'; import { gunzipSync } from 'node:zlib'; import nock from 'nock'; import { expect } from 'chai'; import { CopilotIngestService } from '../../../src/infrastructure/services/copilot-ingest-service'; import { ServiceErrorCode } from '../../../src/infrastructure/service-error'; -import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { CopilotBundle } from '../../../src/types/portal/copilot/copilot-bundle'; const API_URL = 'http://copilot.test'; @@ -44,23 +39,14 @@ const closedPort = async (): Promise => { }; describe('CopilotIngestService', () => { - let configRoot: string; - let configDir: DirectoryPath; const service = new CopilotIngestService({ pollIntervalMs: 1, indexTimeoutMs: 5000 }); - const upload = (authKey: string | null = 'auth-key') => service.upload(COPILOT, bundle, configDir, 'bash', authKey); - const awaitIndexing = () => service.awaitIndexing(COPILOT, VERSION_ID, configDir, 'bash', 'auth-key'); + const upload = () => service.upload(COPILOT, bundle, 'bash', 'auth-key'); + const awaitIndexing = () => service.awaitIndexing(COPILOT, VERSION_ID, 'bash', 'auth-key'); - beforeEach(() => { - configRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-ingest-')); - configDir = new DirectoryPath(configRoot); - nock.cleanAll(); - }); + beforeEach(() => nock.cleanAll()); - afterEach(() => { - fs.rmSync(configRoot, { recursive: true, force: true }); - nock.cleanAll(); - }); + afterEach(() => nock.cleanAll()); describe('upload', () => { it('posts the gzipped bundle with both keys and answers with the queued version', async () => { @@ -85,22 +71,6 @@ describe('CopilotIngestService', () => { expect(scope.isDone()).to.be.true; }); - it('uses the stored key when no flag overrides it', async () => { - fs.writeFileSync(path.join(configRoot, 'config.json'), JSON.stringify({ email: 'a@b.c', authKey: 'stored' })); - nock(API_URL, { reqheaders: { authorization: 'X-Auth-Key stored' } }) - .post('/v1/ingest') - .reply(200, { versionId: VERSION_ID, status: 'unchanged', pageCount: 1 }); - - expect((await upload(null))._unsafeUnwrap().status).to.equal('unchanged'); - }); - - it('asks the user to sign in, without a request, when there is no key', async () => { - const result = await upload(null); - - expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.UnAuthorized); - expect(stripVTControlCharacters(result._unsafeUnwrapErr().errorMessage)).to.contain('apimatic auth login'); - }); - it("surfaces the service's message for a refused upload", async () => { nock(API_URL) .post('/v1/ingest') @@ -121,7 +91,7 @@ describe('CopilotIngestService', () => { it('reports a service that cannot be reached as a network error', async () => { const unreachable = { ...COPILOT, apiUrl: `http://127.0.0.1:${await closedPort()}` }; - const result = await service.upload(unreachable, bundle, configDir, 'bash', 'auth-key'); + const result = await service.upload(unreachable, bundle, 'bash', 'auth-key'); expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.NetworkError); }); @@ -161,7 +131,7 @@ describe('CopilotIngestService', () => { nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).times(1000).reply(200, statusBody('building')); const impatient = new CopilotIngestService({ pollIntervalMs: 1, indexTimeoutMs: 20 }); - const result = await impatient.awaitIndexing(COPILOT, VERSION_ID, configDir, 'bash', 'auth-key'); + const result = await impatient.awaitIndexing(COPILOT, VERSION_ID, 'bash', 'auth-key'); expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.Timeout); }); From a647b1af006e46b5728ac8498ba48d76843342ec Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 16:22:05 +0500 Subject: [PATCH 16/41] fix(portal): link Ask AI sources by their portal path so the router adds the base path once The chat's source chips and search results render Fumadocs' Link, which the TanStack router serves under its basepath. Prefixing the base path as well linked `/docs/docs/...` under a sub-path, and every source 404'd. The service's paths now go through unchanged, and a test renders the chat's sources under a base path. Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/components/copilot.tsx | 6 +- portal-template/src/lib/copilot-links.ts | 29 ---------- test/portal-template/copilot-links.test.ts | 67 +++++++++++----------- 3 files changed, 35 insertions(+), 67 deletions(-) delete mode 100644 portal-template/src/lib/copilot-links.ts diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index f047cf9ba..1517908f8 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -7,7 +7,6 @@ import { DefaultChatTransport, type UIMessage } from 'ai'; import { AIChatPanel, AIChatProvider, AIChatSearch, AIChatSources, AIChatTrigger, useAIChat } from '@fumadocs/ai-chat'; import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; import { CopilotClient } from '@/lib/copilot-client'; -import { servedSearch, servedSources } from '@/lib/copilot-links'; import type { PortalCopilot } from '@/lib/portal-types'; /** One part of a chat message: text, a tool call, a data part, and so on. */ @@ -66,12 +65,13 @@ function useCopilotPanel(): AIChatOptions { /** * Draws the non-text parts the service sends; the panel draws text itself. * A new part type from the service needs a branch here, or it is not shown. + * Pages are linked by their path within the portal, as the service sends them: the router adds the base path. */ function renderPart(part: Part, live: boolean): ReactNode { // "Searched ยท N results", expandable to the pages the answer was based on. - if (part.type === 'tool-search') return ; + if (part.type === 'tool-search') return ; // The numbered source chips, matching the [1], [2] citations in the answer. - if (part.type === 'data-sources') return ; + if (part.type === 'data-sources') return ; return null; } diff --git a/portal-template/src/lib/copilot-links.ts b/portal-template/src/lib/copilot-links.ts deleted file mode 100644 index 007c9fff2..000000000 --- a/portal-template/src/lib/copilot-links.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { withBasePath } from './base-path'; - -interface FoundPages { - output?: { doc?: { url: string } | null }[]; -} - -/** - * The service links pages by their path within the portal; the host serves them under the base - * path. A full address already says where it is, so it is left alone. - */ -export function servedUrl(url: string, base?: string): string { - return url.startsWith('/') && !url.startsWith('//') ? withBasePath(url, base) : url; -} - -/** The numbered source chips, linked where the host serves each page. */ -export function servedSources(sources: readonly T[], base?: string): T[] { - return sources.map((source) => ({ ...source, url: servedUrl(source.url, base) })); -} - -/** A search's found pages, linked where the host serves each one. */ -export function servedSearch(part: T, base?: string): T { - return { - ...part, - output: part.output?.map((found) => ({ - ...found, - doc: found.doc && { ...found.doc, url: servedUrl(found.doc.url, base) } - })) - }; -} diff --git a/test/portal-template/copilot-links.test.ts b/test/portal-template/copilot-links.test.ts index cd00cd70a..9fc479ca0 100644 --- a/test/portal-template/copilot-links.test.ts +++ b/test/portal-template/copilot-links.test.ts @@ -1,49 +1,46 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; import { expect } from 'chai'; -import { servedSearch, servedSources, servedUrl } from '../../portal-template/src/lib/copilot-links'; -describe("the Ask AI chat's links", () => { - it("puts a portal page's path under the base path the host serves it at", () => { - expect(servedUrl('/guides/intro', '/docs/')).to.equal('/docs/guides/intro'); - expect(servedUrl('/', '/docs/')).to.equal('/docs/'); - expect(servedUrl('/guides/intro')).to.equal('/guides/intro'); - }); +/** An installed module's code, as the portal's build bundles it. */ +const installed = (specifier: string) => fs.readFileSync(createRequire(import.meta.url).resolve(specifier), 'utf8'); - it('leaves a full address, or one on another host, as it is', () => { - expect(servedUrl('https://docs.example.com/guides/intro', '/docs/')).to.equal( - 'https://docs.example.com/guides/intro' +// `copilot.tsx` hands the chat the service's paths unchanged, right only while the chat links them through +// the router, which adds the base once: a failure here is an upgrade that changed that, which only a browser would show. +describe("the Ask AI chat's links under a base", () => { + it('links a source by its path within the portal, served under the base once', async () => { + // Their ES module builds: the CommonJS ones warn of a circular require when loaded. + const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } = await import( + '@tanstack/react-router' ); - expect(servedUrl('//cdn.example.com/page', '/docs/')).to.equal('//cdn.example.com/page'); - }); - - it('links each source chip under the base path, keeping its title and label', () => { + const { TanstackProvider } = await import('fumadocs-core/framework/tanstack'); + const { AIChatSources } = await import('@fumadocs/ai-chat'); const sources = [ - { url: '/guides/auth', title: 'Authentication', label: '1' }, + { url: '/guides/intro', title: 'Intro' }, { url: 'https://status.example.com', title: 'Status' } ]; + const root = createRootRoute({ + component: () => createElement(TanstackProvider, null, createElement(AIChatSources, { sources })) + }); + const router = createRouter({ + routeTree: root, + basepath: '/docs', + history: createMemoryHistory({ initialEntries: ['/docs/'] }) + }); + await router.load(); - expect(servedSources(sources, '/docs/')).to.deep.equal([ - { url: '/docs/guides/auth', title: 'Authentication', label: '1' }, - { url: 'https://status.example.com', title: 'Status' } - ]); - }); - - it("links a search's found pages under the base path, keeping the rest of the search", () => { - const part = { - type: 'tool-search', - state: 'output-available', - input: { query: 'auth' }, - output: [{ doc: { url: '/guides/auth', title: 'Authentication' } }, { doc: null }] - }; + const html = renderToString(createElement(RouterProvider, { router })); - expect(servedSearch(part, '/docs/')).to.deep.equal({ - ...part, - output: [{ doc: { url: '/docs/guides/auth', title: 'Authentication' } }, { doc: null }] - }); + expect(html).to.contain('href="/docs/guides/intro"'); + expect(html).to.contain('href="https://status.example.com"'); }); - it('leaves a search that has found nothing yet as it is', () => { - const running = { type: 'tool-search', state: 'input-streaming', output: undefined }; + it("links a search's found pages the same way as the sources", () => { + const chat = installed('@fumadocs/ai-chat'); - expect(servedSearch(running, '/docs/')).to.deep.equal(running); + expect(chat).to.match(/jsxs?\(Link, \{\s*href: source\.url,/); + expect(chat).to.match(/jsxs?\(Link, \{\s*href: doc\.url,/); }); }); From 62a5aacd2f9e6156684acc32c2787daee4900cf0 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 16:23:33 +0500 Subject: [PATCH 17/41] fix(portal): keep the Ask AI build ID when the same pages are generated again The build ID hashed the bundle's exact bytes, `generatedAt` among them, so it changed on every `generate` and the service re-fetched the pages on every deploy. It now hashes what the service reads: the format version, the site address, the base path and the pages, leaving out when and by which CLI the bundle was generated. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/types/portal/copilot/copilot-bundle.ts | 10 ++++- test/actions/portal/generate.test.ts | 8 ++-- .../copilot/generated-portal-context.test.ts | 45 ++++++++++++++----- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/types/portal/copilot/copilot-bundle.ts b/src/types/portal/copilot/copilot-bundle.ts index 6d5f8b626..7094f7401 100644 --- a/src/types/portal/copilot/copilot-bundle.ts +++ b/src/types/portal/copilot/copilot-bundle.ts @@ -61,8 +61,14 @@ export class CopilotBundle { return this.json; } - /** Names these exact bytes, so the Ask AI service fetches the bundle only when a deploy changed it. */ + /** + * Names what the service reads, so it fetches the bundle only when a deploy changed it. When it was + * generated, and by which CLI, are left out: regenerating the same pages keeps the same ID. + */ public buildManifest(): string { - return JSON.stringify({ formatVersion: 1, buildId: createHash('sha256').update(this.json).digest('hex') }); + const { formatVersion, siteUrl, basePath, pages } = this.document; + const content = JSON.stringify({ formatVersion, siteUrl, basePath, pages }); + const buildId = createHash('sha256').update(content).digest('hex'); + return JSON.stringify({ formatVersion: 1, buildId }); } } diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index 3be176c3b..93c102453 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -1,4 +1,3 @@ -import { createHash } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -263,10 +262,9 @@ describe('GenerateAction', () => { const bundle = readPortal('__copilot/bundle.json'); const { pages } = JSON.parse(bundle) as { pages: unknown[] }; expect(pages).to.deep.equal([{ path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }]); - expect(JSON.parse(readPortal('__copilot/build.json'))).to.deep.equal({ - formatVersion: 1, - buildId: createHash('sha256').update(bundle).digest('hex') - }); + const manifest = JSON.parse(readPortal('__copilot/build.json')) as { formatVersion: number; buildId: string }; + expect(manifest.formatVersion).to.equal(1); + expect(manifest.buildId).to.match(/^[0-9a-f]{64}$/); expect(prompts.copilotUpdatesAfterDeploy.calledOnce).to.be.true; }); diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index 1598c666b..b9ec1c86a 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { createHash } from 'node:crypto'; import { gunzipSync } from 'node:zlib'; import { expect } from 'chai'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; @@ -95,26 +94,48 @@ describe('GeneratedPortalContext', () => { write('index.md', '# Welcome'); }); - it('writes the bundle as plain JSON beside a build ID of its exact bytes', async () => { + it('writes the bundle as plain JSON beside its build ID', async () => { const built = (await bundle())!; expect((await new GeneratedPortalContext(new DirectoryPath(root)).publish(built)).isOk()).to.be.true; const json = read('__copilot/bundle.json'); expect(JSON.parse(json)).to.deep.equal(JSON.parse(gunzipSync(built.gzipped()).toString('utf8'))); - expect(JSON.parse(read('__copilot/build.json'))).to.deep.equal({ - formatVersion: 1, - buildId: createHash('sha256').update(json).digest('hex') - }); + expect(read('__copilot/build.json')).to.equal(built.buildManifest()); }); - it('names the same pages with the same build ID, and changed pages with another', async () => { - const buildId = async () => (JSON.parse((await bundle())!.buildManifest()) as { buildId: string }).buildId; - const first = await buildId(); + describe('build ID', () => { + const buildId = async (site: SiteAddress | null = null, cliVersion = '9.9.9', at = AT) => { + const built = await new GeneratedPortalContext(new DirectoryPath(root)).bundle(site, cliVersion, at); + return (JSON.parse(built!.buildManifest()) as { formatVersion: number; buildId: string }).buildId; + }; + + it('is a SHA-256 in hex', async () => { + expect(await buildId()).to.match(/^[0-9a-f]{64}$/); + }); + + it('stays the same when the same pages are generated again, at another time or by another CLI', async () => { + const first = await buildId(); + + expect(await buildId(null, '9.9.9', new Date('2027-01-01T00:00:00.000Z'))).to.equal(first); + expect(await buildId(null, '10.0.0')).to.equal(first); + }); + + it('changes with a page', async () => { + const first = await buildId(); + + write('index.md', '# Welcome back'); + + expect(await buildId()).to.not.equal(first); + }); + + it('changes with the address the pages are served at', async () => { + const at = (url: string) => SiteAddress.parse(url, 'url')._unsafeUnwrap(); - expect(await buildId()).to.equal(first); - write('index.md', '# Welcome back'); - expect(await buildId()).to.not.equal(first); + expect(await buildId(at('https://example.com/docs'))).to.not.equal( + await buildId(at('https://example.com/api')) + ); + }); }); it('reports a portal it cannot write to rather than throwing', async () => { From 881a8e11439cf0134018e47febc36236605d44d8 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 16:24:51 +0500 Subject: [PATCH 18/41] fix(portal): trust only localhost, 127.0.0.1 and [::1] as an Ask AI service on this machine `*.localhost` names got plain HTTP and the saved login, but some systems, Windows' Node among them, resolve them through DNS, so they may not be this machine. The parser and the schema now accept only the exact loopback hosts, and the schema test checks that they agree on the Ask AI service's address. Co-Authored-By: Claude Opus 5.5 (1M context) --- apimatic.schema.json | 2 +- src/types/portal/config/copilot-config.ts | 3 ++- test/types/apimatic-config/schema.test.ts | 10 +++++++++- test/types/portal/portal-config.test.ts | 20 +++++++++++--------- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apimatic.schema.json b/apimatic.schema.json index baaf9d2c1..69f92dd28 100644 --- a/apimatic.schema.json +++ b/apimatic.schema.json @@ -189,7 +189,7 @@ "apiUrl": { "type": "string", "description": "The Ask AI service's address, for example 'https://ask.example.com'. Plain 'http://' is only accepted for a service on this machine, such as 'http://localhost:8787'.", - "pattern": "^([hH][tT][tT][pP][sS]://\\S+|[hH][tT][tT][pP]://(localhost|127\\.0\\.0\\.1|\\[::1\\]|[^/:\\s]+\\.localhost)([:/]\\S*)?)$" + "pattern": "^([hH][tT][tT][pP][sS]://\\S+|[hH][tT][tT][pP]://(localhost|127\\.0\\.0\\.1|\\[::1\\])([:/]\\S*)?)$" }, "suggestions": { "type": "array", diff --git a/src/types/portal/config/copilot-config.ts b/src/types/portal/config/copilot-config.ts index 657cb911e..31c45df26 100644 --- a/src/types/portal/config/copilot-config.ts +++ b/src/types/portal/config/copilot-config.ts @@ -49,11 +49,12 @@ export class CopilotConfig { } } +// Only names that never leave the machine: a `*.localhost` name is resolved through DNS on some systems, Windows' Node among them. const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); const APIMATIC_DOMAIN = 'apimatic.io'; function isLoopback(url: URL): boolean { - return LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith('.localhost'); + return LOOPBACK_HOSTS.has(url.hostname); } /** Whether sync may send the saved APIMatic login to the service: only APIMatic's own, or one on this machine. */ diff --git a/test/types/apimatic-config/schema.test.ts b/test/types/apimatic-config/schema.test.ts index 4626ee62e..cdcb6bb2e 100644 --- a/test/types/apimatic-config/schema.test.ts +++ b/test/types/apimatic-config/schema.test.ts @@ -117,10 +117,18 @@ describe('apimatic.schema.json', () => { ['a link through a parent segment', { navigation: { links: [{ label: 'Start', url: '/guides/../start' }] } }], ['a link with two slashes in its query', { navigation: { links: [{ label: 'Next', url: '/start?next=//x' }] } }], ['a context plugin hosted elsewhere', { pluginUrl: 'https://plugins.acme.test/calc.zip?v=2' }], - ['a plugin address with several query parameters', { pluginUrl: 'https://s3.test/calc.zip?a=1&b=2' }] + ['a plugin address with several query parameters', { pluginUrl: 'https://s3.test/calc.zip?a=1&b=2' }], + ['an Ask AI service over https', { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.example.com' } } }], + ['an Ask AI service on this machine', { ai: { copilot: { key: 'pk_test', apiUrl: 'http://localhost:8787' } } }] ]; const invalid: [string, object][] = [ + ['an Ask AI service over http', { ai: { copilot: { key: 'pk_test', apiUrl: 'http://ask.example.com' } } }], + // Resolved through DNS on some systems, so it may not be this machine. + [ + 'an Ask AI service over http on a .localhost name', + { ai: { copilot: { key: 'pk_test', apiUrl: 'http://ask.localhost' } } } + ], ['an unknown namespace', { theme: {} }], ['a key from the flat block', { title: 'Calc' }], ['a namespace that is not an object', { site: 'Calc' }], diff --git a/test/types/portal/portal-config.test.ts b/test/types/portal/portal-config.test.ts index 2033fc7bb..1308968be 100644 --- a/test/types/portal/portal-config.test.ts +++ b/test/types/portal/portal-config.test.ts @@ -446,16 +446,13 @@ describe('PortalConfig', () => { // Plain HTTP would expose the keys in transit, except to a service on this machine. it('accepts plain HTTP only for a service on this machine', () => { const at = (apiUrl: string) => ({ ai: { copilot: { key: PUBLIC_KEY, apiUrl } } }); - for (const local of [ - 'http://localhost:8787', - 'http://127.0.0.1:8787', - 'http://[::1]:8787', - 'http://ask.localhost' - ]) { + for (const local of ['http://localhost:8787', 'http://127.0.0.1:8787', 'http://[::1]:8787']) { expect(config(at(local)).identity().copilot?.apiUrl, local).to.equal(new URL(local).origin); } - expect(errorsOf(at('http://ask.example.com'))).to.have.length(1); - expect(errorsOf(at('http://localhost.example.com'))).to.have.length(1); + // `*.localhost` is resolved through DNS on some systems, so it may not be this machine. + for (const remote of ['http://ask.example.com', 'http://localhost.example.com', 'http://ask.localhost']) { + expect(errorsOf(at(remote)), remote).to.have.length(1); + } }); }); @@ -469,7 +466,12 @@ describe('PortalConfig', () => { ]) { expect(receivesSavedLogin(trusted), trusted).to.be.true; } - for (const other of ['https://ask.example.com', 'https://apimatic.io.example.com', 'https://evilapimatic.io']) { + for (const other of [ + 'https://ask.example.com', + 'https://apimatic.io.example.com', + 'https://evilapimatic.io', + 'https://ask.localhost' + ]) { expect(receivesSavedLogin(other), other).to.be.false; } }); From fe50480e22ad3fe612f153b1e47ea3a7ddf841c1 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 16:26:38 +0500 Subject: [PATCH 19/41] fix(portal): report an Ask AI request that runs out of time as a timeout A slow upload or status check failed without a response, so it was reported as a service that could not be reached, with advice to check the address. The ingest service now has axios tell a timeout apart (`clarifyTimeoutError`) and reports it as one, naming the time it waited. The prompts word a timeout per step: sync again after an upload, check back later while indexing. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/actions/portal/copilot/sync.ts | 4 +- .../services/copilot-ingest-service.ts | 31 ++++++++---- src/prompts/portal/copilot/sync.ts | 25 ++++++++-- test/actions/portal/copilot/sync.test.ts | 6 +-- .../services/copilot-ingest-service.test.ts | 20 ++++++++ test/prompts/portal/copilot/sync.test.ts | 50 +++++++++++++++++++ 6 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 test/prompts/portal/copilot/sync.test.ts diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts index d426303be..c9975faaa 100644 --- a/src/actions/portal/copilot/sync.ts +++ b/src/actions/portal/copilot/sync.ts @@ -63,7 +63,7 @@ export class PortalCopilotSyncAction { bundle.pageCount() ); if (receipt.isErr()) { - this.prompts.syncFailed(receipt.error, copilot.apiUrl); + this.prompts.uploadFailed(receipt.error, copilot.apiUrl); return ActionResult.failed(); } if (receipt.value.status === 'unchanged') { @@ -79,7 +79,7 @@ export class PortalCopilotSyncAction { this.ingestService.awaitIndexing(copilot, receipt.value.versionId, shell, authKey) ); if (indexed.isErr()) { - this.prompts.syncFailed(indexed.error, copilot.apiUrl); + this.prompts.indexingFailed(indexed.error, copilot.apiUrl); return ActionResult.failed(); } if (indexed.value.status === 'superseded') { diff --git a/src/infrastructure/services/copilot-ingest-service.ts b/src/infrastructure/services/copilot-ingest-service.ts index 83f1881df..c4848c45c 100644 --- a/src/infrastructure/services/copilot-ingest-service.ts +++ b/src/infrastructure/services/copilot-ingest-service.ts @@ -52,7 +52,8 @@ export class CopilotIngestService { const receipt = response.data as unknown; return isReceipt(receipt) ? ok(receipt) : err(ServiceError.InvalidResponse); } catch (error) { - return err(transportFailure(error)); + const timedOut = `The upload did not finish within ${duration(this.timings.uploadTimeoutMs)}.`; + return err(transportFailure(error, timedOut)); } } @@ -80,10 +81,7 @@ export class CopilotIngestService { } // Read after the status, so a version that finished during the last wait still counts. if (Date.now() >= deadline) { - const minutes = Math.max(1, Math.round(this.timings.indexTimeoutMs / 60_000)); - return err( - ServiceError.timeout(`Indexing did not finish within ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`) - ); + return err(ServiceError.timeout(`Indexing did not finish within ${duration(this.timings.indexTimeoutMs)}.`)); } } } @@ -104,7 +102,8 @@ export class CopilotIngestService { const status = statusOf(response.data); return status === null ? err(ServiceError.InvalidResponse) : ok(status); } catch (error) { - return err(transportFailure(error)); + const timedOut = `The Ask AI service did not answer within ${duration(this.timings.statusTimeoutMs)}.`; + return err(transportFailure(error, timedOut)); } } @@ -113,6 +112,8 @@ export class CopilotIngestService { baseURL: copilot.apiUrl, timeout, validateStatus: () => true, + // A request that runs out of time then fails as ETIMEDOUT, apart from one that never got through. + transitional: { clarifyTimeoutError: true }, headers: { 'User-Agent': envInfo.getUserAgent(shell), Authorization: `X-Auth-Key ${token}`, @@ -142,10 +143,20 @@ function failureOf(response: AxiosResponse): ServiceError { } // Every request answers with its status, so an axios error here is one that never got an answer. -function transportFailure(error: unknown): ServiceError { - return axios.isAxiosError(error) && error.response === undefined - ? ServiceError.NetworkError - : handleServiceError(error); +function transportFailure(error: unknown, timedOut: string): ServiceError { + if (!axios.isAxiosError(error) || error.response !== undefined) { + return handleServiceError(error); + } + return error.code === 'ETIMEDOUT' ? ServiceError.timeout(timedOut) : ServiceError.NetworkError; +} + +function duration(ms: number): string { + if (ms < 60_000) { + const seconds = Math.max(1, Math.round(ms / 1000)); + return `${seconds} ${seconds === 1 ? 'second' : 'seconds'}`; + } + const minutes = Math.round(ms / 60_000); + return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; } function isReceipt(body: unknown): body is IngestReceipt { diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index 82ae15b54..93820b126 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -74,7 +74,25 @@ export class PortalCopilotSyncPrompts { ); } - public syncFailed(error: ServiceError, apiUrl: string) { + public uploadFailed(error: ServiceError, apiUrl: string) { + this.syncFailed( + error, + apiUrl, + `${error.errorMessage} The service may have received the pages all the same; sync again, and it answers ` + + `that nothing has changed if it did.` + ); + } + + public indexingFailed(error: ServiceError, apiUrl: string) { + this.syncFailed( + error, + apiUrl, + `${error.errorMessage} The service keeps indexing, and Ask AI answers from the previous pages until it ` + + `is done; check the portal's Ask AI again in a few minutes.` + ); + } + + private syncFailed(error: ServiceError, apiUrl: string, timedOut: string) { switch (error.code) { case ServiceErrorCode.NetworkError: log.error(`The Ask AI service at ${f.link(apiUrl)} could not be reached. Check the address and try again.`); @@ -85,10 +103,7 @@ export class PortalCopilotSyncPrompts { ); return; case ServiceErrorCode.Timeout: - log.error( - `${error.errorMessage} The service keeps indexing, and Ask AI answers from the previous pages until it ` + - `is done; check the portal's Ask AI again in a few minutes.` - ); + log.error(timedOut); return; default: log.error(error.errorMessage); diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index 5edd7eb73..4b3954a61 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -139,7 +139,7 @@ describe('PortalCopilotSyncAction', () => { const result = await execute(); expect(result.isFailed()).to.be.true; - expect(prompts.syncFailed.calledOnceWith(forbidden, COPILOT.apiUrl)).to.be.true; + expect(prompts.uploadFailed.calledOnceWith(forbidden, COPILOT.apiUrl)).to.be.true; }); it('fails when the service cannot be reached', async () => { @@ -148,7 +148,7 @@ describe('PortalCopilotSyncAction', () => { const result = await execute(); expect(result.isFailed()).to.be.true; - expect(prompts.syncFailed.firstCall.args[0].code).to.equal(ServiceErrorCode.NetworkError); + expect(prompts.uploadFailed.firstCall.args[0].code).to.equal(ServiceErrorCode.NetworkError); }); it('fails when indexing fails', async () => { @@ -158,7 +158,7 @@ describe('PortalCopilotSyncAction', () => { const result = await execute(); expect(result.isFailed()).to.be.true; - expect(prompts.syncFailed.calledOnceWith(failed, COPILOT.apiUrl)).to.be.true; + expect(prompts.indexingFailed.calledOnceWith(failed, COPILOT.apiUrl)).to.be.true; }); it('keeps the saved login from a service APIMatic does not run, unless a key is passed', async () => { diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts index d7b05a83b..d62d91b71 100644 --- a/test/infrastructure/services/copilot-ingest-service.test.ts +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -95,6 +95,16 @@ describe('CopilotIngestService', () => { expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.NetworkError); }); + + it('reports an upload that runs out of time as a timeout, not as a service it cannot reach', async () => { + nock(API_URL).post('/v1/ingest').delay(500).reply(200, { versionId: VERSION_ID, status: 'queued', pageCount: 1 }); + const impatient = new CopilotIngestService({ uploadTimeoutMs: 50 }); + + const failure = (await impatient.upload(COPILOT, bundle, 'bash', 'auth-key'))._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.Timeout); + expect(failure.errorMessage).to.equal('The upload did not finish within 1 second.'); + }); }); describe('awaitIndexing', () => { @@ -141,5 +151,15 @@ describe('CopilotIngestService', () => { expect((await awaitIndexing())._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.InvalidResponse); }); + + it('reports a status check that runs out of time as a timeout, not as a service it cannot reach', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).delay(500).reply(200, statusBody('building')); + const impatient = new CopilotIngestService({ pollIntervalMs: 1, statusTimeoutMs: 50 }); + + const failure = (await impatient.awaitIndexing(COPILOT, VERSION_ID, 'bash', 'auth-key'))._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.Timeout); + expect(failure.errorMessage).to.equal('The Ask AI service did not answer within 1 second.'); + }); }); }); diff --git a/test/prompts/portal/copilot/sync.test.ts b/test/prompts/portal/copilot/sync.test.ts new file mode 100644 index 000000000..737931413 --- /dev/null +++ b/test/prompts/portal/copilot/sync.test.ts @@ -0,0 +1,50 @@ +import { stripVTControlCharacters } from 'node:util'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { log } from '@clack/prompts'; +import { ServiceError } from '../../../../src/infrastructure/service-error.js'; +import { PortalCopilotSyncPrompts } from '../../../../src/prompts/portal/copilot/sync.js'; + +describe('PortalCopilotSyncPrompts', () => { + const API_URL = 'https://ask.example.com'; + const prompts = new PortalCopilotSyncPrompts(); + let error: sinon.SinonStub; + + // Without its colours, which picocolors adds on Windows and in CI whatever the terminal. + const printed = () => error.getCalls().map((call) => stripVTControlCharacters(String(call.args[0]))); + + beforeEach(() => { + error = sinon.stub(log, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('tells a timed-out upload to sync again, rather than to check the address', () => { + prompts.uploadFailed(ServiceError.timeout('The upload did not finish within 2 minutes.'), API_URL); + + const [message] = printed(); + expect(message).to.contain('The upload did not finish within 2 minutes.'); + expect(message).to.contain('sync again'); + expect(message).to.not.contain('could not be reached'); + expect(message).to.not.contain('keeps indexing'); + }); + + it('tells timed-out indexing that the service keeps indexing', () => { + prompts.indexingFailed(ServiceError.timeout('Indexing did not finish within 10 minutes.'), API_URL); + + const [message] = printed(); + expect(message).to.contain('Indexing did not finish within 10 minutes.'); + expect(message).to.contain('keeps indexing'); + }); + + it('tells either step that a service it could not reach may be at the wrong address', () => { + prompts.uploadFailed(ServiceError.NetworkError, API_URL); + prompts.indexingFailed(ServiceError.NetworkError, API_URL); + + for (const message of printed()) { + expect(message).to.contain(`The Ask AI service at ${API_URL} could not be reached`); + } + }); +}); From 5a28f0eaa17497320f39cafe3e1b46529a4c9f0b Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 16:37:06 +0500 Subject: [PATCH 20/41] refactor(portal): poll Ask AI indexing with the shared poller, and give failed indexing its own error kind The indexing poll copied `pollUntilCompleted`'s deadline loop. The loop is now `pollUntilSettled`, which takes a `settle` function saying how a status ends the run; `pollUntilCompleted` settles the generation statuses with it, and the Ask AI service its indexing states. Failed indexing came back as `badRequest`, its message written in the service. It is now `ProcessingFailed`, carrying only the service's reason, and the sync prompt words it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../generation-status-poller.ts | 93 +++++++++++++------ src/infrastructure/service-error.ts | 7 +- .../services/copilot-ingest-service.ts | 32 +++---- src/prompts/portal/copilot/sync.ts | 8 ++ test/actions/portal/copilot/sync.test.ts | 2 +- .../generation-status-poller.test.ts | 23 ++++- .../services/copilot-ingest-service.test.ts | 19 +++- test/prompts/portal/copilot/sync.test.ts | 11 +++ 8 files changed, 140 insertions(+), 55 deletions(-) diff --git a/src/infrastructure/generation-status-poller.ts b/src/infrastructure/generation-status-poller.ts index 29d7aad0f..682e26b9f 100644 --- a/src/infrastructure/generation-status-poller.ts +++ b/src/infrastructure/generation-status-poller.ts @@ -21,25 +21,39 @@ export interface GenerationStatus { export type ValidationErrorFormatter = (errors: Record) => string; +/** + * Bounds a run whose status never settles. `label` opens the message. + * The budget is only read between polls, so `fetchStatus` must bound its own request โ€” + * one that connects and never answers outlives any budget set here. + */ +export interface PollTimeout { + budgetMs: number; + label: string; +} + export interface GenerationPoll { pollIntervalMs: number; fetchStatus: () => Promise>; - /** - * Bounds a run whose status never reaches a terminal value. `label` opens the message. - * The budget is only read between polls, so `fetchStatus` must bound its own request โ€” - * one that connects and never answers outlives any budget set here. - */ - timeout: { budgetMs: number; label: string }; + timeout: PollTimeout; /** Only when the endpoint needs custom validation wording. */ formatValidationError?: ValidationErrorFormatter; } -export async function pollUntilCompleted({ +export interface StatusPoll { + pollIntervalMs: number; + fetchStatus: () => Promise>; + timeout: PollTimeout; + /** What a status ends the run with, or null while the run is still going. */ + settle: (status: S) => Result | null; +} + +/** Polls until `settle` ends the run, or the timeout does. */ +export async function pollUntilSettled({ pollIntervalMs, fetchStatus, timeout, - formatValidationError = formatValidationErrors -}: GenerationPoll): Promise> { + settle +}: StatusPoll): Promise> { const deadline = Date.now() + timeout.budgetMs; for (;;) { @@ -50,34 +64,55 @@ export async function pollUntilCompleted({ return err(statusResult.error); } - const { status, errors } = statusResult.value; - - if (status === Status.Completed) { - return ok(statusResult.value); - } - if (status === Status.Failed) { - return err(ServiceError.ServerError); - } - if (status === Status.ValidationError) { - const validationErrors = asMessages(errors); - return err(ServiceError.badRequest(formatValidationError(validationErrors), validationErrors)); - } - if (status === Status.SubscriptionError) { - const message = Object.values(asMessages(errors)).flat()[0]; - return err(ServiceError.forbidden('Access denied to resource.' + (message ? '\n' + bulleted([message]) : ''))); + const settled = settle(statusResult.value); + if (settled !== null) { + return settled; } - // Every other status keeps the run alive rather than ending it: an endpoint reporting - // its own in-flight vocabulary is healthy, and the plugin orchestrator reports - // `Unknown` until it writes a custom status. The deadline is what stops a genuinely - // stuck run, and it is read after the status so a run that finished during the last - // wait still counts. + // The deadline is what stops a genuinely stuck run, and it is read after the status + // so a run that finished during the last wait still counts. if (Date.now() >= deadline) { return err(ServiceError.timeout(timedOutMessage(timeout.label, timeout.budgetMs))); } } } +export async function pollUntilCompleted({ + pollIntervalMs, + fetchStatus, + timeout, + formatValidationError = formatValidationErrors +}: GenerationPoll): Promise> { + return pollUntilSettled({ + pollIntervalMs, + fetchStatus, + timeout, + settle: (current): Result | null => { + const { status, errors } = current; + + if (status === Status.Completed) { + return ok(current); + } + if (status === Status.Failed) { + return err(ServiceError.ServerError); + } + if (status === Status.ValidationError) { + const validationErrors = asMessages(errors); + return err(ServiceError.badRequest(formatValidationError(validationErrors), validationErrors)); + } + if (status === Status.SubscriptionError) { + const message = Object.values(asMessages(errors)).flat()[0]; + return err(ServiceError.forbidden('Access denied to resource.' + (message ? '\n' + bulleted([message]) : ''))); + } + + // Every other status keeps the run alive rather than ending it: an endpoint reporting + // its own in-flight vocabulary is healthy, and the plugin orchestrator reports + // `Unknown` until it writes a custom status. + return null; + } + }); +} + /** Each message under a bullet of its own, any further line of it indented beneath. */ export const bulleted = (messages: string[]): string => messages.map((message) => `- ${message.replaceAll('\n', '\n ')}`).join('\n'); diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 313922aff..fe224d0c4 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -11,7 +11,8 @@ export enum ServiceErrorCode { UnAuthorized = 'UNAUTHORIZED', BadRequest = 'BAD_REQUEST', Forbidden = 'FORBIDDEN', - Timeout = 'TIMEOUT' + Timeout = 'TIMEOUT', + ProcessingFailed = 'PROCESSING_FAILED' } export class ServiceError { @@ -40,6 +41,10 @@ export class ServiceError { static timeout(customMessage: string): ServiceError { return new ServiceError(ServiceErrorCode.Timeout, customMessage, {}); } + /** The service took the work on but could not finish it; the message is its reason, or empty without one. */ + static processingFailed(reason: string | null): ServiceError { + return new ServiceError(ServiceErrorCode.ProcessingFailed, reason ?? '', {}); + } static unauthorizedWithHint(apiMessage: string | null): ServiceError { // Both remedies name the full `auth login` command: the key is supplied to // that command, not to whichever one hit the 401 โ€” most of them don't accept diff --git a/src/infrastructure/services/copilot-ingest-service.ts b/src/infrastructure/services/copilot-ingest-service.ts index c4848c45c..932220f82 100644 --- a/src/infrastructure/services/copilot-ingest-service.ts +++ b/src/infrastructure/services/copilot-ingest-service.ts @@ -5,8 +5,8 @@ import { CopilotBundle } from '../../types/portal/copilot/copilot-bundle.js'; import { IndexedVersion, IndexingState, IngestReceipt, IngestStatus } from '../../types/portal/copilot/ingest.js'; import { isJsonObject } from '../../utils/json-utils.js'; import { envInfo } from '../env-info.js'; +import { pollUntilSettled } from '../generation-status-poller.js'; import { handleServiceError, ServiceError } from '../service-error.js'; -import { sleep } from '../timer-extensions.js'; export const COPILOT_INGEST_TIMINGS = { pollIntervalMs: 2000, @@ -64,26 +64,18 @@ export class CopilotIngestService { shell: string, authKey: string ): Promise> { - const deadline = Date.now() + this.timings.indexTimeoutMs; - for (;;) { - await sleep(this.timings.pollIntervalMs); - - const current = await this.status(copilot, versionId, shell, authKey); - if (current.isErr()) { - return err(current.error); - } - const { status, error } = current.value; - if (status === 'active' || status === 'superseded') { - return ok({ ...current.value, status }); - } - if (status === 'failed') { - return err(ServiceError.badRequest(error ?? 'The Ask AI service could not index the pages.', {})); - } - // Read after the status, so a version that finished during the last wait still counts. - if (Date.now() >= deadline) { - return err(ServiceError.timeout(`Indexing did not finish within ${duration(this.timings.indexTimeoutMs)}.`)); + return pollUntilSettled({ + pollIntervalMs: this.timings.pollIntervalMs, + fetchStatus: () => this.status(copilot, versionId, shell, authKey), + timeout: { budgetMs: this.timings.indexTimeoutMs, label: 'Indexing' }, + settle: (current): Result | null => { + const { status, error } = current; + if (status === 'failed') { + return err(ServiceError.processingFailed(error)); + } + return status === 'queued' || status === 'building' ? null : ok({ ...current, status }); } - } + }); } private async status( diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index 93820b126..3a14d36a1 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -105,6 +105,14 @@ export class PortalCopilotSyncPrompts { case ServiceErrorCode.Timeout: log.error(timedOut); return; + case ServiceErrorCode.ProcessingFailed: { + const reason = error.errorMessage === '' ? '.' : `: ${error.errorMessage}`; + log.error( + `The Ask AI service could not index the pages${reason} ` + + `Ask AI answers from the previous pages until a sync is indexed.` + ); + return; + } default: log.error(error.errorMessage); } diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index 4b3954a61..2635c1894 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -152,7 +152,7 @@ describe('PortalCopilotSyncAction', () => { }); it('fails when indexing fails', async () => { - const failed = ServiceError.badRequest('Embedding provider unavailable.', {}); + const failed = ServiceError.processingFailed('Embedding provider unavailable.'); awaitIndexing.resolves(err(failed)); const result = await execute(); diff --git a/test/infrastructure/generation-status-poller.test.ts b/test/infrastructure/generation-status-poller.test.ts index b2e881dd6..5ddf2035d 100644 --- a/test/infrastructure/generation-status-poller.test.ts +++ b/test/infrastructure/generation-status-poller.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { ok, Result } from 'neverthrow'; -import { pollUntilCompleted } from '../../src/infrastructure/generation-status-poller'; +import { pollUntilCompleted, pollUntilSettled } from '../../src/infrastructure/generation-status-poller'; import { ServiceError, ServiceErrorCode } from '../../src/infrastructure/service-error'; // The service suites reach the timeout with millisecond budgets, which only ever renders the @@ -36,7 +36,9 @@ describe('pollUntilCompleted', () => { }; it('names the budget in minutes', async () => { - expect(await timeoutAfter(5 * 60_000, 'Plugin generation')).to.equal('Plugin generation timed out after 5 minutes.'); + expect(await timeoutAfter(5 * 60_000, 'Plugin generation')).to.equal( + 'Plugin generation timed out after 5 minutes.' + ); }); it('says minute rather than minutes for a one-minute budget', async () => { @@ -51,3 +53,20 @@ describe('pollUntilCompleted', () => { expect(await timeoutAfter(90_000, 'Portal generation')).to.equal('Portal generation timed out after 1 minute.'); }); }); + +describe('pollUntilSettled', () => { + it('polls while the status is unsettled, and ends with what settles it', async () => { + const statuses = ['queued', 'building', 'active']; + let polls = 0; + + const result = await pollUntilSettled({ + pollIntervalMs: 1, + fetchStatus: async (): Promise> => ok(statuses[polls++]), + timeout: { budgetMs: 5000, label: 'Indexing' }, + settle: (status) => (status === 'active' ? ok(`settled on ${status}`) : null) + }); + + expect(result._unsafeUnwrap()).to.equal('settled on active'); + expect(polls).to.equal(3); + }); +}); diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts index d62d91b71..887b4b96c 100644 --- a/test/infrastructure/services/copilot-ingest-service.test.ts +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -129,12 +129,26 @@ describe('CopilotIngestService', () => { expect((await awaitIndexing())._unsafeUnwrap().status).to.equal('superseded'); }); - it("fails with the service's reason when indexing failed", async () => { + it("fails as failed processing, carrying the service's reason, when indexing failed", async () => { nock(API_URL) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('building')) .get(`/v1/ingest/${VERSION_ID}`) .reply(200, statusBody('failed', { error: 'Embedding provider unavailable.' })); - expect((await awaitIndexing())._unsafeUnwrapErr().errorMessage).to.equal('Embedding provider unavailable.'); + const failure = (await awaitIndexing())._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.ProcessingFailed); + expect(failure.errorMessage).to.equal('Embedding provider unavailable.'); + }); + + it('fails as failed processing with no reason when the service gives none', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).reply(200, statusBody('failed')); + + const failure = (await awaitIndexing())._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.ProcessingFailed); + expect(failure.errorMessage).to.equal(''); }); it('times out on a version that never finishes', async () => { @@ -144,6 +158,7 @@ describe('CopilotIngestService', () => { const result = await impatient.awaitIndexing(COPILOT, VERSION_ID, 'bash', 'auth-key'); expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.Timeout); + expect(result._unsafeUnwrapErr().errorMessage).to.equal('Indexing timed out.'); }); it('refuses a status body it cannot read', async () => { diff --git a/test/prompts/portal/copilot/sync.test.ts b/test/prompts/portal/copilot/sync.test.ts index 737931413..2b384c83f 100644 --- a/test/prompts/portal/copilot/sync.test.ts +++ b/test/prompts/portal/copilot/sync.test.ts @@ -39,6 +39,17 @@ describe('PortalCopilotSyncPrompts', () => { expect(message).to.contain('keeps indexing'); }); + it("words failed indexing itself, with the service's reason when it gives one", () => { + prompts.indexingFailed(ServiceError.processingFailed('Embedding provider unavailable.'), API_URL); + prompts.indexingFailed(ServiceError.processingFailed(null), API_URL); + + expect(printed()).to.deep.equal([ + 'The Ask AI service could not index the pages: Embedding provider unavailable. ' + + 'Ask AI answers from the previous pages until a sync is indexed.', + 'The Ask AI service could not index the pages. Ask AI answers from the previous pages until a sync is indexed.' + ]); + }); + it('tells either step that a service it could not reach may be at the wrong address', () => { prompts.uploadFailed(ServiceError.NetworkError, API_URL); prompts.indexingFailed(ServiceError.NetworkError, API_URL); From 53d0a790d334a627d38b7628a5d0b2e77267211d Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 16:50:42 +0500 Subject: [PATCH 21/41] fix(portal): save the portal even when the Ask AI pages cannot be read `GeneratedPortalContext.bundle()` threw on a file it could not read, which stopped `generate` before the portal was saved, and crashed `sync`. It now returns the error: `generate` warns and saves the portal without the pages, pointing to sync, and `sync` fails without uploading. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/actions/portal/copilot/sync.ts | 7 ++++- src/actions/portal/generate.ts | 9 ++++-- src/prompts/portal/copilot/sync.ts | 4 +++ .../copilot/generated-portal-context.ts | 27 ++++++++++++------ test/actions/portal/copilot/sync.test.ts | 11 ++++++++ test/actions/portal/generate.test.ts | 14 ++++++++++ test/e2e/portal-build.test.ts | 2 +- .../copilot/generated-portal-context.test.ts | 28 +++++++++++++++++-- 8 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts index c9975faaa..9f52f8fb0 100644 --- a/src/actions/portal/copilot/sync.ts +++ b/src/actions/portal/copilot/sync.ts @@ -43,11 +43,16 @@ export class PortalCopilotSyncAction { return ActionResult.failed(); } - const bundle = await new GeneratedPortalContext(portalDirectory).bundle( + const bundled = await new GeneratedPortalContext(portalDirectory).bundle( config.siteAddress(), envInfo.getCLIVersion(), new Date() ); + if (bundled.isErr()) { + this.prompts.portalNotRead(portalDirectory, bundled.error); + return ActionResult.failed(); + } + const bundle = bundled.value; if (bundle === null) { this.prompts.portalNotGenerated(portalDirectory); return ActionResult.failed(); diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 6afbc6b07..0e4f10cfe 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -96,10 +96,15 @@ export class GenerateAction { return ActionResult.success(); } - /** The portal still saves without the bundle; Ask AI is then updated by `portal copilot sync`. */ + /** The portal still saves without the bundle, whatever stops it; Ask AI is then updated by `portal copilot sync`. */ private async publishCopilotBundle(builtDirectory: DirectoryPath, site: SiteAddress | null): Promise { const portal = new GeneratedPortalContext(builtDirectory); - const bundle = await portal.bundle(site, envInfo.getCLIVersion(), new Date()); + const bundled = await portal.bundle(site, envInfo.getCLIVersion(), new Date()); + if (bundled.isErr()) { + this.prompts.copilotBundleNotWritten(bundled.error); + return false; + } + const bundle = bundled.value; if (bundle === null || bundle.pageCount() === 0) { this.prompts.copilotPagesNotFound(); return false; diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index 3a14d36a1..0e6bef546 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -48,6 +48,10 @@ export class PortalCopilotSyncPrompts { log.error(message); } + public portalNotRead(portalDirectory: DirectoryPath, reason: string) { + log.error(`The portal at ${f.path(portalDirectory)} could not be read (${reason}).`); + } + public noPages(portalDirectory: DirectoryPath) { const message = `The portal at ${f.path(portalDirectory)} has no pages Ask AI can read. ` + diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts index b1777fc5e..4b2b155be 100644 --- a/src/types/portal/copilot/generated-portal-context.ts +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -21,15 +21,26 @@ export class GeneratedPortalContext { return new FilePath(this.portalDirectory, new FileName('llms.txt')); } - /** Null when the directory holds no generated portal. A listed page without its Markdown twin is left out. */ - public async bundle(site: SiteAddress | null, cliVersion: string, at: Date): Promise { - if (!(await this.fileService.fileExists(this.index))) { - return null; + /** + * Null when the directory holds no generated portal. A listed page without its Markdown twin is left out; + * a file that cannot be read is an error, so the caller decides whether that stops it. + */ + public async bundle( + site: SiteAddress | null, + cliVersion: string, + at: Date + ): Promise> { + try { + if (!(await this.fileService.fileExists(this.index))) { + return ok(null); + } + const listed = listedPages(await this.fileService.getContents(this.index), site); + const read = await Promise.all(listed.map((page) => this.read(page))); + const pages = read.filter((page) => page !== null); + return ok(CopilotBundle.of(pages, site, cliVersion, at)); + } catch (error) { + return err(errorMessage(error)); } - const listed = listedPages(await this.fileService.getContents(this.index), site); - const read = await Promise.all(listed.map((page) => this.read(page))); - const pages = read.filter((page) => page !== null); - return CopilotBundle.of(pages, site, cliVersion, at); } /** Writes the bundle into the portal, so it is deployed with the pages it holds. */ diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index 2635c1894..04091050a 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -13,6 +13,7 @@ import { CommandMetadata } from '../../../../src/types/common/command-metadata'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; import { CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; import { ProjectContext } from '../../../../src/types/project-context'; +import { GeneratedPortalContext } from '../../../../src/types/portal/copilot/generated-portal-context'; /** The bundle as the service receives it. */ const uploaded = (bundle: CopilotBundle) => @@ -227,6 +228,16 @@ describe('PortalCopilotSyncAction', () => { expect(upload.called).to.be.false; }); + it('fails without uploading when the portal cannot be read', async () => { + sinon.stub(GeneratedPortalContext.prototype, 'bundle').resolves(err('EACCES: permission denied')); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.portalNotRead.calledOnceWith(sinon.match.any, 'EACCES: permission denied')).to.be.true; + expect(upload.called).to.be.false; + }); + it('fails without uploading when the portal has not been generated', async () => { fs.rmSync(path.join(root, 'portal'), { recursive: true }); diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index 93c102453..ae67973bc 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -15,6 +15,7 @@ import { ServiceError } from '../../../src/infrastructure/service-error'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { CommandMetadata } from '../../../src/types/common/command-metadata'; import { ProjectContext } from '../../../src/types/project-context'; +import { GeneratedPortalContext } from '../../../src/types/portal/copilot/generated-portal-context'; import { completeArtifacts, stubPreparePortalProject } from './prepare-project-stubs'; const COMMAND_METADATA: CommandMetadata = { commandName: 'portal generate', shell: 'test' }; @@ -278,6 +279,19 @@ describe('GenerateAction', () => { expect(fs.existsSync(path.join(builtSite.toString(), '__copilot', 'build.json'))).to.be.true; }); + it('still saves the portal when its pages cannot be read, pointing to sync instead', async () => { + listPages(); + sinon.stub(GeneratedPortalContext.prototype, 'bundle').resolves(err('EACCES: permission denied')); + + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotBundleNotWritten.calledOnceWith('EACCES: permission denied')).to.be.true; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + }); + it('still saves the portal when it lists no pages, pointing to sync instead', async () => { const result = await execute(COPILOT_FIXTURE); diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 70e54c915..2215149f8 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -1029,7 +1029,7 @@ const stylesheetOf = (output: DirectoryPath) => { it('lists the pages Ask AI answers from, each with its Markdown copy', async () => { const bundle = await new GeneratedPortalContext(portal.output()).bundle(null, '0.0.0', new Date()); - expect(bundle?.pageCount()).to.be.greaterThan(1); + expect(bundle._unsafeUnwrap()?.pageCount()).to.be.greaterThan(1); }); it('tells the browser the public key and the service, and no more', () => { diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index b9ec1c86a..cc5da646f 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -3,6 +3,8 @@ import os from 'node:os'; import path from 'node:path'; import { gunzipSync } from 'node:zlib'; import { expect } from 'chai'; +import sinon from 'sinon'; +import { FileService } from '../../../../src/infrastructure/file-service'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; import { SiteAddress } from '../../../../src/types/portal/config/site-address'; import { GeneratedPortalContext } from '../../../../src/types/portal/copilot/generated-portal-context'; @@ -19,7 +21,9 @@ describe('GeneratedPortalContext', () => { }; const bundle = (site: SiteAddress | null = null) => - new GeneratedPortalContext(new DirectoryPath(root)).bundle(site, '9.9.9', AT); + new GeneratedPortalContext(new DirectoryPath(root)) + .bundle(site, '9.9.9', AT) + .then((built) => built._unsafeUnwrap()); beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-portal-')); @@ -29,6 +33,25 @@ describe('GeneratedPortalContext', () => { fs.rmSync(root, { recursive: true, force: true }); }); + it('reports a page it cannot read rather than throwing', async () => { + write('llms.txt', '- [Welcome](/)'); + write('index.md', '# Welcome'); + const getContents = FileService.prototype.getContents; + const unreadable = sinon.stub(FileService.prototype, 'getContents').callsFake(function (this: FileService, file) { + return file.toString().endsWith('index.md') + ? Promise.reject(new Error('EACCES: permission denied')) + : getContents.call(this, file); + }); + + try { + const built = await new GeneratedPortalContext(new DirectoryPath(root)).bundle(null, '9.9.9', AT); + + expect(built._unsafeUnwrapErr()).to.contain('EACCES: permission denied'); + } finally { + unreadable.restore(); + } + }); + it('is null for a directory with no generated portal in it', async () => { expect(await bundle()).to.be.null; }); @@ -107,7 +130,8 @@ describe('GeneratedPortalContext', () => { describe('build ID', () => { const buildId = async (site: SiteAddress | null = null, cliVersion = '9.9.9', at = AT) => { const built = await new GeneratedPortalContext(new DirectoryPath(root)).bundle(site, cliVersion, at); - return (JSON.parse(built!.buildManifest()) as { formatVersion: number; buildId: string }).buildId; + return (JSON.parse(built._unsafeUnwrap()!.buildManifest()) as { formatVersion: number; buildId: string }) + .buildId; }; it('is a SHA-256 in hex', async () => { From d007288f25cefbe011ba81a6a166571db90c0f36 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 17:05:01 +0500 Subject: [PATCH 22/41] fix(portal): open the Ask AI session when the panel opens The session opened only with the first question, so on a portal the service turns away, visitors saw "Ask AI", asked, and got an error before the button hid; Ctrl+/ still opened the panel after that. The session now opens as the panel opens, and once the service turns the portal away the panel stays shut. Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/components/copilot.tsx | 16 ++++++- portal-template/src/lib/copilot-client.ts | 8 ++++ test/portal-template/copilot-client.test.ts | 47 ++++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index 1517908f8..1110bb468 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -1,6 +1,6 @@ // The Ask AI copilot: Fumadocs' AI chat panel, answered by the Ask AI service (the portal is static). // `@fumadocs/ai-chat` is 0.x and needs fumadocs-ui >= 16.16: keep it pinned and check the chat on every upgrade. -import { useMemo, useState, type ComponentType, type ReactNode } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'; import type * as PageTree from 'fumadocs-core/page-tree'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport, type UIMessage } from 'ai'; @@ -50,12 +50,26 @@ function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilo const suggestions = copilot.suggestions.length > 0 ? copilot.suggestions : undefined; return ( + {children} {available && } ); } +/** Opens the session as the panel opens, and keeps the panel shut once the service turns the portal away. */ +function SessionOnOpen({ client, available }: Readonly<{ client: CopilotClient; available: boolean }>) { + const { open, setOpen } = useAIChat(); + useEffect(() => { + if (open) void client.prepare(); + }, [open, client]); + // Before paint, so Ctrl+/ on a portal turned away never shows the panel at all. + useLayoutEffect(() => { + if (open && !available) setOpen(false); + }, [open, available, setOpen]); + return null; +} + /** The docs layout's `aiChat` option: the panel and whether it is open. Call inside `CopilotProvider`. */ function useCopilotPanel(): AIChatOptions { const { open, setOpen } = useAIChat(); diff --git a/portal-template/src/lib/copilot-client.ts b/portal-template/src/lib/copilot-client.ts index 6fff630e4..4bf405186 100644 --- a/portal-template/src/lib/copilot-client.ts +++ b/portal-template/src/lib/copilot-client.ts @@ -28,6 +28,14 @@ export class CopilotClient { readonly chatAddress = (): string => `${this.copilot.apiUrl}/v1/chat`; + /** + * Opens the session ahead of the first question, as the panel opens, so a portal the service + * turns away hides Ask AI before a visitor types. Other failures wait for the question to show them. + */ + readonly prepare = async (): Promise => { + await this.currentSession().catch(() => undefined); + }; + /** The body for one question: only the latest message, since the service keeps the thread. */ questionBody(messages: readonly unknown[], trigger: string): Record { if (messages.length <= 1) this.thread = null; diff --git a/test/portal-template/copilot-client.test.ts b/test/portal-template/copilot-client.test.ts index 759215c0b..5e8dfa6d6 100644 --- a/test/portal-template/copilot-client.test.ts +++ b/test/portal-template/copilot-client.test.ts @@ -21,7 +21,8 @@ function service(...replies: (Response | (() => Response))[]) { } const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); -const session = (token: string, inMs = 900_000) => json({ token, expiresAt: new Date(Date.now() + inMs).toISOString() }); +const session = (token: string, inMs = 900_000) => + json({ token, expiresAt: new Date(Date.now() + inMs).toISOString() }); describe('the Ask AI client', () => { it('opens a session with the public key, then asks with the session token only', async () => { @@ -64,7 +65,10 @@ describe('the Ask AI client', () => { }); it("shows the service's own message when it refuses a question", async () => { - const { send } = service(session('t1'), json({ code: 'quota_exceeded', message: 'Limit reached. Search still works.' }, 429)); + const { send } = service( + session('t1'), + json({ code: 'quota_exceeded', message: 'Limit reached. Search still works.' }, 429) + ); const client = new CopilotClient(COPILOT, () => undefined, send); const response = await client.fetch(client.chatAddress(), {}); expect(response.status).to.equal(429); @@ -83,6 +87,45 @@ describe('the Ask AI client', () => { } }); + it('opens the session ahead of the first question, which then uses it', async () => { + const { calls, send } = service(session('t1'), new Response('ok')); + const client = new CopilotClient(COPILOT, () => undefined, send); + + await client.prepare(); + await client.fetch(client.chatAddress(), {}); + + expect(calls.map((call) => call.url.split('/v1/')[1])).to.deep.equal(['sessions', 'chat']); + expect(new Headers(calls[1]!.init.headers).get('authorization')).to.equal('Bearer t1'); + }); + + it('hides Ask AI as the session opens, before any question, when the service turns the portal away', async () => { + let hidden = false; + const { send } = service(json({ code: 'invalid_key', message: 'no' }, 403)); + const client = new CopilotClient(COPILOT, () => (hidden = true), send); + + await client.prepare(); + + expect(hidden).to.be.true; + }); + + it('leaves any other failure to open the session to the first question, which tries again', async () => { + const { calls, send } = service( + () => { + throw new TypeError('Failed to fetch'); + }, + session('t1'), + new Response('ok') + ); + let hidden = false; + const client = new CopilotClient(COPILOT, () => (hidden = true), send); + + await client.prepare(); + + expect(hidden).to.be.false; + expect(await (await client.fetch(client.chatAddress(), {})).text()).to.equal('ok'); + expect(calls).to.have.length(3); + }); + it('says the service is unavailable when it cannot be reached, and tries again next time', async () => { const { calls, send } = service( () => { From a2ce8488e573a1241aba7d42e1281ea64f56f701 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Mon, 19 Oct 2026 17:48:00 +0500 Subject: [PATCH 23/41] refactor(portal): have the template write the Ask AI pages, so the CLI no longer parses llms.txt The CLI read the page list from `llms.txt` and rebuilt each page's Markdown file from its link, which dropped pages with non-ASCII names, let a user's own `static/llms.txt` decide what Ask AI saw, missed pages the navigation leaves out, and had `sync` bundle under today's base path. A portal with Ask AI now builds `/api/copilot-pages.json` from the template's own page source, rendered as `llms-full.txt` renders it, without each operation's slice of the specification. `generate` bundles those pages and removes the file, so it is never deployed; `sync` uploads the bundle `generate` wrote, as it is. `llms-index.ts` goes away. Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/portal-config.ts | 2 +- portal-template/prerender-pages.ts | 9 +- portal-template/src/lib/llms.server.ts | 21 ++ .../src/routes/api/copilot-pages[.]json.ts | 16 ++ portal-template/vite.config.ts | 2 +- src/actions/portal/copilot/sync.ts | 12 +- src/actions/portal/generate.ts | 15 +- src/prompts/portal/copilot/sync.ts | 11 +- src/prompts/portal/generate.ts | 9 +- src/types/portal/copilot/copilot-bundle.ts | 47 ++++- .../copilot/generated-portal-context.ts | 89 ++++---- src/types/portal/copilot/llms-index.ts | 50 ----- test/actions/portal/copilot/sync.test.ts | 45 +++-- test/actions/portal/generate.test.ts | 44 ++-- test/e2e/portal-build.test.ts | 36 +++- test/portal-template/prerender-pages.test.ts | 23 ++- .../src/content/guides/caf\303\251.md" | 6 + .../copilot/src/content/guides/nav.json | 4 + .../copilot/src/content/guides/unlisted.md | 5 + .../copilot/src/content/index.md | 6 + .../portal/copilot/copilot-bundle.test.ts | 95 +++++++++ .../copilot/generated-portal-context.test.ts | 190 +++++++----------- test/types/portal/copilot/llms-index.test.ts | 53 ----- 23 files changed, 447 insertions(+), 343 deletions(-) create mode 100644 portal-template/src/routes/api/copilot-pages[.]json.ts delete mode 100644 src/types/portal/copilot/llms-index.ts create mode 100644 "test/resources/portal-inputs/copilot/src/content/guides/caf\303\251.md" create mode 100644 test/resources/portal-inputs/copilot/src/content/guides/nav.json create mode 100644 test/resources/portal-inputs/copilot/src/content/guides/unlisted.md create mode 100644 test/resources/portal-inputs/copilot/src/content/index.md create mode 100644 test/types/portal/copilot/copilot-bundle.test.ts delete mode 100644 test/types/portal/copilot/llms-index.test.ts diff --git a/portal-template/portal-config.ts b/portal-template/portal-config.ts index eaedaf55c..4adc4657a 100644 --- a/portal-template/portal-config.ts +++ b/portal-template/portal-config.ts @@ -25,7 +25,7 @@ export async function readBuildPaths(): Promise { } /** The part of what the browser is told that the build set-up reads too. */ -export type BuildIdentity = Pick; +export type BuildIdentity = Pick; export async function readPortalIdentity(): Promise { return (await readJson('./portal.identity.json')) as BuildIdentity; diff --git a/portal-template/prerender-pages.ts b/portal-template/prerender-pages.ts index 0560ef52f..1c2dbe41f 100644 --- a/portal-template/prerender-pages.ts +++ b/portal-template/prerender-pages.ts @@ -1,7 +1,7 @@ import { readdir } from 'node:fs/promises'; import path from 'node:path'; import { getSlugs, loader } from 'fumadocs-core/source'; -import type { BuildPaths } from './portal-config.ts'; +import type { BuildIdentity, BuildPaths } from './portal-config.ts'; import { withBasePath } from './src/lib/base-path'; import { oauthCallbackPath } from './src/lib/oauth-callback'; import { openApiSection } from './src/lib/openapi-section.server'; @@ -16,8 +16,13 @@ const isSkippedByGlob = (segment: string) => segment.startsWith('.') || segment * collapsed sidebar folders and never sees the `.md` URLs the page actions fetch, so * the list is computed here from the same sources the site is built from. */ -export async function prerenderPages(config: BuildPaths, siteUrl: string | null): Promise<{ path: string }[]> { +export async function prerenderPages( + config: BuildPaths, + { siteUrl, copilot }: BuildIdentity +): Promise<{ path: string }[]> { const urls = new Set(['/', '/api/search.json', '/llms.txt', '/llms-full.txt']); + // The pages the CLI bundles for Ask AI, so only for a portal that offers it. + if (copilot) urls.add('/api/copilot-pages.json'); // Both need absolute URLs, so they are only emitted for a portal that declares its address. if (siteUrl) { urls.add('/sitemap.xml'); diff --git a/portal-template/src/lib/llms.server.ts b/portal-template/src/lib/llms.server.ts index ce82f503b..cc4b68523 100644 --- a/portal-template/src/lib/llms.server.ts +++ b/portal-template/src/lib/llms.server.ts @@ -45,6 +45,27 @@ export async function renderFull(): Promise { return pages.join('\n\n'); } +/** A page as Ask AI reads it: its portal-relative path, as the chat links it, and its Markdown. */ +export interface AssistantPage { + path: string; + title: string; + description: string | null; + markdown: string; +} + +// Every page, those the navigation leaves out too, as `llms-full.txt` renders it: without the specifications. +export async function renderAssistantPages(): Promise { + return await Promise.all( + source.getPages().map(async (page) => ({ + path: page.url, + // The service names a page by its title in an answer's sources, so one without a title is named by its path. + title: page.data.title ?? page.url, + description: page.data.description ?? null, + markdown: await render(page, false) + })) + ); +} + async function render(page: PortalPage, withSpecification: boolean): Promise { return page.type === 'openapi' ? renderReference(page, withSpecification) : await renderContent(page); } diff --git a/portal-template/src/routes/api/copilot-pages[.]json.ts b/portal-template/src/routes/api/copilot-pages[.]json.ts new file mode 100644 index 000000000..145c3bc3e --- /dev/null +++ b/portal-template/src/routes/api/copilot-pages[.]json.ts @@ -0,0 +1,16 @@ +import { createFileRoute, notFound } from '@tanstack/react-router'; +import { renderAssistantPages } from '@/lib/llms.server'; +import { portal } from '@/lib/portal'; + +// Built only for a portal with Ask AI, for the CLI: `portal generate` bundles these pages for the +// service and removes this file, so it is never deployed. +export const Route = createFileRoute('/api/copilot-pages.json')({ + server: { + handlers: { + GET: async () => { + if (portal.copilot === null) throw notFound(); + return Response.json(await renderAssistantPages()); + } + } + } +}); diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 27025c9d4..936232153 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -15,7 +15,7 @@ import { staticFunctionsBase } from './static-functions-base.ts'; export default defineConfig(async () => { const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]); - const pages = await prerenderPages(paths, identity.siteUrl); + const pages = await prerenderPages(paths, identity); const publicDir: string | false = paths.staticDir ?? false; const portalProjectDirectory = fileURLToPath(new URL('.', import.meta.url)); diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts index 9f52f8fb0..1cdcbc669 100644 --- a/src/actions/portal/copilot/sync.ts +++ b/src/actions/portal/copilot/sync.ts @@ -1,5 +1,4 @@ import { getAuthInfo } from '../../../client-utils/auth-manager.js'; -import { envInfo } from '../../../infrastructure/env-info.js'; import { CopilotIngestService } from '../../../infrastructure/services/copilot-ingest-service.js'; import { PortalCopilotSyncPrompts } from '../../../prompts/portal/copilot/sync.js'; import { CommandMetadata } from '../../../types/common/command-metadata.js'; @@ -43,11 +42,8 @@ export class PortalCopilotSyncAction { return ActionResult.failed(); } - const bundled = await new GeneratedPortalContext(portalDirectory).bundle( - config.siteAddress(), - envInfo.getCLIVersion(), - new Date() - ); + // As `generate` wrote it for the deploy, under the address the portal was built for. + const bundled = await new GeneratedPortalContext(portalDirectory).bundle(); if (bundled.isErr()) { this.prompts.portalNotRead(portalDirectory, bundled.error); return ActionResult.failed(); @@ -57,10 +53,6 @@ export class PortalCopilotSyncAction { this.prompts.portalNotGenerated(portalDirectory); return ActionResult.failed(); } - if (bundle.pageCount() === 0) { - this.prompts.noPages(portalDirectory); - return ActionResult.failed(); - } const { shell } = this.commandMetadata; const receipt = await this.prompts.upload( diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 0e4f10cfe..4683ed454 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -6,6 +6,7 @@ import { CommandMetadata } from '../../types/common/command-metadata.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { PortalContext } from '../../types/portal-context.js'; import { SiteAddress } from '../../types/portal/config/site-address.js'; +import { CopilotBundle } from '../../types/portal/copilot/copilot-bundle.js'; import { GeneratedPortalContext } from '../../types/portal/copilot/generated-portal-context.js'; import { ProjectContext } from '../../types/project-context.js'; import { ActionResult } from '../action-result.js'; @@ -96,20 +97,20 @@ export class GenerateAction { return ActionResult.success(); } - /** The portal still saves without the bundle, whatever stops it; Ask AI is then updated by `portal copilot sync`. */ + /** The portal still saves without the bundle, whatever stops it. */ private async publishCopilotBundle(builtDirectory: DirectoryPath, site: SiteAddress | null): Promise { const portal = new GeneratedPortalContext(builtDirectory); - const bundled = await portal.bundle(site, envInfo.getCLIVersion(), new Date()); - if (bundled.isErr()) { - this.prompts.copilotBundleNotWritten(bundled.error); + const taken = await portal.takePages(); + if (taken.isErr()) { + this.prompts.copilotBundleNotWritten(taken.error); return false; } - const bundle = bundled.value; - if (bundle === null || bundle.pageCount() === 0) { + const pages = taken.value; + if (pages === null || pages.length === 0) { this.prompts.copilotPagesNotFound(); return false; } - const published = await portal.publish(bundle); + const published = await portal.publish(CopilotBundle.of(pages, site, envInfo.getCLIVersion(), new Date())); if (published.isErr()) { this.prompts.copilotBundleNotWritten(published.error); return false; diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index 0e6bef546..ab5a9553b 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -43,8 +43,8 @@ export class PortalCopilotSyncPrompts { public portalNotGenerated(portalDirectory: DirectoryPath) { const message = - `No generated portal was found at ${f.path(portalDirectory)}. ` + - `Generate the portal first: ${f.cmdAlt('apimatic', 'portal', 'generate')}.`; + `No pages for Ask AI were found in the portal at ${f.path(portalDirectory)}. ` + + `Generate the portal with its Ask AI chat set up first: ${f.cmdAlt('apimatic', 'portal', 'generate')}.`; log.error(message); } @@ -52,13 +52,6 @@ export class PortalCopilotSyncPrompts { log.error(`The portal at ${f.path(portalDirectory)} could not be read (${reason}).`); } - public noPages(portalDirectory: DirectoryPath) { - const message = - `The portal at ${f.path(portalDirectory)} has no pages Ask AI can read. ` + - `Generate it again with ${f.cmdAlt('apimatic', 'portal', 'generate')}.`; - log.error(message); - } - public upload(fn: Promise>, pageCount: number) { return withSpinner( `Uploading ${pages(pageCount)} to Ask AI`, diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 9bc548b32..95ed71131 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -122,14 +122,15 @@ export class PortalGeneratePrompts { } public copilotPagesNotFound() { - log.warn(`The portal lists no pages for Ask AI to answer from. ${deployThenSync()}`); + log.warn('The portal has no pages for Ask AI to answer from, so none were added to it.'); } public copilotBundleNotWritten(reason: string) { - log.warn(`The pages for Ask AI could not be added to the portal (${reason}). ${deployThenSync()}`); + log.warn( + `The pages for Ask AI could not be added to the portal (${reason}), so it is saved without them. ` + + `Generate the portal again for Ask AI to answer from it.` + ); } } const syncCommand = () => f.cmdAlt('apimatic', 'portal', 'copilot', 'sync'); - -const deployThenSync = () => `Deploy the portal, then run ${syncCommand()} so Ask AI answers from it.`; diff --git a/src/types/portal/copilot/copilot-bundle.ts b/src/types/portal/copilot/copilot-bundle.ts index 7094f7401..f3620feec 100644 --- a/src/types/portal/copilot/copilot-bundle.ts +++ b/src/types/portal/copilot/copilot-bundle.ts @@ -1,10 +1,14 @@ import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; import { gzipSync } from 'node:zlib'; +import { isJsonObject } from '../../../utils/json-utils.js'; import { SiteAddress } from '../config/site-address.js'; -import { ListedPage } from './llms-index.js'; -export interface BundlePage extends ListedPage { +/** A page as Ask AI reads it: its portal-relative path, as the chat links it, and its Markdown. */ +export interface BundlePage { + path: string; + title: string; + description: string | null; markdown: string; } @@ -26,10 +30,20 @@ const GENERATOR_NAME = 'apimatic-cli'; /** The portal's pages as the Ask AI service ingests them, in its format version 1. */ export class CopilotBundle { - private readonly json: string; + private constructor( + private readonly document: BundleDocument, + private readonly json: string = JSON.stringify(document) + ) {} - private constructor(private readonly document: BundleDocument) { - this.json = JSON.stringify(document); + /** A bundle `generate` wrote, kept as its exact bytes; null for one this CLI cannot read. */ + public static parse(json: string): CopilotBundle | null { + let document: unknown; + try { + document = JSON.parse(json); + } catch { + return null; + } + return isBundleDocument(document) ? new CopilotBundle(document, json) : null; } public static of(pages: BundlePage[], site: SiteAddress | null, cliVersion: string, at: Date): CopilotBundle { @@ -72,3 +86,26 @@ export class CopilotBundle { return JSON.stringify({ formatVersion: 1, buildId }); } } + +/** A page as the portal's build writes it for Ask AI; null for anything else. */ +export function bundlePageOf(value: unknown): BundlePage | null { + if (!isJsonObject(value)) return null; + const { path, title, description, markdown } = value; + if (typeof path !== 'string' || typeof title !== 'string' || typeof markdown !== 'string') return null; + if (description !== null && typeof description !== 'string') return null; + return { path, title, description, markdown }; +} + +function isBundleDocument(value: unknown): value is BundleDocument { + return ( + isJsonObject(value) && + value.formatVersion === 1 && + isJsonObject(value.generator) && + typeof value.generatedAt === 'string' && + (value.siteUrl === null || typeof value.siteUrl === 'string') && + typeof value.basePath === 'string' && + Array.isArray(value.pages) && + // A page without a description leaves the key out. + value.pages.every((page) => isJsonObject(page) && bundlePageOf({ description: null, ...page }) !== null) + ); +} diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts index 4b2b155be..1df0ff804 100644 --- a/src/types/portal/copilot/generated-portal-context.ts +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -3,41 +3,51 @@ import { FileService } from '../../../infrastructure/file-service.js'; import { DirectoryPath } from '../../file/directoryPath.js'; import { FileName } from '../../file/fileName.js'; import { FilePath } from '../../file/filePath.js'; -import { SiteAddress } from '../config/site-address.js'; -import { BundlePage, CopilotBundle } from './copilot-bundle.js'; -import { ListedPage, listedPages } from './llms-index.js'; +import { BundlePage, bundlePageOf, CopilotBundle } from './copilot-bundle.js'; import { errorMessage } from '../../../utils/error-utils.js'; /** Where the deployed portal serves its bundle, under the site's address, for the Ask AI service to fetch. */ export const COPILOT_DIRECTORY_NAME = '__copilot'; -/** A portal `portal generate` wrote, read for the pages Ask AI answers from. */ +// Under `/api/`, where the portal's own routes are files with extensions and no page can collide with them. +const PAGES_DIRECTORY_NAME = 'api'; +const PAGES_FILE_NAME = 'copilot-pages.json'; + +/** Where a build of a portal with Ask AI writes its pages, for `generate` to bundle. Never deployed. */ +export const COPILOT_PAGES_FILE = `${PAGES_DIRECTORY_NAME}/${PAGES_FILE_NAME}`; + +/** A portal `portal generate` built or wrote, for the pages Ask AI answers from. */ export class GeneratedPortalContext { private readonly fileService = new FileService(); constructor(private readonly portalDirectory: DirectoryPath) {} - private get index(): FilePath { - return new FilePath(this.portalDirectory, new FileName('llms.txt')); + private get pagesFile(): FilePath { + return new FilePath(this.portalDirectory.join(PAGES_DIRECTORY_NAME), new FileName(PAGES_FILE_NAME)); + } + + private get directory(): DirectoryPath { + return this.portalDirectory.join(COPILOT_DIRECTORY_NAME); + } + + private get bundleFile(): FilePath { + return new FilePath(this.directory, new FileName('bundle.json')); } /** - * Null when the directory holds no generated portal. A listed page without its Markdown twin is left out; - * a file that cannot be read is an error, so the caller decides whether that stops it. + * The pages a build wrote for Ask AI, removing their file so it is not deployed; null for a build + * that wrote none, as one of a portal without Ask AI. */ - public async bundle( - site: SiteAddress | null, - cliVersion: string, - at: Date - ): Promise> { + public async takePages(): Promise> { + const file = this.pagesFile; try { - if (!(await this.fileService.fileExists(this.index))) { + if (!(await this.fileService.fileExists(file))) { return ok(null); } - const listed = listedPages(await this.fileService.getContents(this.index), site); - const read = await Promise.all(listed.map((page) => this.read(page))); - const pages = read.filter((page) => page !== null); - return ok(CopilotBundle.of(pages, site, cliVersion, at)); + const contents = await this.fileService.getContents(file); + await this.fileService.deleteFile(file); + const pages = pagesOf(contents); + return pages === null ? err('the pages the build wrote could not be read') : ok(pages); } catch (error) { return err(errorMessage(error)); } @@ -45,32 +55,41 @@ export class GeneratedPortalContext { /** Writes the bundle into the portal, so it is deployed with the pages it holds. */ public async publish(bundle: CopilotBundle): Promise> { - const directory = this.portalDirectory.join(COPILOT_DIRECTORY_NAME); try { - await this.fileService.createDirectoryIfNotExists(directory); - await this.fileService.writeContents(new FilePath(directory, new FileName('bundle.json')), bundle.toJson()); - await this.fileService.writeContents(new FilePath(directory, new FileName('build.json')), bundle.buildManifest()); + await this.fileService.createDirectoryIfNotExists(this.directory); + await this.fileService.writeContents(this.bundleFile, bundle.toJson()); + await this.fileService.writeContents( + new FilePath(this.directory, new FileName('build.json')), + bundle.buildManifest() + ); return ok(undefined); } catch (error) { return err(errorMessage(error)); } } - /** The page with its Markdown twin, or null when it has none. */ - private async read(page: ListedPage): Promise { - const markdown = this.markdownOf(page); - if (!(await this.fileService.fileExists(markdown))) { - return null; + /** The bundle `generate` published into the portal; null for a portal without one. */ + public async bundle(): Promise> { + try { + if (!(await this.fileService.fileExists(this.bundleFile))) { + return ok(null); + } + const bundle = CopilotBundle.parse(await this.fileService.getContents(this.bundleFile)); + return bundle === null ? err('its Ask AI pages could not be read') : ok(bundle); + } catch (error) { + return err(errorMessage(error)); } - return { ...page, markdown: await this.fileService.getContents(markdown) }; } +} - private markdownOf({ path }: ListedPage): FilePath { - if (path === '/') { - return new FilePath(this.portalDirectory, new FileName('index.md')); - } - const segments = path.slice(1).split('/'); - const page = segments.pop()!; - return new FilePath(this.portalDirectory.join(...segments), new FileName(`${page}.md`)); +function pagesOf(contents: string): BundlePage[] | null { + let value: unknown; + try { + value = JSON.parse(contents); + } catch { + return null; } + if (!Array.isArray(value)) return null; + const pages = value.map(bundlePageOf); + return pages.every((page) => page !== null) ? pages : null; } diff --git a/src/types/portal/copilot/llms-index.ts b/src/types/portal/copilot/llms-index.ts deleted file mode 100644 index cbe617765..000000000 --- a/src/types/portal/copilot/llms-index.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SiteAddress } from '../config/site-address.js'; - -/** One page `llms.txt` lists, addressed from the portal's root: `/` for the home page, `/guides/intro` for one under it. */ -export interface ListedPage { - path: string; - title: string; - description: string | null; -} - -const LINK = /^\s*[-*]\s+\[(.*)\]\(([^)\s]+)\)(?::(.*)|\s*)$/; - -const DOWNLOADS = '/__downloads/'; - -/** The pages `llms.txt` lists under the portal's base path, each once, in the order it lists them. */ -export function listedPages(llmsTxt: string, site: SiteAddress | null): ListedPage[] { - const basePath = site?.path() ?? '/'; - const pages = new Map(); - for (const line of llmsTxt.split(/\r?\n/)) { - const match = LINK.exec(line); - if (match === null) { - continue; - } - const [, title, href, description] = match; - const path = portalPath(href, basePath); - if (path !== null && !pages.has(path)) { - pages.set(path, { path, title: title.trim(), description: description?.trim() || null }); - } - } - return [...pages.values()]; -} - -function portalPath(href: string, basePath: string): string | null { - const [address] = href.split(/[?#]/); - if (!address.startsWith('/') || address.includes(DOWNLOADS)) { - return null; - } - const withSlash = address.endsWith('/') ? address : `${address}/`; - if (!withSlash.startsWith(basePath)) { - return null; - } - const segments = withSlash - .slice(basePath.length) - .split('/') - .filter((segment) => segment !== ''); - // A segment that climbs would name a file outside the portal. - if (segments.some((segment) => segment === '.' || segment === '..')) { - return null; - } - return `/${segments.join('/')}`; -} diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index 04091050a..c5c6bb02f 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -13,6 +13,7 @@ import { CommandMetadata } from '../../../../src/types/common/command-metadata'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; import { CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; import { ProjectContext } from '../../../../src/types/project-context'; +import { SiteAddress } from '../../../../src/types/portal/config/site-address'; import { GeneratedPortalContext } from '../../../../src/types/portal/copilot/generated-portal-context'; /** The bundle as the service receives it. */ @@ -48,22 +49,29 @@ describe('PortalCopilotSyncAction', () => { fs.writeFileSync(file, contents); }; - const writeConfig = (ai: unknown) => + const writeConfig = (ai: unknown, url = 'https://example.com/docs') => write( 'src/apimatic.json', JSON.stringify({ - portal: { site: { name: 'Acme', url: 'https://example.com/docs' }, ...(ai ? { ai } : {}) }, + portal: { site: { name: 'Acme', url }, ...(ai ? { ai } : {}) }, languages: { typescript: {} } }) ); const writeSavedLogin = (authKey: string) => write('config.json', JSON.stringify({ email: 'a@b.c', authKey })); - const writePortal = () => { - write('portal/llms.txt', ['- [Docs](/docs)', ' - [Intro](/docs/guides/intro): Start'].join('\n')); - write('portal/index.md', '# Docs'); - write('portal/guides/intro.md', '# Intro'); - }; + /** As `generate` writes it into a portal built for `https://example.com/docs`. */ + const PUBLISHED = CopilotBundle.of( + [ + { path: '/', title: 'Docs', description: null, markdown: '# Docs' }, + { path: '/guides/intro', title: 'Intro', description: 'Start', markdown: '# Intro' } + ], + SiteAddress.parse('https://example.com/docs', 'url')._unsafeUnwrap(), + '9.9.9', + new Date('2026-10-14T12:00:00.000Z') + ); + + const writePortal = () => write('portal/__copilot/bundle.json', PUBLISHED.toJson()); const execute = (wait = true, authKey: string | null = 'auth-key') => { const project = ProjectContext.in(new DirectoryPath(root)); @@ -93,15 +101,18 @@ describe('PortalCopilotSyncAction', () => { fs.rmSync(root, { recursive: true, force: true }); }); - it('uploads the portal under its base path and waits until Ask AI answers from it', async () => { + it('uploads the bundle generate wrote, as it is, and waits until Ask AI answers from it', async () => { + // The address has changed since the portal was generated: the bundle keeps the one it was built for. + writeConfig({ copilot: COPILOT }, 'https://example.com/api'); + const result = await execute(); expect(result.isSuccess()).to.be.true; const [copilot, bundle, , authKey] = upload.firstCall.args as [unknown, CopilotBundle, unknown, string]; expect(copilot).to.deep.include(COPILOT); expect(authKey).to.equal('auth-key'); + expect(bundle.toJson()).to.equal(PUBLISHED.toJson()); expect(uploaded(bundle)).to.deep.include({ basePath: '/docs/', siteUrl: 'https://example.com/docs' }); - expect(uploaded(bundle).pages.map(({ path: page }) => page)).to.deep.equal(['/', '/guides/intro']); expect(awaitIndexing.firstCall.args[1]).to.equal('v-1'); expect(prompts.indexed.calledOnceWith(indexed('active'))).to.be.true; }); @@ -238,8 +249,9 @@ describe('PortalCopilotSyncAction', () => { expect(upload.called).to.be.false; }); - it('fails without uploading when the portal has not been generated', async () => { - fs.rmSync(path.join(root, 'portal'), { recursive: true }); + it('fails without uploading when the portal has not been generated, or was without Ask AI', async () => { + fs.rmSync(path.join(root, 'portal', '__copilot'), { recursive: true }); + write('portal/index.html', ''); const result = await execute(); @@ -247,15 +259,4 @@ describe('PortalCopilotSyncAction', () => { expect(prompts.portalNotGenerated.calledOnce).to.be.true; expect(upload.called).to.be.false; }); - - it('fails without uploading when no listed page has its Markdown', async () => { - fs.rmSync(path.join(root, 'portal', 'index.md')); - fs.rmSync(path.join(root, 'portal', 'guides'), { recursive: true }); - - const result = await execute(); - - expect(result.isFailed()).to.be.true; - expect(prompts.noPages.calledOnce).to.be.true; - expect(upload.called).to.be.false; - }); }); diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index ae67973bc..2a0409cf1 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -15,7 +15,7 @@ import { ServiceError } from '../../../src/infrastructure/service-error'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { CommandMetadata } from '../../../src/types/common/command-metadata'; import { ProjectContext } from '../../../src/types/project-context'; -import { GeneratedPortalContext } from '../../../src/types/portal/copilot/generated-portal-context'; +import { COPILOT_PAGES_FILE, GeneratedPortalContext } from '../../../src/types/portal/copilot/generated-portal-context'; import { completeArtifacts, stubPreparePortalProject } from './prepare-project-stubs'; const COMMAND_METADATA: CommandMetadata = { commandName: 'portal generate', shell: 'test' }; @@ -249,20 +249,25 @@ describe('GenerateAction', () => { describe('with Ask AI', () => { const readPortal = (relative: string) => fs.readFileSync(path.join(portalDirectory.toString(), relative), 'utf8'); - const listPages = () => { - fs.writeFileSync(path.join(builtSite.toString(), 'llms.txt'), '- [Welcome](/): Start here'); - fs.writeFileSync(path.join(builtSite.toString(), 'index.md'), '# Welcome'); + const PAGE = { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }; + + /** As a build of a portal with Ask AI writes them. */ + const writePages = (pages: unknown[] = [PAGE]) => { + const file = path.join(builtSite.toString(), COPILOT_PAGES_FILE); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(pages)); }; it('deploys the pages with the portal, named by a build ID the service compares', async () => { - listPages(); + writePages(); const result = await execute(COPILOT_FIXTURE); expect(result.isSuccess()).to.be.true; + expect(inPortal(COPILOT_PAGES_FILE), 'the pages as the build wrote them').to.be.false; const bundle = readPortal('__copilot/bundle.json'); const { pages } = JSON.parse(bundle) as { pages: unknown[] }; - expect(pages).to.deep.equal([{ path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }]); + expect(pages).to.deep.equal([PAGE]); const manifest = JSON.parse(readPortal('__copilot/build.json')) as { formatVersion: number; buildId: string }; expect(manifest.formatVersion).to.equal(1); expect(manifest.buildId).to.match(/^[0-9a-f]{64}$/); @@ -270,7 +275,7 @@ describe('GenerateAction', () => { }); it('puts the pages in the archive when the portal is zipped', async () => { - listPages(); + writePages(); const result = await execute(COPILOT_FIXTURE, false, true); @@ -279,9 +284,9 @@ describe('GenerateAction', () => { expect(fs.existsSync(path.join(builtSite.toString(), '__copilot', 'build.json'))).to.be.true; }); - it('still saves the portal when its pages cannot be read, pointing to sync instead', async () => { - listPages(); - sinon.stub(GeneratedPortalContext.prototype, 'bundle').resolves(err('EACCES: permission denied')); + it('still saves the portal when its pages cannot be read', async () => { + writePages(); + sinon.stub(GeneratedPortalContext.prototype, 'takePages').resolves(err('EACCES: permission denied')); const result = await execute(COPILOT_FIXTURE); @@ -292,14 +297,19 @@ describe('GenerateAction', () => { expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; }); - it('still saves the portal when it lists no pages, pointing to sync instead', async () => { - const result = await execute(COPILOT_FIXTURE); + it('still saves the portal when it has no pages', async () => { + for (const write of [() => writePages([]), () => undefined]) { + prompts.copilotPagesNotFound.resetHistory(); + write(); - expect(result.isSuccess()).to.be.true; - expect(inPortal('index.html')).to.be.true; - expect(inPortal('__copilot')).to.be.false; - expect(prompts.copilotPagesNotFound.calledOnce).to.be.true; - expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + const result = await execute(COPILOT_FIXTURE, true); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotPagesNotFound.calledOnce).to.be.true; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + } }); }); diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 2215149f8..14560a104 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -11,7 +11,8 @@ import { DirectoryPath } from '../../src/types/file/directoryPath'; import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalog, CodeSampleCatalogs } from '../../src/types/portal/code-samples'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; -import { GeneratedPortalContext } from '../../src/types/portal/copilot/generated-portal-context'; +import { BundlePage } from '../../src/types/portal/copilot/copilot-bundle'; +import { COPILOT_PAGES_FILE } from '../../src/types/portal/copilot/generated-portal-context'; import { Language } from '../../src/types/sdk/generate'; import { ensurePortalProjectDirectoryBase, @@ -1011,6 +1012,9 @@ const stylesheetOf = (output: DirectoryPath) => { const portal = portalBuilt('copilot'); const { read } = portal; + /** The pages as the build wrote them for `generate`, read without taking them. */ + const builtPages = () => JSON.parse(read(COPILOT_PAGES_FILE)) as BundlePage[]; + // The chat wraps the whole layout, so a prerender that did not wait for its chunk would leave the button out. it('prerenders each page with the chat button', () => { expect(read('index.html')).to.contain('>Ask AI<'); @@ -1025,11 +1029,33 @@ const stylesheetOf = (output: DirectoryPath) => { expect(loaded.filter((name) => chat.some((script) => script.name === name))).to.deep.equal([]); }); - // `generate` publishes these pages for the service to fetch, so a real build must list them with their Markdown. - it('lists the pages Ask AI answers from, each with its Markdown copy', async () => { - const bundle = await new GeneratedPortalContext(portal.output()).bundle(null, '0.0.0', new Date()); + // `generate` bundles these for the service: every page the build renders, those the navigation leaves + // out and those whose names are not ASCII too, and no more. + it('writes every page Ask AI answers from, and no more', () => { + const pages = builtPages(); + const twinOf = (page: string) => (page === '/' ? 'index.md' : `${decodeURIComponent(page.slice(1))}.md`); + const twins = fs + .readdirSync(portal.output().toString(), { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.md')) + .map((file) => file.split(path.sep).join('/')); - expect(bundle._unsafeUnwrap()?.pageCount()).to.be.greaterThan(1); + expect(pages.map(({ path: page }) => twinOf(page).normalize()).sort()).to.deep.equal( + twins.map((twin) => twin.normalize()).sort() + ); + expect(pages.find(({ title }) => title === 'Cafรฉ')).to.deep.include({ + description: 'A page whose name is not ASCII.' + }); + expect(pages.map(({ title }) => title)).to.include('Unlisted'); + }); + + // As `llms-full.txt` renders them: each operation's slice of the specification would repeat the schemas it shares. + it('renders each page without its specification', () => { + const operations = builtPages().filter(({ path: page }) => page.startsWith('/api/')); + + expect(operations, 'the specification has operations').to.not.be.empty; + for (const { path: page, markdown } of operations) { + expect(markdown, page).to.contain(`(${page})`).and.not.contain('```yaml'); + } }); it('tells the browser the public key and the service, and no more', () => { diff --git a/test/portal-template/prerender-pages.test.ts b/test/portal-template/prerender-pages.test.ts index c576287a1..238f08637 100644 --- a/test/portal-template/prerender-pages.test.ts +++ b/test/portal-template/prerender-pages.test.ts @@ -3,6 +3,10 @@ import os from 'os'; import path from 'path'; import { expect } from 'chai'; import { prerenderPages } from '../../portal-template/prerender-pages'; +import type { PortalCopilot } from '../../portal-template/src/lib/portal-types'; +import { COPILOT_PAGES_FILE } from '../../src/types/portal/copilot/generated-portal-context'; + +const COPILOT: PortalCopilot = { key: 'pk_test', apiUrl: 'https://ask.apimatic.io', suggestions: [] }; // The list this builds is the whole of what a static build writes: TanStack's crawler is off, // so a URL missing here is a page that is never emitted, however many things link to it. @@ -17,10 +21,15 @@ describe('prerenderPages', () => { }; const write = (relative: string, body = '# page\n') => writeIn(contentDir, relative, body); - const urlsFor = async (siteUrl: string | null = null, base = '/', specs: Record = {}) => { + const urlsFor = async ( + siteUrl: string | null = null, + base = '/', + specs: Record = {}, + copilot: PortalCopilot | null = null + ) => { const pages = await prerenderPages( { base, specs, specDir: '', codeSamples: null, contentDir, generatedDir, staticDir: null, downloadsDir: null }, - siteUrl + { siteUrl, copilot } ); return pages.map((page) => page.path); }; @@ -106,6 +115,16 @@ describe('prerenderPages', () => { expect(await urlsFor('https://docs.test')).to.include('/robots.txt'); }); + it('emits the pages for Ask AI only for a portal that offers it, where the CLI reads them', async () => { + write('index.md'); + + expect(await urlsFor()).to.not.include(`/${COPILOT_PAGES_FILE}`); + const urls = await urlsFor(null, '/', {}, COPILOT); + expect(urls).to.include(`/${COPILOT_PAGES_FILE}`); + expect(urls).to.not.include(`/${COPILOT_PAGES_FILE}.md`); + expect(await urlsFor('https://docs.test/docs', '/docs/', {}, COPILOT)).to.include(`/docs/${COPILOT_PAGES_FILE}`); + }); + it('lists the OAuth callback, without a Markdown twin', async () => { write('index.md'); diff --git "a/test/resources/portal-inputs/copilot/src/content/guides/caf\303\251.md" "b/test/resources/portal-inputs/copilot/src/content/guides/caf\303\251.md" new file mode 100644 index 000000000..8760aa218 --- /dev/null +++ "b/test/resources/portal-inputs/copilot/src/content/guides/caf\303\251.md" @@ -0,0 +1,6 @@ +--- +title: Cafรฉ +description: A page whose name is not ASCII. +--- + +Pets can visit the cafรฉ. diff --git a/test/resources/portal-inputs/copilot/src/content/guides/nav.json b/test/resources/portal-inputs/copilot/src/content/guides/nav.json new file mode 100644 index 000000000..37e658726 --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/content/guides/nav.json @@ -0,0 +1,4 @@ +{ + "title": "Guides", + "pages": ["cafรฉ"] +} diff --git a/test/resources/portal-inputs/copilot/src/content/guides/unlisted.md b/test/resources/portal-inputs/copilot/src/content/guides/unlisted.md new file mode 100644 index 000000000..f0ccee532 --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/content/guides/unlisted.md @@ -0,0 +1,5 @@ +--- +title: Unlisted +--- + +A page the navigation leaves out, which Ask AI still answers from. diff --git a/test/resources/portal-inputs/copilot/src/content/index.md b/test/resources/portal-inputs/copilot/src/content/index.md new file mode 100644 index 000000000..ed45dba6a --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/content/index.md @@ -0,0 +1,6 @@ +--- +title: Ask AI Pets +description: Pets, with answers. +--- + +The pets API, with Ask AI to answer questions about it. diff --git a/test/types/portal/copilot/copilot-bundle.test.ts b/test/types/portal/copilot/copilot-bundle.test.ts new file mode 100644 index 000000000..68bd3543d --- /dev/null +++ b/test/types/portal/copilot/copilot-bundle.test.ts @@ -0,0 +1,95 @@ +import { gunzipSync } from 'node:zlib'; +import { expect } from 'chai'; +import { SiteAddress } from '../../../../src/types/portal/config/site-address'; +import { BundlePage, CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; + +const AT = new Date('2026-10-14T12:00:00.000Z'); + +const PAGES: BundlePage[] = [ + { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, + { path: '/guides/intro', title: 'Intro', description: null, markdown: '# Intro' } +]; + +const at = (url: string) => SiteAddress.parse(url, 'url')._unsafeUnwrap(); + +const documentOf = (bundle: CopilotBundle) => JSON.parse(gunzipSync(bundle.gzipped()).toString('utf8')) as unknown; + +describe('CopilotBundle', () => { + it("holds the pages in the service's format, a page without a description leaving the key out", () => { + const bundle = CopilotBundle.of(PAGES, null, '9.9.9', AT); + + expect(bundle.pageCount()).to.equal(2); + expect(documentOf(bundle)).to.deep.equal({ + formatVersion: 1, + generator: { name: 'apimatic-cli', version: '9.9.9' }, + generatedAt: '2026-10-14T12:00:00.000Z', + siteUrl: null, + basePath: '/', + pages: [ + { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, + { path: '/guides/intro', title: 'Intro', markdown: '# Intro' } + ] + }); + }); + + it('names the address a portal served under a path is at, and that path', () => { + const bundle = CopilotBundle.of(PAGES, at('https://example.com/docs'), '9.9.9', AT); + + expect(documentOf(bundle)).to.deep.include({ siteUrl: 'https://example.com/docs', basePath: '/docs/' }); + }); + + describe('parse', () => { + it('reads back a bundle, keeping its exact bytes', () => { + const json = CopilotBundle.of(PAGES, at('https://example.com/docs'), '9.9.9', AT).toJson(); + + const bundle = CopilotBundle.parse(json)!; + + expect(bundle.toJson()).to.equal(json); + expect(bundle.pageCount()).to.equal(2); + expect(bundle.buildManifest()).to.equal(CopilotBundle.parse(json)!.buildManifest()); + }); + + it('refuses anything that is not a bundle of this format', () => { + const valid = JSON.parse(CopilotBundle.of(PAGES, null, '9.9.9', AT).toJson()) as Record; + + for (const [label, json] of [ + ['text that is not JSON', '{'], + ['another format version', JSON.stringify({ ...valid, formatVersion: 2 })], + ['pages that are not a list', JSON.stringify({ ...valid, pages: {} })], + ['a page without its Markdown', JSON.stringify({ ...valid, pages: [{ path: '/', title: 'Welcome' }] })], + ['a page that is not an object', JSON.stringify({ ...valid, pages: ['/'] })] + ]) { + expect(CopilotBundle.parse(json!), label).to.be.null; + } + }); + }); + + describe('build ID', () => { + const buildId = (pages = PAGES, site: SiteAddress | null = null, cliVersion = '9.9.9', when = AT) => + (JSON.parse(CopilotBundle.of(pages, site, cliVersion, when).buildManifest()) as { buildId: string }).buildId; + + it('is a SHA-256 in hex, in format version 1', () => { + const manifest = JSON.parse(CopilotBundle.of(PAGES, null, '9.9.9', AT).buildManifest()) as unknown; + + expect(manifest).to.deep.include({ formatVersion: 1 }); + expect(buildId()).to.match(/^[0-9a-f]{64}$/); + }); + + it('stays the same when the same pages are generated again, at another time or by another CLI', () => { + expect(buildId(PAGES, null, '9.9.9', new Date('2027-01-01T00:00:00.000Z'))).to.equal(buildId()); + expect(buildId(PAGES, null, '10.0.0')).to.equal(buildId()); + }); + + it('changes with a page', () => { + const changed = [{ ...PAGES[0]!, markdown: '# Welcome back' }, PAGES[1]!]; + + expect(buildId(changed)).to.not.equal(buildId()); + }); + + it('changes with the address the pages are served at', () => { + expect(buildId(PAGES, at('https://example.com/docs'))).to.not.equal( + buildId(PAGES, at('https://example.com/api')) + ); + }); + }); +}); diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index cc5da646f..dd2c1c417 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -1,174 +1,124 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { gunzipSync } from 'node:zlib'; import { expect } from 'chai'; import sinon from 'sinon'; import { FileService } from '../../../../src/infrastructure/file-service'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; -import { SiteAddress } from '../../../../src/types/portal/config/site-address'; -import { GeneratedPortalContext } from '../../../../src/types/portal/copilot/generated-portal-context'; +import { BundlePage, CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; +import { + COPILOT_PAGES_FILE, + GeneratedPortalContext +} from '../../../../src/types/portal/copilot/generated-portal-context'; -const AT = new Date('2026-10-14T12:00:00.000Z'); +const PAGES: BundlePage[] = [ + { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, + { path: '/guides/caf%C3%A9', title: 'Cafรฉ', description: null, markdown: '# Cafรฉ' } +]; + +const BUNDLE = CopilotBundle.of(PAGES, null, '9.9.9', new Date('2026-10-14T12:00:00.000Z')); describe('GeneratedPortalContext', () => { let root: string; + let portal: GeneratedPortalContext; const write = (relative: string, contents: string) => { const file = path.join(root, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, contents); }; + const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + const exists = (relative: string) => fs.existsSync(path.join(root, relative)); - const bundle = (site: SiteAddress | null = null) => - new GeneratedPortalContext(new DirectoryPath(root)) - .bundle(site, '9.9.9', AT) - .then((built) => built._unsafeUnwrap()); + /** Has `FileService` fail to read the files whose path ends with `suffix`. */ + const unreadable = (suffix: string) => { + const getContents = FileService.prototype.getContents; + return sinon.stub(FileService.prototype, 'getContents').callsFake(function (this: FileService, file) { + return file.toString().endsWith(suffix) + ? Promise.reject(new Error('EACCES: permission denied')) + : getContents.call(this, file); + }); + }; beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-portal-')); + portal = new GeneratedPortalContext(new DirectoryPath(root)); }); afterEach(() => { + sinon.restore(); fs.rmSync(root, { recursive: true, force: true }); }); - it('reports a page it cannot read rather than throwing', async () => { - write('llms.txt', '- [Welcome](/)'); - write('index.md', '# Welcome'); - const getContents = FileService.prototype.getContents; - const unreadable = sinon.stub(FileService.prototype, 'getContents').callsFake(function (this: FileService, file) { - return file.toString().endsWith('index.md') - ? Promise.reject(new Error('EACCES: permission denied')) - : getContents.call(this, file); - }); + describe('takePages', () => { + it('reads the pages the build wrote for Ask AI, and removes them so they are not deployed', async () => { + write(COPILOT_PAGES_FILE, JSON.stringify(PAGES)); - try { - const built = await new GeneratedPortalContext(new DirectoryPath(root)).bundle(null, '9.9.9', AT); + expect((await portal.takePages())._unsafeUnwrap()).to.deep.equal(PAGES); + expect(exists(COPILOT_PAGES_FILE)).to.be.false; + }); - expect(built._unsafeUnwrapErr()).to.contain('EACCES: permission denied'); - } finally { - unreadable.restore(); - } - }); + it('is null for a build that wrote none, as one of a portal without Ask AI', async () => { + expect((await portal.takePages())._unsafeUnwrap()).to.be.null; + }); - it('is null for a directory with no generated portal in it', async () => { - expect(await bundle()).to.be.null; - }); + it('reports pages it cannot make out, and still removes them', async () => { + for (const contents of ['{', '{}', JSON.stringify([{ path: '/', title: 'Welcome' }])]) { + write(COPILOT_PAGES_FILE, contents); - it("bundles each listed page with its Markdown twin, the home page's being index.md", async () => { - write( - 'llms.txt', - [ - '- [Welcome](/): Start here', - ' - [Intro](/guides/intro): Install', - ' - [Missing](/guides/missing)', - ' - [SDK](/__downloads/sdk.zip)' - ].join('\n') - ); - write('index.md', '# Welcome'); - write('guides/intro.md', '# Intro'); - - const built = (await bundle())!; - - expect(built.pageCount()).to.equal(2); - expect(JSON.parse(gunzipSync(built.gzipped()).toString('utf8'))).to.deep.equal({ - formatVersion: 1, - generator: { name: 'apimatic-cli', version: '9.9.9' }, - generatedAt: '2026-10-14T12:00:00.000Z', - siteUrl: null, - basePath: '/', - pages: [ - { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, - { path: '/guides/intro', title: 'Intro', description: 'Install', markdown: '# Intro' } - ] + expect((await portal.takePages()).isErr(), contents).to.be.true; + expect(exists(COPILOT_PAGES_FILE), contents).to.be.false; + } }); - }); - - it('reads a portal served under a path, with its pages addressed from that path', async () => { - write('llms.txt', ['- [Docs](/docs)', '- [Intro](/docs/guides/intro)'].join('\n')); - write('index.md', '# Docs'); - write('guides/intro.md', '# Intro'); - - const built = (await bundle(SiteAddress.parse('https://example.com/docs', 'url')._unsafeUnwrap()))!; - - const document = JSON.parse(gunzipSync(built.gzipped()).toString('utf8')) as { - siteUrl: string | null; - basePath: string; - pages: { path: string; description?: string }[]; - }; - expect(document.siteUrl).to.equal('https://example.com/docs'); - expect(document.basePath).to.equal('/docs/'); - expect(document.pages.map(({ path: page }) => page)).to.deep.equal(['/', '/guides/intro']); - expect(document.pages[0]).to.not.have.property('description'); - }); - it('has no pages when none of the listed ones has its Markdown twin', async () => { - write('llms.txt', '- [Intro](/guides/intro)'); + it('reports pages it cannot read rather than throwing', async () => { + write(COPILOT_PAGES_FILE, JSON.stringify(PAGES)); + unreadable('copilot-pages.json'); - expect((await bundle())!.pageCount()).to.equal(0); + expect((await portal.takePages())._unsafeUnwrapErr()).to.contain('EACCES: permission denied'); + }); }); describe('publish', () => { - const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); - - beforeEach(() => { - write('llms.txt', '- [Welcome](/)'); - write('index.md', '# Welcome'); - }); - it('writes the bundle as plain JSON beside its build ID', async () => { - const built = (await bundle())!; - - expect((await new GeneratedPortalContext(new DirectoryPath(root)).publish(built)).isOk()).to.be.true; + expect((await portal.publish(BUNDLE)).isOk()).to.be.true; - const json = read('__copilot/bundle.json'); - expect(JSON.parse(json)).to.deep.equal(JSON.parse(gunzipSync(built.gzipped()).toString('utf8'))); - expect(read('__copilot/build.json')).to.equal(built.buildManifest()); + expect(read('__copilot/bundle.json')).to.equal(BUNDLE.toJson()); + expect(read('__copilot/build.json')).to.equal(BUNDLE.buildManifest()); }); - describe('build ID', () => { - const buildId = async (site: SiteAddress | null = null, cliVersion = '9.9.9', at = AT) => { - const built = await new GeneratedPortalContext(new DirectoryPath(root)).bundle(site, cliVersion, at); - return (JSON.parse(built._unsafeUnwrap()!.buildManifest()) as { formatVersion: number; buildId: string }) - .buildId; - }; - - it('is a SHA-256 in hex', async () => { - expect(await buildId()).to.match(/^[0-9a-f]{64}$/); - }); + it('reports a portal it cannot write to rather than throwing', async () => { + write('__copilot', 'a file where the directory goes'); - it('stays the same when the same pages are generated again, at another time or by another CLI', async () => { - const first = await buildId(); + expect((await portal.publish(BUNDLE)).isErr()).to.be.true; + }); + }); - expect(await buildId(null, '9.9.9', new Date('2027-01-01T00:00:00.000Z'))).to.equal(first); - expect(await buildId(null, '10.0.0')).to.equal(first); - }); + describe('bundle', () => { + it('reads back the bundle as it was published, byte for byte', async () => { + await portal.publish(BUNDLE); - it('changes with a page', async () => { - const first = await buildId(); + const bundle = (await portal.bundle())._unsafeUnwrap()!; - write('index.md', '# Welcome back'); + expect(bundle.toJson()).to.equal(BUNDLE.toJson()); + expect(bundle.buildManifest()).to.equal(BUNDLE.buildManifest()); + }); - expect(await buildId()).to.not.equal(first); - }); + it('is null for a portal without one', async () => { + expect((await portal.bundle())._unsafeUnwrap()).to.be.null; + }); - it('changes with the address the pages are served at', async () => { - const at = (url: string) => SiteAddress.parse(url, 'url')._unsafeUnwrap(); + it('reports a bundle it cannot make out', async () => { + write('__copilot/bundle.json', '{ "formatVersion": 2 }'); - expect(await buildId(at('https://example.com/docs'))).to.not.equal( - await buildId(at('https://example.com/api')) - ); - }); + expect((await portal.bundle()).isErr()).to.be.true; }); - it('reports a portal it cannot write to rather than throwing', async () => { - const built = (await bundle())!; - write('__copilot', 'a file where the directory goes'); - - const published = await new GeneratedPortalContext(new DirectoryPath(root)).publish(built); + it('reports a bundle it cannot read rather than throwing', async () => { + await portal.publish(BUNDLE); + unreadable('bundle.json'); - expect(published.isErr()).to.be.true; + expect((await portal.bundle())._unsafeUnwrapErr()).to.contain('EACCES: permission denied'); }); }); }); diff --git a/test/types/portal/copilot/llms-index.test.ts b/test/types/portal/copilot/llms-index.test.ts deleted file mode 100644 index c479263df..000000000 --- a/test/types/portal/copilot/llms-index.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect } from 'chai'; -import { SiteAddress } from '../../../../src/types/portal/config/site-address'; -import { listedPages } from '../../../../src/types/portal/copilot/llms-index'; - -const underDocs = SiteAddress.parse('https://example.com/docs', 'url')._unsafeUnwrap(); - -describe('listedPages', () => { - it('reads every linked page with its title and description, in the order llms.txt lists them', () => { - const llmsTxt = [ - '# Acme', - '', - '> The Acme API.', - '- [Welcome](/): Start here', - ' - [ Intro ](/guides/intro)', - '* [Errors](/api/errors): What can go wrong' - ].join('\r\n'); - - expect(listedPages(llmsTxt, null)).to.deep.equal([ - { path: '/', title: 'Welcome', description: 'Start here' }, - { path: '/guides/intro', title: 'Intro', description: null }, - { path: '/api/errors', title: 'Errors', description: 'What can go wrong' } - ]); - }); - - it('addresses pages from the base path, leaving out those outside it', () => { - const llmsTxt = [ - '- [Docs](/docs)', - '- [Intro](/docs/guides/intro/)', - '- [Blog](/blog/post)', - '- [Search](/docsearch)' - ].join('\n'); - - expect(listedPages(llmsTxt, underDocs).map((page) => page.path)).to.deep.equal(['/', '/guides/intro']); - }); - - it('lists a page once, by its address without query or fragment', () => { - const llmsTxt = ['- [Intro](/guides/intro?tab=ts)', '- [Intro again](/guides/intro#install)'].join('\n'); - - expect(listedPages(llmsTxt, null)).to.deep.equal([{ path: '/guides/intro', title: 'Intro', description: null }]); - }); - - it('leaves out downloads, other websites and addresses that climb out of the portal', () => { - const llmsTxt = [ - '- [SDK](/__downloads/sdk.zip)', - '- [Status](https://status.example.com)', - '- [Relative](guides/intro)', - '- [Climb](/guides/../../secret)', - '- [Here](/guides/./intro)' - ].join('\n'); - - expect(listedPages(llmsTxt, null)).to.deep.equal([]); - }); -}); From 26cf47ca08e6317ebb853d49cf51e435c5427c9b Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 09:17:02 +0500 Subject: [PATCH 24/41] fix(portal): let sync read the Ask AI bundle from a portal generated with --zip A portal generated with `--zip` holds only `portal.zip`, so `sync` found no bundle and asked the user to generate the portal they had just generated. It now reads `__copilot/bundle.json` from inside the archive when the directory has none, through a new `ZipService.readEntry`. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/infrastructure/zip-service.ts | 10 ++++++ .../copilot/generated-portal-context.ts | 30 ++++++++++++++--- test/actions/portal/copilot/sync.test.ts | 17 ++++++++++ test/infrastructure/zip-service.test.ts | 25 +++++++++++++++ .../copilot/generated-portal-context.test.ts | 32 +++++++++++++++++++ 5 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/infrastructure/zip-service.ts b/src/infrastructure/zip-service.ts index 08b6f3742..a191c53b5 100644 --- a/src/infrastructure/zip-service.ts +++ b/src/infrastructure/zip-service.ts @@ -29,6 +29,16 @@ export class ZipService { } } + /** One file of an archive, as text, without expanding the rest; null for an archive without it. */ + public async readEntry(sourceFile: FilePath, entryPath: string): Promise> { + try { + const entry = new AdmZip(sourceFile.toString()).getEntry(entryPath); + return ok(entry === null ? null : entry.getData().toString('utf8')); + } catch (error) { + return err(errorMessage(error)); + } + } + private async write(sourceDir: DirectoryPath, outputZipPath: FilePath): Promise { const entries = this.entriesUnder(sourceDir, ''); const zipfile = new yazl.ZipFile(); diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts index 1df0ff804..8dbadabd8 100644 --- a/src/types/portal/copilot/generated-portal-context.ts +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -1,13 +1,16 @@ import { err, ok, Result } from 'neverthrow'; import { FileService } from '../../../infrastructure/file-service.js'; +import { ZipService } from '../../../infrastructure/zip-service.js'; import { DirectoryPath } from '../../file/directoryPath.js'; import { FileName } from '../../file/fileName.js'; import { FilePath } from '../../file/filePath.js'; +import { ZIP_FILE_NAME } from '../../portal-context.js'; import { BundlePage, bundlePageOf, CopilotBundle } from './copilot-bundle.js'; import { errorMessage } from '../../../utils/error-utils.js'; /** Where the deployed portal serves its bundle, under the site's address, for the Ask AI service to fetch. */ export const COPILOT_DIRECTORY_NAME = '__copilot'; +const BUNDLE_FILE_NAME = 'bundle.json'; // Under `/api/`, where the portal's own routes are files with extensions and no page can collide with them. const PAGES_DIRECTORY_NAME = 'api'; @@ -19,6 +22,7 @@ export const COPILOT_PAGES_FILE = `${PAGES_DIRECTORY_NAME}/${PAGES_FILE_NAME}`; /** A portal `portal generate` built or wrote, for the pages Ask AI answers from. */ export class GeneratedPortalContext { private readonly fileService = new FileService(); + private readonly zipService = new ZipService(); constructor(private readonly portalDirectory: DirectoryPath) {} @@ -31,7 +35,7 @@ export class GeneratedPortalContext { } private get bundleFile(): FilePath { - return new FilePath(this.directory, new FileName('bundle.json')); + return new FilePath(this.directory, new FileName(BUNDLE_FILE_NAME)); } /** @@ -68,14 +72,30 @@ export class GeneratedPortalContext { } } - /** The bundle `generate` published into the portal; null for a portal without one. */ + /** + * The bundle `generate` published into the portal, whether saved as a directory or, with `--zip`, as an + * archive; null for a portal without one. + */ public async bundle(): Promise> { + const json = await this.bundleJson(); + if (json.isErr() || json.value === null) { + return json.map(() => null); + } + const bundle = CopilotBundle.parse(json.value); + return bundle === null ? err('its Ask AI pages could not be read') : ok(bundle); + } + + private async bundleJson(): Promise> { try { - if (!(await this.fileService.fileExists(this.bundleFile))) { + if (await this.fileService.fileExists(this.bundleFile)) { + return ok(await this.fileService.getContents(this.bundleFile)); + } + const archive = new FilePath(this.portalDirectory, new FileName(ZIP_FILE_NAME)); + if (!(await this.fileService.fileExists(archive))) { return ok(null); } - const bundle = CopilotBundle.parse(await this.fileService.getContents(this.bundleFile)); - return bundle === null ? err('its Ask AI pages could not be read') : ok(bundle); + // Archived from the built directory, so the bundle is at the same path inside. + return await this.zipService.readEntry(archive, `${COPILOT_DIRECTORY_NAME}/${BUNDLE_FILE_NAME}`); } catch (error) { return err(errorMessage(error)); } diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index c5c6bb02f..ac15b3ea3 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -11,6 +11,9 @@ import { ServiceError, ServiceErrorCode } from '../../../../src/infrastructure/s import { PortalCopilotSyncPrompts } from '../../../../src/prompts/portal/copilot/sync'; import { CommandMetadata } from '../../../../src/types/common/command-metadata'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; +import { FileName } from '../../../../src/types/file/fileName'; +import { FilePath } from '../../../../src/types/file/filePath'; +import { ZipService } from '../../../../src/infrastructure/zip-service'; import { CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; import { ProjectContext } from '../../../../src/types/project-context'; import { SiteAddress } from '../../../../src/types/portal/config/site-address'; @@ -117,6 +120,20 @@ describe('PortalCopilotSyncAction', () => { expect(prompts.indexed.calledOnceWith(indexed('active'))).to.be.true; }); + it('uploads the bundle of a portal generated with --zip, which holds only the archive', async () => { + const portal = path.join(root, 'portal'); + const archive = new FilePath(new DirectoryPath(root), new FileName('portal.zip')); + (await new ZipService().archive(new DirectoryPath(portal), archive))._unsafeUnwrap(); + fs.rmSync(portal, { recursive: true }); + fs.mkdirSync(portal); + fs.renameSync(archive.toString(), path.join(portal, 'portal.zip')); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect((upload.firstCall.args[1] as CopilotBundle).toJson()).to.equal(PUBLISHED.toJson()); + }); + it('reports a portal Ask AI already answers from, without waiting', async () => { upload.resolves(ok({ versionId: 'v-0', status: 'unchanged', pageCount: 2 })); diff --git a/test/infrastructure/zip-service.test.ts b/test/infrastructure/zip-service.test.ts index 959f18e31..992647ec8 100644 --- a/test/infrastructure/zip-service.test.ts +++ b/test/infrastructure/zip-service.test.ts @@ -80,6 +80,31 @@ describe('ZipService', () => { }); }); + describe('readEntry', () => { + it('reads one file of what archive packed, by its path inside', async () => { + write(path.join('__copilot', 'bundle.json'), '{"pages":[]}'); + write('index.html', ''); + (await new ZipService().archive(source, archive))._unsafeUnwrap(); + + expect((await new ZipService().readEntry(archive, '__copilot/bundle.json'))._unsafeUnwrap()).to.equal( + '{"pages":[]}' + ); + }); + + it('is null for a file the archive does not hold', async () => { + write('index.html', ''); + (await new ZipService().archive(source, archive))._unsafeUnwrap(); + + expect((await new ZipService().readEntry(archive, '__copilot/bundle.json'))._unsafeUnwrap()).to.be.null; + }); + + it('reports a file that is not an archive', async () => { + fs.writeFileSync(archive.toString(), 'not a zip'); + + expect((await new ZipService().readEntry(archive, 'index.html'))._unsafeUnwrapErr()).to.not.be.empty; + }); + }); + describe('unArchive', () => { it('expands what archive packed', async () => { write('openapi.json', '{}'); diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index dd2c1c417..0ca8a7325 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -4,6 +4,9 @@ import path from 'node:path'; import { expect } from 'chai'; import sinon from 'sinon'; import { FileService } from '../../../../src/infrastructure/file-service'; +import { ZipService } from '../../../../src/infrastructure/zip-service'; +import { FilePath } from '../../../../src/types/file/filePath'; +import { FileName } from '../../../../src/types/file/fileName'; import { DirectoryPath } from '../../../../src/types/file/directoryPath'; import { BundlePage, CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; import { @@ -108,6 +111,35 @@ describe('GeneratedPortalContext', () => { expect((await portal.bundle())._unsafeUnwrap()).to.be.null; }); + describe('of a portal saved as an archive', () => { + /** Saves what is in `built` as `portal generate --zip` does: as the portal's only file. */ + const zipPortal = async (built: string) => { + const archive = new FilePath(new DirectoryPath(root), new FileName('portal.zip')); + (await new ZipService().archive(new DirectoryPath(path.join(root, built)), archive))._unsafeUnwrap(); + fs.rmSync(path.join(root, built), { recursive: true }); + }; + + it('reads the bundle from inside the archive', async () => { + await new GeneratedPortalContext(new DirectoryPath(path.join(root, 'built'))).publish(BUNDLE); + await zipPortal('built'); + + expect((await portal.bundle())._unsafeUnwrap()!.toJson()).to.equal(BUNDLE.toJson()); + }); + + it('is null for an archive without one', async () => { + write('built/index.html', ''); + await zipPortal('built'); + + expect((await portal.bundle())._unsafeUnwrap()).to.be.null; + }); + + it('reports an archive it cannot read', async () => { + write('portal.zip', 'not a zip'); + + expect((await portal.bundle()).isErr()).to.be.true; + }); + }); + it('reports a bundle it cannot make out', async () => { write('__copilot/bundle.json', '{ "formatVersion": 2 }'); From cd79ed31a1a3631b6d504058dcc678b38fad6a93 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 09:22:20 +0500 Subject: [PATCH 25/41] test(portal): check the browser is told only the Ask AI settings, and no build paths The test said "and no more" but only checked that the key and the service reached the browser. It now compares the chat settings the browser imports as a whole, and checks that no published file of the Ask AI portal names the build machine's directories, through the helper the default portal's checks already used, moved to module level. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/e2e/portal-build.test.ts | 71 +++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 14560a104..a012e1001 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -170,6 +170,34 @@ async function typeCheck(project: DirectoryPath) { ); } +/** Published files naming one of these build-machine directories, in any spelling. */ +function publishedFilesNaming(output: DirectoryPath, ...directories: string[]): string[] { + // Three spellings of each: a bundler normalises separators either way, and a Windows + // path embedded in a string literal has its backslashes escaped. + const secrets = directories.flatMap((absolute) => { + const native = absolute.replace(/\//g, '\\'); + return [native, native.replace(/\\/g, '\\\\'), absolute.replace(/\\/g, '/')]; + }); + + const walk = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(directory, entry.name); + return entry.isDirectory() ? walk(child) : [child]; + }); + + return ( + walk(output.toString()) + // Images and fonts cannot carry a path the build put there, and reading them as text + // only invites a false match. + .filter((file) => !/\.(png|jpe?g|gif|svg|ico|woff2?)$/i.test(file)) + .filter((file) => { + const content = fs.readFileSync(file, 'utf8'); + return secrets.some((secret) => content.includes(secret)); + }) + .map((file) => path.relative(output.toString(), file)) + ); +} + /** The browser's scripts, by name. */ const scriptsOf = (output: DirectoryPath) => fs @@ -281,33 +309,7 @@ const stylesheetOf = (output: DirectoryPath) => { expect(offenders).to.deep.equal([]); }); - /** Published files naming one of these build-machine directories, in any spelling. */ - const filesNaming = (...directories: string[]) => { - // Three spellings of each: a bundler normalises separators either way, and a Windows - // path embedded in a string literal has its backslashes escaped. - const secrets = directories.flatMap((absolute) => { - const native = absolute.replace(/\//g, '\\'); - return [native, native.replace(/\\/g, '\\\\'), absolute.replace(/\\/g, '/')]; - }); - - const walk = (directory: string): string[] => - fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const child = path.join(directory, entry.name); - return entry.isDirectory() ? walk(child) : [child]; - }); - - return ( - walk(portal.output().toString()) - // Images and fonts cannot carry a path the build put there, and reading them as text - // only invites a false match. - .filter((file) => !/\.(png|jpe?g|gif|svg|ico|woff2?)$/i.test(file)) - .filter((file) => { - const content = fs.readFileSync(file, 'utf8'); - return secrets.some((secret) => content.includes(secret)); - }) - .map((file) => path.relative(portal.output().toString(), file)) - ); - }; + const filesNaming = (...directories: string[]) => publishedFilesNaming(portal.output(), ...directories); // The `.server` split exists so the build machine's filesystem stays out of what is // published: `portal.server.ts` holds the absolute path of every specification, and the @@ -1058,10 +1060,21 @@ const stylesheetOf = (output: DirectoryPath) => { } }); - it('tells the browser the public key and the service, and no more', () => { + it('tells the browser the public key, the service and the starter questions, and no more', () => { const scripts = scriptsOf(portal.output()); - + // The browser imports this file whole, so what it holds is what every visitor is told. + const { copilot } = JSON.parse( + fs.readFileSync(path.join(portal.project().toString(), 'portal.identity.json'), 'utf8') + ) as { copilot: unknown }; + + expect(copilot).to.deep.equal({ + key: 'pk_test_e2e', + apiUrl: 'https://ask.apimatic.io', + suggestions: ['How do I list pets?'] + }); expect(scripts.some((script) => script.text.includes('pk_test_e2e'))).to.be.true; expect(scripts.some((script) => script.text.includes('https://ask.apimatic.io'))).to.be.true; + const fixture = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/copilot/src'); + expect(publishedFilesNaming(portal.output(), portal.project().toString(), fixture.toString())).to.deep.equal([]); }); }); From bc672e670bbfe28ebc56891b87ce7d34c88e7fd5 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 09:33:59 +0500 Subject: [PATCH 26/41] fix(portal): accept only APIMatic's Ask AI service, or one on this machine, so sync never sends the login elsewhere `sync` sends the APIMatic login to the Ask AI service, but the `portal` block accepted any HTTPS address, and asked users to pass their account key with --auth-key for a service APIMatic does not run. The block, and the schema with it, now accept only `https://` on `apimatic.io` or one of its subdomains, or a service on this machine. The saved-login check this made unreachable goes, so `sync` uses the passed key or else the saved login, and the `auth login` hint on a refusal always applies. The schema now also agrees with the parser on an address in capitals and on one with credentials before its host. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 8 ++-- apimatic.schema.json | 4 +- src/actions/portal/copilot/sync.ts | 16 +++---- src/commands/portal/copilot/sync.ts | 4 +- src/prompts/portal/copilot/sync.ts | 11 +---- src/types/portal/config/copilot-config.ts | 19 ++++---- test/actions/portal/copilot/sync.test.ts | 36 ++++++--------- test/types/apimatic-config/schema.test.ts | 23 +++++++++- test/types/portal/portal-config.test.ts | 55 +++++++++++------------ 9 files changed, 88 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index ff6f60c32..6fcaa9129 100644 --- a/README.md +++ b/README.md @@ -312,11 +312,11 @@ DESCRIPTION Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as they are now. Run it after deploying a portal generated with 'apimatic portal generate'. - The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json'. The pages are swapped in - at once, after all of them are indexed; until then Ask AI answers from the previous ones. + The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json': APIMatic's Ask AI + service, or one on this machine. The pages are swapped in at once, after all of them are indexed; until then Ask AI + answers from the previous ones. - Your saved APIMatic login is sent only to an Ask AI service APIMatic runs, or to one on this machine. For any other - service, pass your auth key with --auth-key. + The upload is made with your saved APIMatic login, or with the auth key passed with --auth-key. EXAMPLES apimatic portal copilot sync diff --git a/apimatic.schema.json b/apimatic.schema.json index 69f92dd28..f9e327629 100644 --- a/apimatic.schema.json +++ b/apimatic.schema.json @@ -188,8 +188,8 @@ }, "apiUrl": { "type": "string", - "description": "The Ask AI service's address, for example 'https://ask.example.com'. Plain 'http://' is only accepted for a service on this machine, such as 'http://localhost:8787'.", - "pattern": "^([hH][tT][tT][pP][sS]://\\S+|[hH][tT][tT][pP]://(localhost|127\\.0\\.0\\.1|\\[::1\\])([:/]\\S*)?)$" + "description": "The address of APIMatic's Ask AI service, such as 'https://ask.apimatic.io', or of one on this machine while developing it, such as 'http://localhost:8787'.", + "pattern": "^([hH][tT][tT][pP][sS]://([^/?#@\\s]+\\.)?[aA][pP][iI][mM][aA][tT][iI][cC]\\.[iI][oO]|[hH][tT][tT][pP][sS]?://([lL][oO][cC][aA][lL][hH][oO][sS][tT]|127\\.0\\.0\\.1|\\[::1\\]))(:[0-9]+)?([/?#]\\S*)?$" }, "suggestions": { "type": "array", diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts index 1cdcbc669..039c94b94 100644 --- a/src/actions/portal/copilot/sync.ts +++ b/src/actions/portal/copilot/sync.ts @@ -3,7 +3,6 @@ import { CopilotIngestService } from '../../../infrastructure/services/copilot-i import { PortalCopilotSyncPrompts } from '../../../prompts/portal/copilot/sync.js'; import { CommandMetadata } from '../../../types/common/command-metadata.js'; import { DirectoryPath } from '../../../types/file/directoryPath.js'; -import { receivesSavedLogin } from '../../../types/portal/config/copilot-config.js'; import { PLACEHOLDER_SITE } from '../../../types/portal/config/site-config.js'; import { GeneratedPortalContext } from '../../../types/portal/copilot/generated-portal-context.js'; import { ProjectContext } from '../../../types/project-context.js'; @@ -37,7 +36,7 @@ export class PortalCopilotSyncAction { this.prompts.copilotNotConfigured(project.sourceDirectory()); return ActionResult.failed(); } - const authKey = await this.authKeyFor(copilot.apiUrl); + const authKey = await this.uploadKey(); if (authKey === null) { return ActionResult.failed(); } @@ -87,17 +86,16 @@ export class PortalCopilotSyncAction { return ActionResult.success(); }; - /** The only place that decides which key the service receives: the passed one, else the saved login if it may have it. */ - private async authKeyFor(apiUrl: string): Promise { - // A blank `--auth-key` (say, from an unset variable) is no key, so it never lets the saved login through. + /** + * The only place that decides which key the service receives: the passed one, else the saved login. The + * service is APIMatic's own, or one on this machine, as the `portal` block allows no other. + */ + private async uploadKey(): Promise { + // A blank `--auth-key` (say, from an unset variable) is no key, so it falls back to the saved login. const passed = this.authKey?.trim() ?? ''; if (passed !== '') { return passed; } - if (!receivesSavedLogin(apiUrl)) { - this.prompts.savedLoginWithheld(apiUrl); - return null; - } // `auth logout` blanks the stored key rather than deleting the file, so the key is checked, not the file. const saved = (await getAuthInfo(this.configDir.toString()))?.authKey; if (!saved) { diff --git a/src/commands/portal/copilot/sync.ts b/src/commands/portal/copilot/sync.ts index fda969d73..d8b022562 100644 --- a/src/commands/portal/copilot/sync.ts +++ b/src/commands/portal/copilot/sync.ts @@ -13,9 +13,9 @@ export default class PortalCopilotSync extends Command { static readonly description = `Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as they are now. Run it after deploying a portal generated with 'apimatic portal generate'. -The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json'. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones. +The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json': APIMatic's Ask AI service, or one on this machine. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones. -Your saved APIMatic login is sent only to an Ask AI service APIMatic runs, or to one on this machine. For any other service, pass your auth key with --auth-key.`; +The upload is made with your saved APIMatic login, or with the auth key passed with --auth-key.`; static readonly cmdTxt = format.cmd('apimatic', 'portal', 'copilot', 'sync'); diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index ab5a9553b..e5798aec0 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -11,7 +11,7 @@ import { format as f } from '../../format.js'; import { withSpinner } from '../../prompt.js'; import { reportSourceProblem } from '../source.js'; -const COPILOT_EXAMPLE = '"ai": { "copilot": { "key": "pk_โ€ฆ", "apiUrl": "https://โ€ฆ" } }'; +const COPILOT_EXAMPLE = '"ai": { "copilot": { "key": "pk_โ€ฆ", "apiUrl": "https://ask.apimatic.io" } }'; const pages = (count: number): string => `${count} ${count === 1 ? 'page' : 'pages'}`; @@ -28,15 +28,6 @@ export class PortalCopilotSyncPrompts { log.error(message); } - public savedLoginWithheld(apiUrl: string) { - const message = - `The Ask AI service at ${f.link( - apiUrl - )} is not run by APIMatic, so your saved APIMatic login is not sent to it. ` + - `If you trust this service, pass your auth key with ${f.flag('auth-key', '{api-key}')}.`; - log.error(message); - } - public notLoggedIn() { log.error(ServiceError.unauthorizedWithHint(null).errorMessage); } diff --git a/src/types/portal/config/copilot-config.ts b/src/types/portal/config/copilot-config.ts index 31c45df26..17f046bc0 100644 --- a/src/types/portal/config/copilot-config.ts +++ b/src/types/portal/config/copilot-config.ts @@ -57,20 +57,23 @@ function isLoopback(url: URL): boolean { return LOOPBACK_HOSTS.has(url.hostname); } -/** Whether sync may send the saved APIMatic login to the service: only APIMatic's own, or one on this machine. */ -export function receivesSavedLogin(apiUrl: string): boolean { - const url = new URL(apiUrl); - const { hostname } = url; - return isLoopback(url) || hostname === APIMATIC_DOMAIN || hostname.endsWith(`.${APIMATIC_DOMAIN}`); +function isApimatic(url: URL): boolean { + return ( + url.protocol === 'https:' && (url.hostname === APIMATIC_DOMAIN || url.hostname.endsWith(`.${APIMATIC_DOMAIN}`)) + ); } // Required while there is no hosted service; once there is, default to its address and keep this as an override. -// Plain HTTP would expose the keys in transit, so it is only accepted for a service on this machine. +// `sync` sends the APIMatic login to this address, so it is APIMatic's own service, over HTTPS, or one on this +// machine while developing it: no other service is offered, and none should be handed an APIMatic login. function serviceOrigin(value: unknown, path: string): Parsed { const url = typeof value === 'string' && isWebAddress(value) ? new URL(value) : null; - return url !== null && (url.protocol === 'https:' || isLoopback(url)) + return url !== null && (isApimatic(url) || isLoopback(url)) ? ok(url.origin) - : err([`'${path}' must be the Ask AI service's address, starting with 'https://' ('http://' only for localhost).`]); + : err([ + `'${path}' must be the address of APIMatic's Ask AI service, starting with 'https://' and ending in ` + + `'${APIMATIC_DOMAIN}', or of one on this machine, such as 'http://localhost:8787'.` + ]); } function suggestionList(value: unknown, path: string): Parsed { diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index ac15b3ea3..7998e5a84 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -28,7 +28,7 @@ const uploaded = (bundle: CopilotBundle) => }; const COMMAND_METADATA: CommandMetadata = { commandName: 'portal:copilot:sync', shell: 'test' }; -const COPILOT = { key: 'pk_test', apiUrl: 'https://copilot.test' }; +const COPILOT = { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' }; const indexed = (status: 'active' | 'superseded') => ({ versionId: 'v-1', @@ -190,38 +190,30 @@ describe('PortalCopilotSyncAction', () => { expect(prompts.indexingFailed.calledOnceWith(failed, COPILOT.apiUrl)).to.be.true; }); - it('keeps the saved login from a service APIMatic does not run, unless a key is passed', async () => { - const withheld = await execute(true, null); - - expect(withheld.isFailed()).to.be.true; - expect(prompts.savedLoginWithheld.calledOnceWith(COPILOT.apiUrl)).to.be.true; - expect(upload.called).to.be.false; - - expect((await execute()).isSuccess()).to.be.true; - }); - - it('treats a blank --auth-key as no key, so the saved login stays withheld', async () => { + it('refuses, without uploading, a service APIMatic does not run', async () => { writeSavedLogin('saved-key'); + writeConfig({ copilot: { ...COPILOT, apiUrl: 'https://ask.example.com' } }); - for (const blank of ['', ' ']) { - expect((await execute(true, blank)).isFailed(), JSON.stringify(blank)).to.be.true; - } - expect(prompts.savedLoginWithheld.callCount).to.equal(2); + const result = await execute(true, null); + + expect(result.isFailed()).to.be.true; + expect(prompts.sourceProblem.calledOnce).to.be.true; expect(upload.called).to.be.false; }); - it("uses the saved login with APIMatic's own service and one on this machine", async () => { + it('uses the passed key, else the saved login, a blank --auth-key counting as none', async () => { writeSavedLogin('saved-key'); for (const apiUrl of ['https://ask.apimatic.io', 'http://localhost:8787']) { writeConfig({ copilot: { ...COPILOT, apiUrl } }); - expect((await execute(true, null)).isSuccess(), apiUrl).to.be.true; - expect((await execute(true, '')).isSuccess(), apiUrl).to.be.true; + for (const passed of ['passed-key', null, '', ' ']) { + expect((await execute(true, passed)).isSuccess(), `${apiUrl} ${JSON.stringify(passed)}`).to.be.true; + } } - expect(upload.args.map((args) => args[3])).to.deep.equal(Array(4).fill('saved-key')); - expect(awaitIndexing.args.map((args) => args[3])).to.deep.equal(Array(4).fill('saved-key')); - expect(prompts.savedLoginWithheld.called).to.be.false; + const sent = ['passed-key', 'saved-key', 'saved-key', 'saved-key']; + expect(upload.args.map((args) => args[3])).to.deep.equal([...sent, ...sent]); + expect(awaitIndexing.args.map((args) => args[3])).to.deep.equal([...sent, ...sent]); }); it('asks the user to log in, without uploading, when no key is passed or saved', async () => { diff --git a/test/types/apimatic-config/schema.test.ts b/test/types/apimatic-config/schema.test.ts index cdcb6bb2e..f968fc36a 100644 --- a/test/types/apimatic-config/schema.test.ts +++ b/test/types/apimatic-config/schema.test.ts @@ -118,12 +118,31 @@ describe('apimatic.schema.json', () => { ['a link with two slashes in its query', { navigation: { links: [{ label: 'Next', url: '/start?next=//x' }] } }], ['a context plugin hosted elsewhere', { pluginUrl: 'https://plugins.acme.test/calc.zip?v=2' }], ['a plugin address with several query parameters', { pluginUrl: 'https://s3.test/calc.zip?a=1&b=2' }], - ['an Ask AI service over https', { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.example.com' } } }], + ["APIMatic's Ask AI service", { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } }], + [ + "APIMatic's Ask AI service, in capitals", + { ai: { copilot: { key: 'pk_test', apiUrl: 'HTTPS://ASK.APIMATIC.IO:8443' } } } + ], ['an Ask AI service on this machine', { ai: { copilot: { key: 'pk_test', apiUrl: 'http://localhost:8787' } } }] ]; const invalid: [string, object][] = [ - ['an Ask AI service over http', { ai: { copilot: { key: 'pk_test', apiUrl: 'http://ask.example.com' } } }], + [ + 'an Ask AI service APIMatic does not run', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.example.com' } } } + ], + [ + "APIMatic's Ask AI service over http", + { ai: { copilot: { key: 'pk_test', apiUrl: 'http://ask.apimatic.io' } } } + ], + [ + 'an Ask AI service on a host ending like APIMatic', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io.example.com' } } } + ], + [ + 'an Ask AI service on this machine in name only', + { ai: { copilot: { key: 'pk_test', apiUrl: 'http://localhost:@evil.example/' } } } + ], // Resolved through DNS on some systems, so it may not be this machine. [ 'an Ask AI service over http on a .localhost name', diff --git a/test/types/portal/portal-config.test.ts b/test/types/portal/portal-config.test.ts index 1308968be..d711197e6 100644 --- a/test/types/portal/portal-config.test.ts +++ b/test/types/portal/portal-config.test.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { PortalConfig } from '../../../src/types/portal/portal-config'; -import { receivesSavedLogin } from '../../../src/types/portal/config/copilot-config'; import { SuggestedSite } from '../../../src/types/portal/config/site-config'; describe('PortalConfig', () => { @@ -400,28 +399,28 @@ describe('PortalConfig', () => { it('offers the Ask AI chat only when the block sets one up', () => { expect(config({}).identity().copilot).to.be.null; - const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.example.com/v1' }; + const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io/v1' }; expect(config({ ai: { copilot } }).identity().copilot).to.deep.equal({ key: PUBLIC_KEY, - apiUrl: 'https://ask.example.com', + apiUrl: 'https://ask.apimatic.io', suggestions: [] }); expect(config({ ai: { copilot } }).toJSON().ai.copilot).to.deep.equal({ key: PUBLIC_KEY, - apiUrl: 'https://ask.example.com' + apiUrl: 'https://ask.apimatic.io' }); expect(config({}).toJSON().ai).to.not.have.property('copilot'); }); it('offers up to three starter questions, and writes them back only when set', () => { const suggestions = [' How do I authenticate? ', 'How do I create an order?']; - const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.example.com', suggestions }; + const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io', suggestions }; expect(config({ ai: { copilot } }).identity().copilot?.suggestions).to.deep.equal([ 'How do I authenticate?', 'How do I create an order?' ]); expect(config({ ai: { copilot } }).toJSON().ai.copilot?.suggestions).to.have.length(2); - const tooMany = { key: PUBLIC_KEY, apiUrl: 'https://ask.example.com', suggestions: ['a', 'b', 'c', 'd'] }; + const tooMany = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io', suggestions: ['a', 'b', 'c', 'd'] }; expect(errorsOf({ ai: { copilot: tooMany } })).to.deep.equal([ "'portal.ai.copilot.suggestions' must be a list of up to 3 questions, each a non-empty string of at most 120 characters." ]); @@ -430,7 +429,7 @@ describe('PortalConfig', () => { // The service validates the key and owns its design, so any non-empty key is passed through. it('accepts any key, whatever its layout', () => { - const copilot = { key: ' v2-key_Zx9 ', apiUrl: 'https://ask.example.com' }; + const copilot = { key: ' v2-key_Zx9 ', apiUrl: 'https://ask.apimatic.io' }; expect(config({ ai: { copilot } }).identity().copilot?.key).to.equal('v2-key_Zx9'); }); @@ -438,41 +437,39 @@ describe('PortalConfig', () => { expect(errorsOf({ ai: { copilot: { apiUrl: 'javascript:alert(1)', extra: true } } })).to.deep.equal([ "'portal.ai.copilot.extra' is not a 'portal' setting.", "'portal.ai.copilot.key' must be a non-empty string.", - "'portal.ai.copilot.apiUrl' must be the Ask AI service's address, starting with 'https://' ('http://' only for localhost)." + "'portal.ai.copilot.apiUrl' must be the address of APIMatic's Ask AI service, starting with 'https://' and ending in 'apimatic.io', or of one on this machine, such as 'http://localhost:8787'." ]); expect(errorsOf({ ai: { copilot: 'yes' } })).to.deep.equal(["'portal.ai.copilot' must be a JSON object."]); }); - // Plain HTTP would expose the keys in transit, except to a service on this machine. - it('accepts plain HTTP only for a service on this machine', () => { + // `sync` sends the APIMatic login there, so no service but APIMatic's, or one on this machine, is accepted. + it("accepts only APIMatic's own service, over HTTPS, or one on this machine", () => { const at = (apiUrl: string) => ({ ai: { copilot: { key: PUBLIC_KEY, apiUrl } } }); - for (const local of ['http://localhost:8787', 'http://127.0.0.1:8787', 'http://[::1]:8787']) { - expect(config(at(local)).identity().copilot?.apiUrl, local).to.equal(new URL(local).origin); - } - // `*.localhost` is resolved through DNS on some systems, so it may not be this machine. - for (const remote of ['http://ask.example.com', 'http://localhost.example.com', 'http://ask.localhost']) { - expect(errorsOf(at(remote)), remote).to.have.length(1); - } - }); - }); - - describe('receivesSavedLogin', () => { - it("trusts only APIMatic's own services and those on this machine with the saved login", () => { - for (const trusted of [ + for (const accepted of [ 'https://ask.apimatic.io', 'https://apimatic.io', + 'https://ask.staging.apimatic.io:8443', + 'HTTPS://ASK.APIMATIC.IO', 'http://localhost:8787', - 'http://[::1]' + 'https://localhost:8787', + 'http://127.0.0.1:8787', + 'http://[::1]:8787' ]) { - expect(receivesSavedLogin(trusted), trusted).to.be.true; + expect(config(at(accepted)).identity().copilot?.apiUrl, accepted).to.equal(new URL(accepted).origin); } - for (const other of [ + for (const refused of [ 'https://ask.example.com', - 'https://apimatic.io.example.com', + // Plain HTTP would expose the keys in transit. + 'http://ask.apimatic.io', + 'https://ask.apimatic.io.example.com', 'https://evilapimatic.io', - 'https://ask.localhost' + 'https://ask.apimatic.io@evil.example', + 'http://localhost:@evil.example/', + 'http://localhost.example.com', + // `*.localhost` is resolved through DNS on some systems, so it may not be this machine. + 'http://ask.localhost' ]) { - expect(receivesSavedLogin(other), other).to.be.false; + expect(errorsOf(at(refused)), refused).to.have.length(1); } }); }); From 5e9293b13f059eb09a4872614df11c61d95d67fa Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 09:57:59 +0500 Subject: [PATCH 27/41] fix(portal): send Ask AI the page a question is asked from without its query or fragment Fumadocs' chat put `location.href` in every question, so the service received each visitor's whole address, an OAuth callback's code among it. The chat now builds its questions through `toMessage`, naming the page by its origin and path only, with its title as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/components/copilot.tsx | 9 ++++++-- portal-template/src/lib/copilot-client.ts | 22 ++++++++++++++++++++ test/portal-template/copilot-client.test.ts | 23 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index 1110bb468..e433d14d7 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -6,7 +6,7 @@ import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport, type UIMessage } from 'ai'; import { AIChatPanel, AIChatProvider, AIChatSearch, AIChatSources, AIChatTrigger, useAIChat } from '@fumadocs/ai-chat'; import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; -import { CopilotClient } from '@/lib/copilot-client'; +import { CopilotClient, questionMessage } from '@/lib/copilot-client'; import type { PortalCopilot } from '@/lib/portal-types'; /** One part of a chat message: text, a tool call, a data part, and so on. */ @@ -49,7 +49,7 @@ function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilo const chat = useChat({ transport, onData: client.noteData }); const suggestions = copilot.suggestions.length > 0 ? copilot.suggestions : undefined; return ( - + {children} {available && } @@ -57,6 +57,11 @@ function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilo ); } +// The chat's own message would send the page's whole address, its query and fragment too. +function askFromThisPage(text: string) { + return questionMessage(text, { href: location.href, title: document.title }); +} + /** Opens the session as the panel opens, and keeps the panel shut once the service turns the portal away. */ function SessionOnOpen({ client, available }: Readonly<{ client: CopilotClient; available: boolean }>) { const { open, setOpen } = useAIChat(); diff --git a/portal-template/src/lib/copilot-client.ts b/portal-template/src/lib/copilot-client.ts index 4bf405186..8219fa599 100644 --- a/portal-template/src/lib/copilot-client.ts +++ b/portal-template/src/lib/copilot-client.ts @@ -10,6 +10,28 @@ interface Problem { message?: string; } +/** The page a visitor asks from, as the browser reports it. */ +export interface AskingPage { + href: string; + title: string; +} + +/** + * A visitor's question, with the page it was asked from, as the chat's own message would carry it. The + * address keeps only the page: its query and fragment can hold what is the visitor's alone, such as an + * OAuth callback's code, and the service needs neither. + */ +export function questionMessage(text: string, { href, title }: AskingPage) { + const page = new URL(href); + return { + role: 'user' as const, + parts: [ + { type: 'data-client' as const, data: { location: `${page.origin}${page.pathname}`, title } }, + { type: 'text' as const, text } + ] + }; +} + /** * The Ask AI service as the chat panel sees it. The public key opens a short session; questions * carry the session's token, never the key, and the service names the conversation's thread. diff --git a/test/portal-template/copilot-client.test.ts b/test/portal-template/copilot-client.test.ts index 5e8dfa6d6..682ac898d 100644 --- a/test/portal-template/copilot-client.test.ts +++ b/test/portal-template/copilot-client.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { CopilotClient } from '../../portal-template/src/lib/copilot-client'; +import { CopilotClient, questionMessage } from '../../portal-template/src/lib/copilot-client'; const COPILOT = { key: 'pk_live_abc', apiUrl: 'https://ask.example.com' }; @@ -154,3 +154,24 @@ describe('the Ask AI client', () => { expect(client.questionBody([{ id: 'q9' }], 'submit-message').threadId).to.be.null; }); }); + +describe("the Ask AI question's page", () => { + it('names the page a question is asked from by its address, without its query or fragment', () => { + const message = questionMessage('How do I log in?', { + href: 'https://docs.example.com/docs/oauth/callback?code=secret&state=xyz#token=abc', + title: 'Signing in' + }); + + expect(message).to.deep.equal({ + role: 'user', + parts: [ + { + type: 'data-client', + data: { location: 'https://docs.example.com/docs/oauth/callback', title: 'Signing in' } + }, + { type: 'text', text: 'How do I log in?' } + ] + }); + expect(JSON.stringify(message)).to.not.contain('secret').and.not.contain('abc'); + }); +}); From 987ef6f67c3c18ff1d3c316b1a57976f3f121f88 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 10:04:40 +0500 Subject: [PATCH 28/41] refactor(portal): hold the Ask AI service address as a value object, and refuse an address with a path The service address was parsed, kept as a string and handled as one through `sync`, the ingest service and the prompts, and a path such as `/v1` was dropped without a word. `ServiceAddress` now holds the rules in one place: APIMatic's own service over HTTPS, or one on this machine, as its origin alone, refusing a path, a query, a fragment or credentials. The settings keep it, `sync` passes it on, and it becomes a string only where it leaves the CLI. The schema refuses what the parser refuses. Co-Authored-By: Claude Opus 5.5 (1M context) --- apimatic.schema.json | 4 +- src/actions/portal/copilot/sync.ts | 6 +- .../services/copilot-ingest-service.ts | 6 +- src/prompts/portal/copilot/sync.ts | 15 ++-- src/types/portal/config/ai-config.ts | 7 +- src/types/portal/config/copilot-config.ts | 52 +++++--------- src/types/portal/config/service-address.ts | 50 +++++++++++++ src/types/portal/portal-config.ts | 7 +- test/actions/portal/copilot/sync.test.ts | 14 ++-- .../services/copilot-ingest-service.test.ts | 8 ++- test/prompts/portal/copilot/sync.test.ts | 16 +++-- test/types/apimatic-config/schema.test.ts | 16 +++++ .../portal/config/service-address.test.ts | 71 +++++++++++++++++++ test/types/portal/portal-config.test.ts | 2 +- 14 files changed, 209 insertions(+), 65 deletions(-) create mode 100644 src/types/portal/config/service-address.ts create mode 100644 test/types/portal/config/service-address.test.ts diff --git a/apimatic.schema.json b/apimatic.schema.json index f9e327629..acf41f10a 100644 --- a/apimatic.schema.json +++ b/apimatic.schema.json @@ -188,8 +188,8 @@ }, "apiUrl": { "type": "string", - "description": "The address of APIMatic's Ask AI service, such as 'https://ask.apimatic.io', or of one on this machine while developing it, such as 'http://localhost:8787'.", - "pattern": "^([hH][tT][tT][pP][sS]://([^/?#@\\s]+\\.)?[aA][pP][iI][mM][aA][tT][iI][cC]\\.[iI][oO]|[hH][tT][tT][pP][sS]?://([lL][oO][cC][aA][lL][hH][oO][sS][tT]|127\\.0\\.0\\.1|\\[::1\\]))(:[0-9]+)?([/?#]\\S*)?$" + "description": "The address of APIMatic's Ask AI service, such as 'https://ask.apimatic.io', or of one on this machine while developing it, such as 'http://localhost:8787'. The address alone, without a path.", + "pattern": "^([hH][tT][tT][pP][sS]://([^/?#@\\s]+\\.)?[aA][pP][iI][mM][aA][tT][iI][cC]\\.[iI][oO]|[hH][tT][tT][pP][sS]?://([lL][oO][cC][aA][lL][hH][oO][sS][tT]|127\\.0\\.0\\.1|\\[::1\\]))(:[0-9]+)?/?$" }, "suggestions": { "type": "array", diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts index 039c94b94..3086fc94b 100644 --- a/src/actions/portal/copilot/sync.ts +++ b/src/actions/portal/copilot/sync.ts @@ -31,7 +31,7 @@ export class PortalCopilotSyncAction { } const { config } = settings.value; - const copilot = config.identity().copilot; + const copilot = config.copilotService(); if (copilot === null) { this.prompts.copilotNotConfigured(project.sourceDirectory()); return ActionResult.failed(); @@ -59,7 +59,7 @@ export class PortalCopilotSyncAction { bundle.pageCount() ); if (receipt.isErr()) { - this.prompts.uploadFailed(receipt.error, copilot.apiUrl); + this.prompts.uploadFailed(receipt.error, copilot.address); return ActionResult.failed(); } if (receipt.value.status === 'unchanged') { @@ -75,7 +75,7 @@ export class PortalCopilotSyncAction { this.ingestService.awaitIndexing(copilot, receipt.value.versionId, shell, authKey) ); if (indexed.isErr()) { - this.prompts.indexingFailed(indexed.error, copilot.apiUrl); + this.prompts.indexingFailed(indexed.error, copilot.address); return ActionResult.failed(); } if (indexed.value.status === 'superseded') { diff --git a/src/infrastructure/services/copilot-ingest-service.ts b/src/infrastructure/services/copilot-ingest-service.ts index 932220f82..6527c26d8 100644 --- a/src/infrastructure/services/copilot-ingest-service.ts +++ b/src/infrastructure/services/copilot-ingest-service.ts @@ -1,6 +1,6 @@ import axios, { AxiosResponse } from 'axios'; import { err, ok, Result } from 'neverthrow'; -import { PortalCopilot } from '../../types/portal/config/copilot-config.js'; +import { CopilotService } from '../../types/portal/config/copilot-config.js'; import { CopilotBundle } from '../../types/portal/copilot/copilot-bundle.js'; import { IndexedVersion, IndexingState, IngestReceipt, IngestStatus } from '../../types/portal/copilot/ingest.js'; import { isJsonObject } from '../../utils/json-utils.js'; @@ -18,7 +18,7 @@ export const COPILOT_INGEST_TIMINGS = { export type CopilotIngestTimings = Partial; /** All the service needs of the portal's Ask AI settings: where it is, and which portal's chat it is. */ -export type CopilotTarget = Pick; +export type CopilotTarget = CopilotService; const STATES = new Set(['queued', 'building', 'active', 'superseded', 'failed']); @@ -101,7 +101,7 @@ export class CopilotIngestService { private axiosInstance(copilot: CopilotTarget, shell: string, token: string, timeout: number) { return axios.create({ - baseURL: copilot.apiUrl, + baseURL: copilot.address.toString(), timeout, validateStatus: () => true, // A request that runs out of time then fails as ETIMEDOUT, apart from one that never got through. diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index e5798aec0..f39b6a8a9 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -3,6 +3,7 @@ import { Result } from 'neverthrow'; import { ServiceError, ServiceErrorCode } from '../../../infrastructure/service-error.js'; import { APIMATIC_CONFIG_FILE_NAME } from '../../../types/apimatic-config/document.js'; import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { ServiceAddress } from '../../../types/portal/config/service-address.js'; import { FileName } from '../../../types/file/fileName.js'; import { FilePath } from '../../../types/file/filePath.js'; import { IndexedVersion, IngestReceipt } from '../../../types/portal/copilot/ingest.js'; @@ -62,28 +63,30 @@ export class PortalCopilotSyncPrompts { ); } - public uploadFailed(error: ServiceError, apiUrl: string) { + public uploadFailed(error: ServiceError, service: ServiceAddress) { this.syncFailed( error, - apiUrl, + service, `${error.errorMessage} The service may have received the pages all the same; sync again, and it answers ` + `that nothing has changed if it did.` ); } - public indexingFailed(error: ServiceError, apiUrl: string) { + public indexingFailed(error: ServiceError, service: ServiceAddress) { this.syncFailed( error, - apiUrl, + service, `${error.errorMessage} The service keeps indexing, and Ask AI answers from the previous pages until it ` + `is done; check the portal's Ask AI again in a few minutes.` ); } - private syncFailed(error: ServiceError, apiUrl: string, timedOut: string) { + private syncFailed(error: ServiceError, service: ServiceAddress, timedOut: string) { switch (error.code) { case ServiceErrorCode.NetworkError: - log.error(`The Ask AI service at ${f.link(apiUrl)} could not be reached. Check the address and try again.`); + log.error( + `The Ask AI service at ${f.link(`${service}`)} could not be reached. Check the address and try again.` + ); return; case ServiceErrorCode.Forbidden: log.error( diff --git a/src/types/portal/config/ai-config.ts b/src/types/portal/config/ai-config.ts index e76db938e..c0e3d00e0 100644 --- a/src/types/portal/config/ai-config.ts +++ b/src/types/portal/config/ai-config.ts @@ -1,5 +1,5 @@ import { Result } from 'neverthrow'; -import { CopilotConfig, PortalCopilot } from './copilot-config.js'; +import { CopilotConfig, CopilotService, PortalCopilot } from './copilot-config.js'; import { allOf, namespace, optional, optionalBoolean, Parsed, unknownKeys } from './fields.js'; const KNOWN = ['pageActions', 'copilot']; @@ -34,6 +34,11 @@ export class AiConfig { return this.copilotSettings?.identity() ?? null; } + /** The service the Ask AI chat is on, or null when the portal offers none. */ + public copilotService(): CopilotService | null { + return this.copilotSettings?.service() ?? null; + } + public toJSON(): { pageActions: boolean; copilot?: ReturnType } { return { pageActions: this.pageActions, diff --git a/src/types/portal/config/copilot-config.ts b/src/types/portal/config/copilot-config.ts index 17f046bc0..d7e3f0d36 100644 --- a/src/types/portal/config/copilot-config.ts +++ b/src/types/portal/config/copilot-config.ts @@ -1,5 +1,6 @@ import { err, ok, Result } from 'neverthrow'; -import { allOf, isWebAddress, namespace, nonEmptyString, optional, Parsed, unknownKeys } from './fields.js'; +import { allOf, namespace, nonEmptyString, optional, Parsed, unknownKeys } from './fields.js'; +import { ServiceAddress } from './service-address.js'; const KNOWN = ['key', 'apiUrl', 'suggestions']; const MAX_SUGGESTIONS = 3; @@ -15,10 +16,16 @@ export interface PortalCopilot { type CopilotBlock = Omit & { suggestions?: string[] }; +/** What `sync` needs of the Ask AI settings: which portal's chat it is, and the service it is on. */ +export interface CopilotService { + key: string; + address: ServiceAddress; +} + export class CopilotConfig { private constructor( private readonly key: string, - private readonly apiOrigin: string, + private readonly address: ServiceAddress, private readonly suggestions: string[] ) {} @@ -29,53 +36,32 @@ export class CopilotConfig { // The key is not checked here: the Ask AI service validates it, and its design may change. Result.combineWithAllErrors([ nonEmptyString(data.key, `${path}.key`), - serviceOrigin(data.apiUrl, `${path}.apiUrl`), + // Required while there is no hosted service; once there is, default to its address and keep this as an override. + ServiceAddress.parse(data.apiUrl, `${path}.apiUrl`), optional(data.suggestions, (present) => suggestionList(present, `${path}.suggestions`)) ]) - ).map(([key, apiOrigin, suggestions]) => new CopilotConfig(key, apiOrigin, suggestions ?? [])) + ).map(([key, address, suggestions]) => new CopilotConfig(key, address, suggestions ?? [])) ); } + /** As the browser is told it. */ public identity(): PortalCopilot { - return { key: this.key, apiUrl: this.apiOrigin, suggestions: this.suggestions }; + return { key: this.key, apiUrl: this.address.toString(), suggestions: this.suggestions }; + } + + public service(): CopilotService { + return { key: this.key, address: this.address }; } public toJSON(): CopilotBlock { return { key: this.key, - apiUrl: this.apiOrigin, + apiUrl: this.address.toString(), ...(this.suggestions.length > 0 ? { suggestions: this.suggestions } : {}) }; } } -// Only names that never leave the machine: a `*.localhost` name is resolved through DNS on some systems, Windows' Node among them. -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); -const APIMATIC_DOMAIN = 'apimatic.io'; - -function isLoopback(url: URL): boolean { - return LOOPBACK_HOSTS.has(url.hostname); -} - -function isApimatic(url: URL): boolean { - return ( - url.protocol === 'https:' && (url.hostname === APIMATIC_DOMAIN || url.hostname.endsWith(`.${APIMATIC_DOMAIN}`)) - ); -} - -// Required while there is no hosted service; once there is, default to its address and keep this as an override. -// `sync` sends the APIMatic login to this address, so it is APIMatic's own service, over HTTPS, or one on this -// machine while developing it: no other service is offered, and none should be handed an APIMatic login. -function serviceOrigin(value: unknown, path: string): Parsed { - const url = typeof value === 'string' && isWebAddress(value) ? new URL(value) : null; - return url !== null && (isApimatic(url) || isLoopback(url)) - ? ok(url.origin) - : err([ - `'${path}' must be the address of APIMatic's Ask AI service, starting with 'https://' and ending in ` + - `'${APIMATIC_DOMAIN}', or of one on this machine, such as 'http://localhost:8787'.` - ]); -} - function suggestionList(value: unknown, path: string): Parsed { const valid = Array.isArray(value) && diff --git a/src/types/portal/config/service-address.ts b/src/types/portal/config/service-address.ts new file mode 100644 index 000000000..935a3c9df --- /dev/null +++ b/src/types/portal/config/service-address.ts @@ -0,0 +1,50 @@ +import { err, ok } from 'neverthrow'; +import { isWebAddress, Parsed } from './fields.js'; + +const APIMATIC_DOMAIN = 'apimatic.io'; + +// Only names that never leave the machine: a `*.localhost` name is resolved through DNS on some systems, Windows' Node among them. +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Where the Ask AI service is: APIMatic's own, over HTTPS, or one on this machine while developing it. `sync` + * sends the APIMatic login there, so no other service is accepted. It is an origin alone: the CLI and the chat + * add the service's own paths, such as `/v1/chat`. + */ +export class ServiceAddress { + private constructor(private readonly origin: string) {} + + public static parse(value: unknown, path: string): Parsed { + const text = typeof value === 'string' ? value : ''; + const url = isWebAddress(text) ? new URL(text) : null; + if (url === null || !(isApimatic(url) || isLoopback(url))) { + return err([ + `'${path}' must be the address of APIMatic's Ask AI service, starting with 'https://' and ending in ` + + `'${APIMATIC_DOMAIN}', or of one on this machine, such as 'http://localhost:8787'.` + ]); + } + // Checked as written too: the URL parser drops an empty query or fragment. + if (url.username !== '' || url.password !== '' || url.pathname !== '/' || /[?#]/.test(text)) { + return err([ + `'${path}' must be the service's address alone, without a path, query or credentials, ` + + `for example 'https://ask.apimatic.io'.` + ]); + } + return ok(new ServiceAddress(url.origin)); + } + + /** The service's origin, such as `https://ask.apimatic.io`. */ + public toString(): string { + return this.origin; + } +} + +function isApimatic(url: URL): boolean { + return ( + url.protocol === 'https:' && (url.hostname === APIMATIC_DOMAIN || url.hostname.endsWith(`.${APIMATIC_DOMAIN}`)) + ); +} + +function isLoopback(url: URL): boolean { + return LOOPBACK_HOSTS.has(url.hostname); +} diff --git a/src/types/portal/portal-config.ts b/src/types/portal/portal-config.ts index fa4ac7876..be73a9ab3 100644 --- a/src/types/portal/portal-config.ts +++ b/src/types/portal/portal-config.ts @@ -2,7 +2,7 @@ import { err, ok, Result } from 'neverthrow'; import { isJsonObject } from '../../utils/json-utils.js'; import { UrlPath } from '../file/urlPath.js'; import { AiConfig } from './config/ai-config.js'; -import { PortalCopilot } from './config/copilot-config.js'; +import { CopilotService, PortalCopilot } from './config/copilot-config.js'; import { BrandConfig, ColorMode } from './config/brand-config.js'; import { allOf, optional, Parsed, unknownKeys } from './config/fields.js'; import { Link } from './config/link.js'; @@ -108,6 +108,11 @@ export class PortalConfig { return this.pluginAddress; } + /** The service the Ask AI chat is on, or null when the portal offers none. */ + public copilotService(): CopilotService | null { + return this.ai.copilotService(); + } + public brandSettings(): BrandConfig { return this.brand; } diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts index 7998e5a84..0cabbbd1d 100644 --- a/test/actions/portal/copilot/sync.test.ts +++ b/test/actions/portal/copilot/sync.test.ts @@ -6,7 +6,7 @@ import { expect } from 'chai'; import { gunzipSync } from 'node:zlib'; import { err, ok } from 'neverthrow'; import { PortalCopilotSyncAction } from '../../../../src/actions/portal/copilot/sync'; -import { CopilotIngestService } from '../../../../src/infrastructure/services/copilot-ingest-service'; +import { CopilotIngestService, CopilotTarget } from '../../../../src/infrastructure/services/copilot-ingest-service'; import { ServiceError, ServiceErrorCode } from '../../../../src/infrastructure/service-error'; import { PortalCopilotSyncPrompts } from '../../../../src/prompts/portal/copilot/sync'; import { CommandMetadata } from '../../../../src/types/common/command-metadata'; @@ -30,6 +30,9 @@ const uploaded = (bundle: CopilotBundle) => const COMMAND_METADATA: CommandMetadata = { commandName: 'portal:copilot:sync', shell: 'test' }; const COPILOT = { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' }; +/** The address of the service the portal's settings name. */ +const theService = sinon.match((address: unknown) => `${address as string}` === COPILOT.apiUrl, COPILOT.apiUrl); + const indexed = (status: 'active' | 'superseded') => ({ versionId: 'v-1', status, @@ -111,8 +114,9 @@ describe('PortalCopilotSyncAction', () => { const result = await execute(); expect(result.isSuccess()).to.be.true; - const [copilot, bundle, , authKey] = upload.firstCall.args as [unknown, CopilotBundle, unknown, string]; - expect(copilot).to.deep.include(COPILOT); + const [copilot, bundle, , authKey] = upload.firstCall.args as [CopilotTarget, CopilotBundle, unknown, string]; + expect(copilot.key).to.equal(COPILOT.key); + expect(`${copilot.address}`).to.equal(COPILOT.apiUrl); expect(authKey).to.equal('auth-key'); expect(bundle.toJson()).to.equal(PUBLISHED.toJson()); expect(uploaded(bundle)).to.deep.include({ basePath: '/docs/', siteUrl: 'https://example.com/docs' }); @@ -168,7 +172,7 @@ describe('PortalCopilotSyncAction', () => { const result = await execute(); expect(result.isFailed()).to.be.true; - expect(prompts.uploadFailed.calledOnceWith(forbidden, COPILOT.apiUrl)).to.be.true; + expect(prompts.uploadFailed.calledOnceWith(forbidden, theService)).to.be.true; }); it('fails when the service cannot be reached', async () => { @@ -187,7 +191,7 @@ describe('PortalCopilotSyncAction', () => { const result = await execute(); expect(result.isFailed()).to.be.true; - expect(prompts.indexingFailed.calledOnceWith(failed, COPILOT.apiUrl)).to.be.true; + expect(prompts.indexingFailed.calledOnceWith(failed, theService)).to.be.true; }); it('refuses, without uploading, a service APIMatic does not run', async () => { diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts index 887b4b96c..cf702528a 100644 --- a/test/infrastructure/services/copilot-ingest-service.test.ts +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -6,9 +6,11 @@ import { expect } from 'chai'; import { CopilotIngestService } from '../../../src/infrastructure/services/copilot-ingest-service'; import { ServiceErrorCode } from '../../../src/infrastructure/service-error'; import { CopilotBundle } from '../../../src/types/portal/copilot/copilot-bundle'; +import { ServiceAddress } from '../../../src/types/portal/config/service-address'; -const API_URL = 'http://copilot.test'; -const COPILOT = { key: 'pk_test', apiUrl: API_URL }; +const API_URL = 'http://localhost:8787'; +const at = (url: string) => ServiceAddress.parse(url, 'apiUrl')._unsafeUnwrap(); +const COPILOT = { key: 'pk_test', address: at(API_URL) }; const VERSION_ID = 'v-1'; const bundle = CopilotBundle.of( @@ -89,7 +91,7 @@ describe('CopilotIngestService', () => { }); it('reports a service that cannot be reached as a network error', async () => { - const unreachable = { ...COPILOT, apiUrl: `http://127.0.0.1:${await closedPort()}` }; + const unreachable = { ...COPILOT, address: at(`http://127.0.0.1:${await closedPort()}`) }; const result = await service.upload(unreachable, bundle, 'bash', 'auth-key'); diff --git a/test/prompts/portal/copilot/sync.test.ts b/test/prompts/portal/copilot/sync.test.ts index 2b384c83f..6bda2893f 100644 --- a/test/prompts/portal/copilot/sync.test.ts +++ b/test/prompts/portal/copilot/sync.test.ts @@ -4,9 +4,11 @@ import sinon from 'sinon'; import { log } from '@clack/prompts'; import { ServiceError } from '../../../../src/infrastructure/service-error.js'; import { PortalCopilotSyncPrompts } from '../../../../src/prompts/portal/copilot/sync.js'; +import { ServiceAddress } from '../../../../src/types/portal/config/service-address.js'; describe('PortalCopilotSyncPrompts', () => { - const API_URL = 'https://ask.example.com'; + const API_URL = 'https://ask.apimatic.io'; + const SERVICE = ServiceAddress.parse(API_URL, 'apiUrl')._unsafeUnwrap(); const prompts = new PortalCopilotSyncPrompts(); let error: sinon.SinonStub; @@ -22,7 +24,7 @@ describe('PortalCopilotSyncPrompts', () => { }); it('tells a timed-out upload to sync again, rather than to check the address', () => { - prompts.uploadFailed(ServiceError.timeout('The upload did not finish within 2 minutes.'), API_URL); + prompts.uploadFailed(ServiceError.timeout('The upload did not finish within 2 minutes.'), SERVICE); const [message] = printed(); expect(message).to.contain('The upload did not finish within 2 minutes.'); @@ -32,7 +34,7 @@ describe('PortalCopilotSyncPrompts', () => { }); it('tells timed-out indexing that the service keeps indexing', () => { - prompts.indexingFailed(ServiceError.timeout('Indexing did not finish within 10 minutes.'), API_URL); + prompts.indexingFailed(ServiceError.timeout('Indexing did not finish within 10 minutes.'), SERVICE); const [message] = printed(); expect(message).to.contain('Indexing did not finish within 10 minutes.'); @@ -40,8 +42,8 @@ describe('PortalCopilotSyncPrompts', () => { }); it("words failed indexing itself, with the service's reason when it gives one", () => { - prompts.indexingFailed(ServiceError.processingFailed('Embedding provider unavailable.'), API_URL); - prompts.indexingFailed(ServiceError.processingFailed(null), API_URL); + prompts.indexingFailed(ServiceError.processingFailed('Embedding provider unavailable.'), SERVICE); + prompts.indexingFailed(ServiceError.processingFailed(null), SERVICE); expect(printed()).to.deep.equal([ 'The Ask AI service could not index the pages: Embedding provider unavailable. ' + @@ -51,8 +53,8 @@ describe('PortalCopilotSyncPrompts', () => { }); it('tells either step that a service it could not reach may be at the wrong address', () => { - prompts.uploadFailed(ServiceError.NetworkError, API_URL); - prompts.indexingFailed(ServiceError.NetworkError, API_URL); + prompts.uploadFailed(ServiceError.NetworkError, SERVICE); + prompts.indexingFailed(ServiceError.NetworkError, SERVICE); for (const message of printed()) { expect(message).to.contain(`The Ask AI service at ${API_URL} could not be reached`); diff --git a/test/types/apimatic-config/schema.test.ts b/test/types/apimatic-config/schema.test.ts index f968fc36a..03d4d3193 100644 --- a/test/types/apimatic-config/schema.test.ts +++ b/test/types/apimatic-config/schema.test.ts @@ -123,10 +123,26 @@ describe('apimatic.schema.json', () => { "APIMatic's Ask AI service, in capitals", { ai: { copilot: { key: 'pk_test', apiUrl: 'HTTPS://ASK.APIMATIC.IO:8443' } } } ], + [ + "APIMatic's Ask AI service, with a trailing slash", + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io/' } } } + ], ['an Ask AI service on this machine', { ai: { copilot: { key: 'pk_test', apiUrl: 'http://localhost:8787' } } }] ]; const invalid: [string, object][] = [ + [ + 'an Ask AI service address with a path', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io/v1' } } } + ], + [ + 'an Ask AI service address with a query', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io/?region=eu' } } } + ], + [ + 'an Ask AI service address with credentials', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://user@ask.apimatic.io' } } } + ], [ 'an Ask AI service APIMatic does not run', { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.example.com' } } } diff --git a/test/types/portal/config/service-address.test.ts b/test/types/portal/config/service-address.test.ts new file mode 100644 index 000000000..16fa6123f --- /dev/null +++ b/test/types/portal/config/service-address.test.ts @@ -0,0 +1,71 @@ +import { expect } from 'chai'; +import { ServiceAddress } from '../../../../src/types/portal/config/service-address'; + +describe('ServiceAddress', () => { + const SETTING = 'portal.ai.copilot.apiUrl'; + + const address = (value: unknown) => ServiceAddress.parse(value, SETTING)._unsafeUnwrap(); + const refusal = (value: unknown) => ServiceAddress.parse(value, SETTING)._unsafeUnwrapErr(); + + const NOT_APIMATIC = + "'portal.ai.copilot.apiUrl' must be the address of APIMatic's Ask AI service, starting with 'https://' and " + + "ending in 'apimatic.io', or of one on this machine, such as 'http://localhost:8787'."; + const NOT_ALONE = + "'portal.ai.copilot.apiUrl' must be the service's address alone, without a path, query or credentials, " + + "for example 'https://ask.apimatic.io'."; + + describe('parse', () => { + it("reads APIMatic's own service, over HTTPS, or one on this machine, as its origin", () => { + const cases: [string, string][] = [ + ['https://ask.apimatic.io', 'https://ask.apimatic.io'], + ['https://ask.apimatic.io/', 'https://ask.apimatic.io'], + ['https://apimatic.io', 'https://apimatic.io'], + ['https://ask.staging.apimatic.io:8443', 'https://ask.staging.apimatic.io:8443'], + ['HTTPS://ASK.APIMATIC.IO', 'https://ask.apimatic.io'], + ['http://localhost:8787', 'http://localhost:8787'], + ['https://localhost:8787/', 'https://localhost:8787'], + ['http://127.0.0.1:8787', 'http://127.0.0.1:8787'], + ['http://[::1]:8787', 'http://[::1]:8787'] + ]; + for (const [value, origin] of cases) { + expect(address(value).toString(), value).to.equal(origin); + } + }); + + // `sync` sends the APIMatic login there, so no service but APIMatic's, or one on this machine, is accepted. + it('refuses any other service, or APIMatic over plain HTTP', () => { + for (const value of [ + 'https://ask.example.com', + 'http://ask.apimatic.io', + 'https://ask.apimatic.io.example.com', + 'https://evilapimatic.io', + 'https://ask.apimatic.io@evil.example', + 'http://localhost:@evil.example/', + 'http://localhost.example.com', + // `*.localhost` is resolved through DNS on some systems, so it may not be this machine. + 'http://ask.localhost', + 'javascript:alert(1)', + '', + 7 + ]) { + expect(refusal(value), String(value)).to.deep.equal([NOT_APIMATIC]); + } + }); + + // The CLI and the chat add the service's own paths, so a path of the address's own would be dropped or doubled. + it('refuses an address with a path, a query, a fragment or credentials', () => { + for (const value of [ + 'https://ask.apimatic.io/v1', + 'https://ask.apimatic.io\\v1', + 'https://ask.apimatic.io/?', + 'https://ask.apimatic.io/?region=eu', + 'https://ask.apimatic.io#', + 'https://user@ask.apimatic.io', + 'https://user:secret@ask.apimatic.io', + 'http://localhost:8787/api' + ]) { + expect(refusal(value), value).to.deep.equal([NOT_ALONE]); + } + }); + }); +}); diff --git a/test/types/portal/portal-config.test.ts b/test/types/portal/portal-config.test.ts index d711197e6..ed1a0bb87 100644 --- a/test/types/portal/portal-config.test.ts +++ b/test/types/portal/portal-config.test.ts @@ -399,7 +399,7 @@ describe('PortalConfig', () => { it('offers the Ask AI chat only when the block sets one up', () => { expect(config({}).identity().copilot).to.be.null; - const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io/v1' }; + const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io/' }; expect(config({ ai: { copilot } }).identity().copilot).to.deep.equal({ key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io', From be64acdbb004f6829f2d7daa2edf2e40729bb0b1 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 10:19:48 +0500 Subject: [PATCH 29/41] fix(portal): tell a portal without site.url to sync, and have generate and sync --help give the same advice `generate` said Ask AI picks up the pages by itself, while `sync --help` said to run it after every deploy, and a portal without `site.url` was told the same, though the service cannot find it. `generate` now names the address the service fetches the pages from, or, without one, says to deploy and run `sync`; `sync --help` says when it is needed: for a portal without `site.url`, or one the service cannot reach. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 +++++- src/actions/portal/generate.ts | 2 +- src/commands/portal/copilot/sync.ts | 4 +++- src/prompts/portal/generate.ts | 17 ++++++++++++--- test/actions/portal/generate.test.ts | 3 ++- test/prompts/portal/generate.test.ts | 31 ++++++++++++++++++++++++++++ 6 files changed, 56 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6fcaa9129..1b0c25230 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,11 @@ DESCRIPTION Update the portal's Ask AI chat with the portal's current pages. Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as - they are now. Run it after deploying a portal generated with 'apimatic portal generate'. + they are now. + + The service picks up the pages by itself from a portal deployed at its 'site.url'. Run this command after deploying a + portal generated with 'apimatic portal generate' when it cannot: for a portal without 'site.url', or one the service + cannot reach, such as one on a private network. The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json': APIMatic's Ask AI service, or one on this machine. The pages are swapped in at once, after all of them are indexed; until then Ask AI diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 4683ed454..57487424e 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -92,7 +92,7 @@ export class GenerateAction { this.prompts.portalGenerated(portalDirectory); this.prompts.nextSteps(portalDirectory, zipPortal, site); - if (copilotPublished) this.prompts.copilotUpdatesAfterDeploy(); + if (copilotPublished) this.prompts.copilotUpdatesAfterDeploy(site); return ActionResult.success(); } diff --git a/src/commands/portal/copilot/sync.ts b/src/commands/portal/copilot/sync.ts index d8b022562..d6eaf701f 100644 --- a/src/commands/portal/copilot/sync.ts +++ b/src/commands/portal/copilot/sync.ts @@ -11,7 +11,9 @@ import { ProjectContext } from '../../../types/project-context.js'; export default class PortalCopilotSync extends Command { static readonly summary = "Update the portal's Ask AI chat with the portal's current pages."; - static readonly description = `Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as they are now. Run it after deploying a portal generated with 'apimatic portal generate'. + static readonly description = `Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as they are now. + +The service picks up the pages by itself from a portal deployed at its 'site.url'. Run this command after deploying a portal generated with 'apimatic portal generate' when it cannot: for a portal without 'site.url', or one the service cannot reach, such as one on a private network. The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json': APIMatic's Ask AI service, or one on this machine. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones. diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 95ed71131..0b315814d 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -114,10 +114,21 @@ export class PortalGeneratePrompts { noteWrapped(message, 'Next steps'); } - public copilotUpdatesAfterDeploy() { + /** + * The service fetches a deployed portal's pages from the portal's address, so only a portal with one + * updates Ask AI by itself; `sync` is the way for the rest. + */ + public copilotUpdatesAfterDeploy(site: SiteAddress | null) { + if (site === null) { + log.info( + `Deploy the portal, then run ${syncCommand()} so Ask AI answers from these pages: without ` + + `${f.var('site.url')}, the Ask AI service cannot find the portal by itself.` + ); + return; + } log.info( - 'Ask AI picks up these pages by itself once the portal is deployed. ' + - `If the Ask AI service cannot reach the portal, run ${syncCommand()} after deploying instead.` + `Ask AI picks up these pages by itself once the portal is deployed at ${f.link(`${site}`)}. ` + + `If the Ask AI service cannot reach it there, run ${syncCommand()} after deploying instead.` ); } diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index 2a0409cf1..279c45074 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -271,7 +271,8 @@ describe('GenerateAction', () => { const manifest = JSON.parse(readPortal('__copilot/build.json')) as { formatVersion: number; buildId: string }; expect(manifest.formatVersion).to.equal(1); expect(manifest.buildId).to.match(/^[0-9a-f]{64}$/); - expect(prompts.copilotUpdatesAfterDeploy.calledOnce).to.be.true; + // The fixture gives no `site.url`, so the service cannot find the portal by itself. + expect(prompts.copilotUpdatesAfterDeploy.calledOnceWith(null)).to.be.true; }); it('puts the pages in the archive when the portal is zipped', async () => { diff --git a/test/prompts/portal/generate.test.ts b/test/prompts/portal/generate.test.ts index 93a216c14..bbecbf3b1 100644 --- a/test/prompts/portal/generate.test.ts +++ b/test/prompts/portal/generate.test.ts @@ -1,11 +1,42 @@ import { stripVTControlCharacters } from 'node:util'; import { expect } from 'chai'; import sinon from 'sinon'; +import { log } from '@clack/prompts'; import { PortalGeneratePrompts } from '../../../src/prompts/portal/generate.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { SiteAddress } from '../../../src/types/portal/config/site-address.js'; describe('PortalGeneratePrompts', () => { + describe('how Ask AI gets the pages', () => { + const printed = (url: string | null) => { + const site = url === null ? null : SiteAddress.parse(url, 'portal.site.url')._unsafeUnwrap(); + const info = sinon.stub(log, 'info'); + try { + new PortalGeneratePrompts().copilotUpdatesAfterDeploy(site); + } finally { + info.restore(); + } + return stripVTControlCharacters(String(info.firstCall.args[0])); + }; + + it('says the service picks them up from a portal with an address, with sync for one it cannot reach', () => { + const message = printed('https://example.com/docs'); + + expect(message).to.contain( + 'picks up these pages by itself once the portal is deployed at https://example.com/docs' + ); + expect(message).to.contain('cannot reach it there, run apimatic portal copilot sync after deploying'); + }); + + it('sends a portal without an address to sync, since the service cannot find it', () => { + const message = printed(null); + + expect(message).to.contain('Deploy the portal, then run apimatic portal copilot sync'); + expect(message).to.contain('cannot find the portal by itself'); + expect(message).to.not.contain('by itself once'); + }); + }); + describe('the next steps', () => { const portal = new DirectoryPath('portal'); From bacb58ce42539c3949813889e73b0a250816ef87 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 11:17:20 +0500 Subject: [PATCH 30/41] fix(portal): leave the Ask AI chat out of a portal that does not offer it Every portal paid for the chat: `app.css` imported its preset, whose `@source` wrote the chat's classes into each stylesheet, and the layout's `import()` of the chat made the build emit its chunk into every output. The CLI now decides both from the `portal` block. `theme.css` imports the chat's preset only for a portal with Ask AI, and the CLI writes `src/lib/copilot-entry.ts`, which holds the chat's `import()` only then; without it, the module names the chat as a type alone, and the build has nothing to make a chunk of. `portal serve` rewrites both as the block changes. Built outside a repository, a portal without the chat drops from the chat's 105,222 bytes of CSS to 97,272, ships no chat chunk, and loads 5.4 KB less JavaScript (gzipped) up front. Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/lib/copilot-entry.ts | 5 +++ portal-template/src/lib/layout.tsx | 9 +++-- portal-template/src/styles/app.css | 6 ++-- src/infrastructure/portal-project-service.ts | 7 ++++ src/types/portal/portal-copilot-entry.ts | 34 ++++++++++++++++++ src/types/portal/portal-stylesheet.ts | 13 +++++-- test/e2e/portal-build.test.ts | 16 +++++++++ .../portal-project-service.test.ts | 17 +++++++++ .../types/portal/portal-copilot-entry.test.ts | 35 +++++++++++++++++++ test/types/portal/portal-stylesheet.test.ts | 32 +++++++++++++++-- 10 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 portal-template/src/lib/copilot-entry.ts create mode 100644 src/types/portal/portal-copilot-entry.ts create mode 100644 test/types/portal/portal-copilot-entry.test.ts diff --git a/portal-template/src/lib/copilot-entry.ts b/portal-template/src/lib/copilot-entry.ts new file mode 100644 index 000000000..f25484e4d --- /dev/null +++ b/portal-template/src/lib/copilot-entry.ts @@ -0,0 +1,5 @@ +// Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead. +// This portal offers no Ask AI chat, so nothing here imports it, and the build leaves it out. +import type CopilotLayoutComponent from '@/components/copilot'; + +export const CopilotLayout: typeof CopilotLayoutComponent | null = null; diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index c56c495bf..03d8638c0 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -1,18 +1,17 @@ -import { lazy, Suspense, useMemo, type ReactNode } from 'react'; +import { Suspense, useMemo, type ReactNode } from 'react'; import type * as PageTree from 'fumadocs-core/page-tree'; import { DocsLayout as NotebookLayout } from 'fumadocs-ui/layouts/notebook'; import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; +import { CopilotLayout } from './copilot-entry'; import { baseOptions } from './layout.shared'; import { portal } from './portal'; import { portalTabs } from './tabs'; type LayoutProps = Readonly<{ tree: PageTree.Root; children: ReactNode }>; -// Its own chunk, so a portal without `portal.ai.copilot` never downloads the chat. -const CopilotLayout = lazy(() => import('@/components/copilot')); - export function PortalLayout({ tree, children }: LayoutProps) { - if (portal.copilot === null) return {children}; + // Null for a portal without `portal.ai.copilot`, whose build then carries none of the chat. + if (CopilotLayout === null || portal.copilot === null) return {children}; // The prerender waits for the chunk, so the fallback only shows if it is slow on a client-side navigation. return ( {children}}> diff --git a/portal-template/src/styles/app.css b/portal-template/src/styles/app.css index 8e44f39e0..cee519cda 100644 --- a/portal-template/src/styles/app.css +++ b/portal-template/src/styles/app.css @@ -2,12 +2,12 @@ @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css'; @import 'fumadocs-openapi/css/preset.css'; -@import '@fumadocs/ai-chat/css/preset.css'; /* Served with the site: a cross-origin font stylesheet would hold up first paint and hydration. */ @import '@fontsource-variable/geist'; @import '@fontsource-variable/geist-mono'; -/* Written by the CLI from the 'portal' block of apimatic.json: the colour overrides. Imported - last, so what it sets outranks the theme and presets above. */ +/* Written by the CLI from the 'portal' block of apimatic.json: the colour overrides, and the Ask AI + chat's preset for a portal that offers it. Imported last, so what it sets outranks the theme and + presets above. */ @import './theme.css'; /* The `font-sans` and `font-mono` classes read these, and the page defaults derive from them. */ diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index 462752667..729a13f68 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -14,6 +14,7 @@ import { PortalConfig } from '../types/portal/portal-config.js'; import { PLUGIN_DOWNLOAD, SDK_DOWNLOADS_FOLDER, sdkDownload } from '../types/portal/portal-downloads.js'; import { PortalSettings, PortalSource } from '../types/portal/portal-source.js'; import { PortalStylesheet } from '../types/portal/portal-stylesheet.js'; +import { PortalCopilotEntry } from '../types/portal/portal-copilot-entry.js'; import { errorMessage } from '../utils/error-utils.js'; import { envInfo } from './env-info.js'; import { FileService } from './file-service.js'; @@ -60,6 +61,8 @@ const IDENTITY_FILE_NAME = 'portal.identity.json'; /** In `src/styles/`, beside `app.css`, which imports it. */ const STYLESHEET_FILE_NAME = 'theme.css'; +/** Beside `layout.tsx`, which takes the Ask AI chat from it. */ +const COPILOT_ENTRY_FILE_NAME = 'copilot-entry.ts'; /** Where the SDKs and the context plugin are laid out as the site serves them, inside the project. */ export const DOWNLOADS_DIRECTORY_NAME = 'downloads'; @@ -391,6 +394,10 @@ export class PortalProjectService { [ new FilePath(projectDirectory.join('src').join('styles'), new FileName(STYLESHEET_FILE_NAME)), PortalStylesheet.of(config).toString() + ], + [ + new FilePath(projectDirectory.join('src').join('lib'), new FileName(COPILOT_ENTRY_FILE_NAME)), + PortalCopilotEntry.of(config).toString() ] ]; } diff --git a/src/types/portal/portal-copilot-entry.ts b/src/types/portal/portal-copilot-entry.ts new file mode 100644 index 000000000..46151e97f --- /dev/null +++ b/src/types/portal/portal-copilot-entry.ts @@ -0,0 +1,34 @@ +import { PortalConfig } from './portal-config.js'; + +const HEADER = "// Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead."; + +/** + * The build project's `src/lib/copilot-entry.ts`, which the layout takes the Ask AI chat from. The chat's + * `import()` is written only for a portal that offers it: the build makes a chunk of any `import()` it + * finds, so a portal without the chat would otherwise ship the chat's code. Rewritten whole, as + * `portal serve` does when the block changes; rendered here so the module is tested without a build. + */ +export class PortalCopilotEntry { + private constructor(private readonly chat: boolean) {} + + public static of(config: PortalConfig): PortalCopilotEntry { + return new PortalCopilotEntry(config.copilotService() !== null); + } + + public toString(): string { + const body = this.chat + ? [ + '// In a chunk of its own, loaded only by this portal, which offers the chat.', + "import { lazy } from 'react';", + '', + "export const CopilotLayout = lazy(() => import('@/components/copilot'));" + ] + : [ + '// This portal offers no Ask AI chat, so nothing here imports it, and the build leaves it out.', + "import type CopilotLayoutComponent from '@/components/copilot';", + '', + 'export const CopilotLayout: typeof CopilotLayoutComponent | null = null;' + ]; + return [HEADER, ...body].join('\n').concat('\n'); + } +} diff --git a/src/types/portal/portal-stylesheet.ts b/src/types/portal/portal-stylesheet.ts index 8f87a9a2f..cbc8d834e 100644 --- a/src/types/portal/portal-stylesheet.ts +++ b/src/types/portal/portal-stylesheet.ts @@ -3,6 +3,12 @@ import { PortalConfig } from './portal-config.js'; const HEADER = "/* Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead. */"; +/** + * The Ask AI chat's preset: a `@source` of the chat's code, so Tailwind writes the classes it uses. Imported + * only for a portal that offers the chat, so no other portal's stylesheet carries them. + */ +export const CHAT_PRESET = "@import '@fumadocs/ai-chat/css/preset.css';"; + /** * Scoped with `:not(.dark)`: a bare `:root` has the specificity of the theme's `.dark` block and * comes after it, so a colour meant for light mode would win in dark mode too. @@ -16,10 +22,10 @@ export const DARK_SELECTOR = '.dark'; * can rewrite it whole when the block changes; rendered here so the CSS is tested without a build. */ export class PortalStylesheet { - private constructor(private readonly primary: { light: Color; dark: Color } | null) {} + private constructor(private readonly primary: { light: Color; dark: Color } | null, private readonly chat: boolean) {} public static of(config: PortalConfig): PortalStylesheet { - return new PortalStylesheet(config.brandSettings().brandColors().primaryColors()); + return new PortalStylesheet(config.brandSettings().brandColors().primaryColors(), config.copilotService() !== null); } public toString(): string { @@ -30,7 +36,8 @@ export class PortalStylesheet { PortalStylesheet.rule(LIGHT_SELECTOR, this.primary.light), PortalStylesheet.rule(DARK_SELECTOR, this.primary.dark) ]; - return [HEADER, ...rules].join('\n\n').concat('\n'); + // An import goes before any rule, or the stylesheet drops it. + return [HEADER, ...(this.chat ? [CHAT_PRESET] : []), ...rules].join('\n\n').concat('\n'); } private static rule(selector: string, primary: Color): string { diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index a012e1001..d760619d8 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -208,6 +208,9 @@ const scriptsOf = (output: DirectoryPath) => /** Code only the Ask AI chat carries: the address it opens a session at. */ const CHAT_CODE = '/v1/sessions'; +/** A class only the Ask AI chat uses, on its source chips' numbers. */ +const CHAT_CLASS = '.text-\\[10\\.5px\\]'; + /** * The scripts a page loads before anything asks for more: those its HTML names, and every one * they import in turn. A chunk reached only through `import()` is left out. @@ -324,6 +327,15 @@ const stylesheetOf = (output: DirectoryPath) => { expect(filesNaming(fixture.join('content').toString(), fixture.toString())).to.deep.equal([]); }); + // A portal without the chat ships none of its code, not even a chunk it never loads. That its stylesheet + // leaves out the chat's classes is held by the stylesheet's own tests: on Windows, a project built beside + // a source on another drive than the temp folder has Tailwind write classes nothing uses, the chat's among them. + it('ships none of the Ask AI chat', () => { + const chat = scriptsOf(portal.output()).filter((script) => script.text.includes(CHAT_CODE)); + + expect(chat.map(({ name }) => name)).to.deep.equal([]); + }); + it('writes the sidebar tree to one cache file instead of into every page payload', () => { expect(treeCacheFiles()).to.have.length(1); }); @@ -1031,6 +1043,10 @@ const stylesheetOf = (output: DirectoryPath) => { expect(loaded.filter((name) => chat.some((script) => script.name === name))).to.deep.equal([]); }); + it("styles the chat, its classes in the portal's stylesheet", () => { + expect(stylesheetOf(portal.output())).to.contain(CHAT_CLASS); + }); + // `generate` bundles these for the service: every page the build renders, those the navigation leaves // out and those whose names are not ASCII too, and no more. it('writes every page Ask AI answers from, and no more', () => { diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index a01f9de45..8b8915186 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -23,6 +23,7 @@ import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; import { PortalLanguages } from '../../src/types/portal/portal-languages'; import { PortalSource } from '../../src/types/portal/portal-source'; import { PortalStylesheet } from '../../src/types/portal/portal-stylesheet'; +import { PortalCopilotEntry } from '../../src/types/portal/portal-copilot-entry'; import { Language } from '../../src/types/sdk/generate'; const NO_ARTIFACTS = PortalArtifacts.none(); @@ -258,6 +259,22 @@ describe('PortalProjectService', () => { expect(fs.readFileSync(path.join(styles, 'app.css'), 'utf8')).to.contain("@import './theme.css';"); }); + // `portal serve` rewrites it as the block changes, and the dev server reloads the layout with it. + it('writes the module the layout takes the Ask AI chat from, and rewrites it as the block changes', async () => { + const entry = () => fs.readFileSync(path.join(project.toString(), 'src/lib/copilot-entry.ts'), 'utf8'); + const withoutChat = configFor({ site: { name: 'My API' } }); + const withChat = configFor({ + site: { name: 'My API' }, + ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } + }); + + (await service.prepare(project, sourceFor({ config: withoutChat }), NO_ARTIFACTS))._unsafeUnwrap(); + expect(entry()).to.equal(PortalCopilotEntry.of(withoutChat).toString()); + + expect((await service.applyConfig(project, settingsFor(withChat)))._unsafeUnwrap()).to.be.true; + expect(entry()).to.equal(PortalCopilotEntry.of(withChat).toString()); + }); + it('leaves no identity placeholder in the module the browser receives', async () => { (await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap(); diff --git a/test/types/portal/portal-copilot-entry.test.ts b/test/types/portal/portal-copilot-entry.test.ts new file mode 100644 index 000000000..681914940 --- /dev/null +++ b/test/types/portal/portal-copilot-entry.test.ts @@ -0,0 +1,35 @@ +import fs from 'fs'; +import path from 'path'; +import { expect } from 'chai'; +import { PortalConfig } from '../../../src/types/portal/portal-config'; +import { PortalCopilotEntry } from '../../../src/types/portal/portal-copilot-entry'; + +describe('PortalCopilotEntry', () => { + const entryFor = (block: object) => + PortalCopilotEntry.of( + PortalConfig.fromBlock(block, { name: 'Calc', description: null })._unsafeUnwrap() + ).toString(); + + const CHAT_IMPORT = "import('@/components/copilot')"; + + it('imports the Ask AI chat, lazily, for a portal that offers it', () => { + const entry = entryFor({ ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } }); + + expect(entry).to.contain(`export const CopilotLayout = lazy(() => ${CHAT_IMPORT});`); + }); + + // The build makes a chunk of any `import()` it finds; a type-only import leaves nothing behind. + it('names the chat only as a type for a portal without it, so its build carries none of the chat', () => { + const entry = entryFor({}); + + expect(entry).to.not.contain(CHAT_IMPORT); + expect(entry).to.contain("import type CopilotLayoutComponent from '@/components/copilot';"); + expect(entry).to.contain('export const CopilotLayout: typeof CopilotLayoutComponent | null = null;'); + }); + + it('is what the template ships, for a portal without the chat', () => { + const shipped = fs.readFileSync(path.resolve('portal-template', 'src', 'lib', 'copilot-entry.ts'), 'utf8'); + + expect(shipped.replace(/\r\n/g, '\n')).to.equal(entryFor({})); + }); +}); diff --git a/test/types/portal/portal-stylesheet.test.ts b/test/types/portal/portal-stylesheet.test.ts index 9bbbc455b..89adb85af 100644 --- a/test/types/portal/portal-stylesheet.test.ts +++ b/test/types/portal/portal-stylesheet.test.ts @@ -3,7 +3,12 @@ import path from 'path'; import { expect } from 'chai'; import { compile } from 'tailwindcss'; import { PortalConfig } from '../../../src/types/portal/portal-config'; -import { DARK_SELECTOR, LIGHT_SELECTOR, PortalStylesheet } from '../../../src/types/portal/portal-stylesheet'; +import { + CHAT_PRESET, + DARK_SELECTOR, + LIGHT_SELECTOR, + PortalStylesheet +} from '../../../src/types/portal/portal-stylesheet'; describe('PortalStylesheet', () => { const stylesheetFor = (block: object) => @@ -49,6 +54,16 @@ describe('PortalStylesheet', () => { // A bare `:root` has the specificity of the theme's `.dark` block and comes after it, so a // colour meant for light mode would win in dark mode as well. + // The chat's classes would otherwise come with every portal's stylesheet, chat or not. + it("imports the Ask AI chat's preset only for a portal that offers the chat, before any rule", () => { + const copilot = { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' }; + + expect(stylesheetFor({})).to.not.contain(CHAT_PRESET); + const css = stylesheetFor({ ai: { copilot }, brand: { colors: { primary: '#1d4ed8' } } }); + expect(css).to.contain(CHAT_PRESET); + expect(css.indexOf(CHAT_PRESET)).to.be.below(css.indexOf(`${LIGHT_SELECTOR} {`)); + }); + it('scopes the light rule away from dark mode', () => { const css = stylesheetFor({ brand: { colors: { primary: '#1d4ed8' } } }); @@ -66,7 +81,7 @@ describe('PortalStylesheet', () => { * `app.css` also imports set no colour outside `@theme`, and one of them loads a plugin, so * they are left out. */ - const compiled = async (theme: string): Promise => { + const compiler = async (theme: string) => { // As Node looks a package up, from the real directory of the file importing it: under // pnpm a package's own dependencies sit beside it, not at the top of `node_modules`. const resolvePackage = (id: string, base: string): string => { @@ -85,8 +100,19 @@ describe('PortalStylesheet', () => { return { path: file, base: path.dirname(file), content: fs.readFileSync(file, 'utf8') }; }; const app = "@import 'tailwindcss';\n@import 'fumadocs-ui/css/neutral.css';\n@import './theme.css';\n"; - return (await compile(app, { base: styles, loadStylesheet })).build([]); + return await compile(app, { base: styles, loadStylesheet }); }; + const compiled = async (theme: string): Promise => (await compiler(theme)).build([]); + + it("scans the Ask AI chat's code for its classes only for a portal that offers the chat", async () => { + const chatSources = async (block: object) => + (await compiler(stylesheetFor(block))).sources.filter(({ base }) => base.includes('ai-chat')); + + expect(await chatSources({})).to.be.empty; + expect( + await chatSources({ ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } }) + ).to.have.length(1); + }); it("keeps each mode's primary, after the neutral theme's own rules", async () => { const output = await compiled( From b50d29a3f5df0eb54a3c0bc8f04285d7a9474f58 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 11:52:14 +0500 Subject: [PATCH 31/41] perf(portal): hydrate the page without waiting for the Ask AI chat The chat's lazy component wrapped the whole layout, so a page with the chat stayed inert until the chat's chunk had arrived. The layout now renders on its own, keeping the chat's open state and a place for its panel; the chat loads beside it, fills that place through a portal, and keeps its open state and the layout's as one, so its button, Escape, Ctrl+/ and the panel's close button behave as before. The generated `copilot-entry.ts` asks for the chunk as the page's scripts run. On Slow 4G over HTTP/2, a page with the chat became interactive in 5.17 s rather than 6.10 s, against 4.33 s for one without it. The template is now also type-checked with the chat's `copilot-entry.ts`. Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/components/copilot.tsx | 53 ++++++++++--------- portal-template/src/lib/copilot-entry.ts | 4 +- portal-template/src/lib/layout.tsx | 41 +++++++++++--- src/types/portal/portal-copilot-entry.ts | 11 ++-- test/e2e/portal-build.test.ts | 7 +++ .../types/portal/portal-copilot-entry.test.ts | 7 +-- 6 files changed, 82 insertions(+), 41 deletions(-) diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index e433d14d7..fe08ca6d5 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -1,11 +1,10 @@ // The Ask AI copilot: Fumadocs' AI chat panel, answered by the Ask AI service (the portal is static). // `@fumadocs/ai-chat` is 0.x and needs fumadocs-ui >= 16.16: keep it pinned and check the chat on every upgrade. -import { useEffect, useLayoutEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'; -import type * as PageTree from 'fumadocs-core/page-tree'; +import { useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport, type UIMessage } from 'ai'; import { AIChatPanel, AIChatProvider, AIChatSearch, AIChatSources, AIChatTrigger, useAIChat } from '@fumadocs/ai-chat'; -import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; import { CopilotClient, questionMessage } from '@/lib/copilot-client'; import type { PortalCopilot } from '@/lib/portal-types'; @@ -15,32 +14,44 @@ type Part = UIMessage['parts'][number]; type SearchPart = Parameters[0]['part']; type Sources = Parameters[0]['sources']; -/** The docs layout the chat goes around, handed the chat's `aiChat` option. */ -type ChatLayout = ComponentType>; -type LayoutProps = Readonly<{ layout: ChatLayout; tree: PageTree.Root; children: ReactNode }>; +/** What the layout hands the chat: whether its panel is open, and where the layout keeps a place for it. */ +export type CopilotChatProps = Readonly<{ + copilot: PortalCopilot; + open: boolean; + onOpenChange: (open: boolean) => void; + /** The element the layout keeps in its panel slot; null until the layout has rendered it. */ + panel: HTMLElement | null; +}>; /** - * The docs layout with the Ask AI chat around it. The module's one export, so the layout can load - * it on its own, and only for a portal that offers the chat. + * The Ask AI chat, beside the docs layout rather than around it, so the page hydrates without waiting for + * this chunk. The module's one export, so the layout can load it on its own, and only for a portal that + * offers the chat. */ -export default function CopilotLayout({ copilot, ...layout }: LayoutProps & Readonly<{ copilot: PortalCopilot }>) { +export default function CopilotChat({ copilot, open, onOpenChange, panel }: CopilotChatProps) { return ( - + + {panel !== null && createPortal(, panel)} ); } -// Separate, because the panel state exists only inside `CopilotProvider`. -function WithPanel({ layout: Layout, tree, children }: LayoutProps) { - return ( - - {children} - - ); +/** + * Keeps the chat's open state and the layout's as one: either side may open or close the panel. Each + * side passes on only a change of its own (both setters are stable), and setting a state to the value it + * holds changes nothing, so the two settle at once. + */ +function FollowLayout({ open, onOpenChange }: Readonly<{ open: boolean; onOpenChange: (open: boolean) => void }>) { + const chat = useAIChat(); + // The layout's own control closed or opened it. + useEffect(() => chat.setOpen(open), [open, chat.setOpen]); + // The chat's button, Ctrl+/ or Escape did. + useEffect(() => onOpenChange(chat.open), [chat.open, onOpenChange]); + return null; } -/** The chat state and the "Ask AI" button; wraps the layout so a conversation survives navigation. */ +/** The chat state and the "Ask AI" button; beside the layout, so a conversation survives navigation. */ function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilot; children: ReactNode }>) { // Turned away by the service (disabled, unknown key, another website): the portal shows no Ask AI at all. const [available, setAvailable] = useState(true); @@ -75,12 +86,6 @@ function SessionOnOpen({ client, available }: Readonly<{ client: CopilotClient; return null; } -/** The docs layout's `aiChat` option: the panel and whether it is open. Call inside `CopilotProvider`. */ -function useCopilotPanel(): AIChatOptions { - const { open, setOpen } = useAIChat(); - return { open, onOpenChange: setOpen, panel: }; -} - /** * Draws the non-text parts the service sends; the panel draws text itself. * A new part type from the service needs a branch here, or it is not shown. diff --git a/portal-template/src/lib/copilot-entry.ts b/portal-template/src/lib/copilot-entry.ts index f25484e4d..7c4e4509f 100644 --- a/portal-template/src/lib/copilot-entry.ts +++ b/portal-template/src/lib/copilot-entry.ts @@ -1,5 +1,5 @@ // Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead. // This portal offers no Ask AI chat, so nothing here imports it, and the build leaves it out. -import type CopilotLayoutComponent from '@/components/copilot'; +import type CopilotChatComponent from '@/components/copilot'; -export const CopilotLayout: typeof CopilotLayoutComponent | null = null; +export const CopilotChat: typeof CopilotChatComponent | null = null; diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index 03d8638c0..2aaa5d811 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -1,8 +1,9 @@ -import { Suspense, useMemo, type ReactNode } from 'react'; +import { Suspense, useMemo, useState, type ReactNode } from 'react'; import type * as PageTree from 'fumadocs-core/page-tree'; import { DocsLayout as NotebookLayout } from 'fumadocs-ui/layouts/notebook'; import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; -import { CopilotLayout } from './copilot-entry'; +import { CopilotChat } from './copilot-entry'; +import type { PortalCopilot } from './portal-types'; import { baseOptions } from './layout.shared'; import { portal } from './portal'; import { portalTabs } from './tabs'; @@ -11,14 +12,38 @@ type LayoutProps = Readonly<{ tree: PageTree.Root; children: ReactNode }>; export function PortalLayout({ tree, children }: LayoutProps) { // Null for a portal without `portal.ai.copilot`, whose build then carries none of the chat. - if (CopilotLayout === null || portal.copilot === null) return {children}; - // The prerender waits for the chunk, so the fallback only shows if it is slow on a client-side navigation. + if (CopilotChat === null || portal.copilot === null) return {children}; return ( - {children}}> - + + {children} + + ); +} + +type ChatProps = LayoutProps & Readonly<{ chat: NonNullable; copilot: PortalCopilot }>; + +/** + * The layout with the Ask AI chat beside it, not around it: a page hydrates as soon as its own scripts + * have run, and the chat's chunk, loading meanwhile, fills the panel the layout keeps a place for. + */ +function WithChat({ chat: Chat, copilot, tree, children }: ChatProps) { + const [open, setOpen] = useState(false); + const [panel, setPanel] = useState(null); + // `contents`, so the chat's panel lays out as the slot's own child. + const aiChat = useMemo( + () => ({ open, onOpenChange: setOpen, panel:
}), + [open] + ); + return ( + <> + {children} - - + + {/* The prerender waits for the chunk, so each page carries the chat's button. */} + + + + ); } diff --git a/src/types/portal/portal-copilot-entry.ts b/src/types/portal/portal-copilot-entry.ts index 46151e97f..f970857a6 100644 --- a/src/types/portal/portal-copilot-entry.ts +++ b/src/types/portal/portal-copilot-entry.ts @@ -18,16 +18,19 @@ export class PortalCopilotEntry { public toString(): string { const body = this.chat ? [ - '// In a chunk of its own, loaded only by this portal, which offers the chat.', + '// In a chunk of its own, loaded only by this portal, which offers the chat. Asked for as this', + "// module runs, with the page's own scripts, rather than once the page has rendered.", "import { lazy } from 'react';", '', - "export const CopilotLayout = lazy(() => import('@/components/copilot'));" + "const chat = import('@/components/copilot');", + '', + 'export const CopilotChat = lazy(() => chat);' ] : [ '// This portal offers no Ask AI chat, so nothing here imports it, and the build leaves it out.', - "import type CopilotLayoutComponent from '@/components/copilot';", + "import type CopilotChatComponent from '@/components/copilot';", '', - 'export const CopilotLayout: typeof CopilotLayoutComponent | null = null;' + 'export const CopilotChat: typeof CopilotChatComponent | null = null;' ]; return [HEADER, ...body].join('\n').concat('\n'); } diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index d760619d8..b46a7a950 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -1043,6 +1043,13 @@ const stylesheetOf = (output: DirectoryPath) => { expect(loaded.filter((name) => chat.some((script) => script.name === name))).to.deep.equal([]); }); + // The layout takes the chat from a module the CLI writes per portal: this is the one build that has the chat's. + it('type-checks with the chat', async () => { + const result = await typeCheck(portal.project()); + + expect(result.exitCode, result.all).to.equal(0); + }); + it("styles the chat, its classes in the portal's stylesheet", () => { expect(stylesheetOf(portal.output())).to.contain(CHAT_CLASS); }); diff --git a/test/types/portal/portal-copilot-entry.test.ts b/test/types/portal/portal-copilot-entry.test.ts index 681914940..96761cba1 100644 --- a/test/types/portal/portal-copilot-entry.test.ts +++ b/test/types/portal/portal-copilot-entry.test.ts @@ -15,7 +15,8 @@ describe('PortalCopilotEntry', () => { it('imports the Ask AI chat, lazily, for a portal that offers it', () => { const entry = entryFor({ ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } }); - expect(entry).to.contain(`export const CopilotLayout = lazy(() => ${CHAT_IMPORT});`); + expect(entry).to.contain(`const chat = ${CHAT_IMPORT};`); + expect(entry).to.contain('export const CopilotChat = lazy(() => chat);'); }); // The build makes a chunk of any `import()` it finds; a type-only import leaves nothing behind. @@ -23,8 +24,8 @@ describe('PortalCopilotEntry', () => { const entry = entryFor({}); expect(entry).to.not.contain(CHAT_IMPORT); - expect(entry).to.contain("import type CopilotLayoutComponent from '@/components/copilot';"); - expect(entry).to.contain('export const CopilotLayout: typeof CopilotLayoutComponent | null = null;'); + expect(entry).to.contain("import type CopilotChatComponent from '@/components/copilot';"); + expect(entry).to.contain('export const CopilotChat: typeof CopilotChatComponent | null = null;'); }); it('is what the template ships, for a portal without the chat', () => { From 861d0eaa5e13fc3f2f1178bfe92b012ced0d11b4 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 15:43:42 +0500 Subject: [PATCH 32/41] fix(portal): name the bundle's exact bytes in build.json, as the Ask AI service checks them The Ask AI service refreshes a deployed portal by fetching build.json, then bundle.json, and refuses the bundle unless its bytes hash to build.json's buildId (copilot-service, src/ingest/portal-fetch.ts). 62a5aacd hashed the pages alone, so that check failed on every deploy and a portal never refreshed by itself. The build ID names the bundle's exact bytes again. Regenerating the same pages still changes the ID, since generatedAt does; the service then finds the pages unchanged and indexes nothing again, so a redeploy costs one fetch of the bundle. The tests now pin the service's contract against the files as written. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/types/portal/copilot/copilot-bundle.ts | 10 ++-------- test/actions/portal/generate.test.ts | 4 +++- .../portal/copilot/copilot-bundle.test.ts | 18 +++++++++++------- .../copilot/generated-portal-context.test.ts | 6 ++++++ 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/types/portal/copilot/copilot-bundle.ts b/src/types/portal/copilot/copilot-bundle.ts index f3620feec..2c0365089 100644 --- a/src/types/portal/copilot/copilot-bundle.ts +++ b/src/types/portal/copilot/copilot-bundle.ts @@ -75,15 +75,9 @@ export class CopilotBundle { return this.json; } - /** - * Names what the service reads, so it fetches the bundle only when a deploy changed it. When it was - * generated, and by which CLI, are left out: regenerating the same pages keeps the same ID. - */ + /** Names these exact bytes: the service checks a deployed bundle.json against it, so it never indexes half a deploy. */ public buildManifest(): string { - const { formatVersion, siteUrl, basePath, pages } = this.document; - const content = JSON.stringify({ formatVersion, siteUrl, basePath, pages }); - const buildId = createHash('sha256').update(content).digest('hex'); - return JSON.stringify({ formatVersion: 1, buildId }); + return JSON.stringify({ formatVersion: 1, buildId: createHash('sha256').update(this.json).digest('hex') }); } } diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index 279c45074..cab48a4a8 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -270,7 +271,8 @@ describe('GenerateAction', () => { expect(pages).to.deep.equal([PAGE]); const manifest = JSON.parse(readPortal('__copilot/build.json')) as { formatVersion: number; buildId: string }; expect(manifest.formatVersion).to.equal(1); - expect(manifest.buildId).to.match(/^[0-9a-f]{64}$/); + // The service refuses a deployed bundle.json that does not hash to this. + expect(manifest.buildId).to.equal(createHash('sha256').update(bundle).digest('hex')); // The fixture gives no `site.url`, so the service cannot find the portal by itself. expect(prompts.copilotUpdatesAfterDeploy.calledOnceWith(null)).to.be.true; }); diff --git a/test/types/portal/copilot/copilot-bundle.test.ts b/test/types/portal/copilot/copilot-bundle.test.ts index 68bd3543d..1698a86c3 100644 --- a/test/types/portal/copilot/copilot-bundle.test.ts +++ b/test/types/portal/copilot/copilot-bundle.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { gunzipSync } from 'node:zlib'; import { expect } from 'chai'; import { SiteAddress } from '../../../../src/types/portal/config/site-address'; @@ -68,16 +69,19 @@ describe('CopilotBundle', () => { const buildId = (pages = PAGES, site: SiteAddress | null = null, cliVersion = '9.9.9', when = AT) => (JSON.parse(CopilotBundle.of(pages, site, cliVersion, when).buildManifest()) as { buildId: string }).buildId; - it('is a SHA-256 in hex, in format version 1', () => { - const manifest = JSON.parse(CopilotBundle.of(PAGES, null, '9.9.9', AT).buildManifest()) as unknown; + // The service fetches a deployed bundle.json and refuses it unless it hashes to this ID. + it("is the SHA-256 of the bundle's exact bytes, in format version 1", () => { + const bundle = CopilotBundle.of(PAGES, null, '9.9.9', AT); - expect(manifest).to.deep.include({ formatVersion: 1 }); - expect(buildId()).to.match(/^[0-9a-f]{64}$/); + expect(JSON.parse(bundle.buildManifest())).to.deep.equal({ + formatVersion: 1, + buildId: createHash('sha256').update(bundle.toJson()).digest('hex') + }); }); - it('stays the same when the same pages are generated again, at another time or by another CLI', () => { - expect(buildId(PAGES, null, '9.9.9', new Date('2027-01-01T00:00:00.000Z'))).to.equal(buildId()); - expect(buildId(PAGES, null, '10.0.0')).to.equal(buildId()); + // The service finds the same pages then and indexes nothing again; it only fetches the bundle once more. + it('changes when the same pages are generated again, as the bytes do', () => { + expect(buildId(PAGES, null, '9.9.9', new Date('2027-01-01T00:00:00.000Z'))).to.not.equal(buildId()); }); it('changes with a page', () => { diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index 0ca8a7325..a02b9e525 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { expect } from 'chai'; import sinon from 'sinon'; import { FileService } from '../../../../src/infrastructure/file-service'; @@ -88,6 +89,11 @@ describe('GeneratedPortalContext', () => { expect(read('__copilot/bundle.json')).to.equal(BUNDLE.toJson()); expect(read('__copilot/build.json')).to.equal(BUNDLE.buildManifest()); + // As the service checks a deployed portal: the build ID is the hash of bundle.json as written. + const written = fs.readFileSync(path.join(root, '__copilot', 'bundle.json')); + expect(JSON.parse(read('__copilot/build.json')).buildId).to.equal( + createHash('sha256').update(written).digest('hex') + ); }); it('reports a portal it cannot write to rather than throwing', async () => { From 2cc69c498ba00ed482f9ee3fd4128e736e7f6036 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 16:20:01 +0500 Subject: [PATCH 33/41] test(portal): check a portal without Ask AI carries none of the chat's classes either #440 built the project where Tailwind no longer picks up classes nothing uses, so the default portal's stylesheet can be held to having none of the chat's, as its code already was. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/e2e/portal-build.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 97a426d5a..6cafc39cb 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -319,13 +319,12 @@ const stylesheetOf = (output: DirectoryPath) => { expect(filesNaming(fixture.join('content').toString(), fixture.toString())).to.deep.equal([]); }); - // A portal without the chat ships none of its code, not even a chunk it never loads. That its stylesheet - // leaves out the chat's classes is held by the stylesheet's own tests: on Windows, a project built beside - // a source on another drive than the temp folder has Tailwind write classes nothing uses, the chat's among them. - it('ships none of the Ask AI chat', () => { + // A portal without the chat pays nothing for it, not even a chunk it never loads. + it('ships none of the Ask AI chat, neither its code nor its classes', () => { const chat = scriptsOf(portal.output()).filter((script) => script.text.includes(CHAT_CODE)); expect(chat.map(({ name }) => name)).to.deep.equal([]); + expect(stylesheetOf(portal.output())).to.not.contain(CHAT_CLASS); }); it('writes the sidebar tree to one cache file instead of into every page payload', () => { From 27c8d4bd7acad23fc192a88b5e20a829b7dd832e Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 16:56:08 +0500 Subject: [PATCH 34/41] fix(portal): have the schema and the parser agree on every Ask AI service address The parser rewrote some addresses the schema refused (127.1, [0:0::1], an encoded dot, an empty port), and the schema took some the parser refused (a port past 65535, a colon inside a host name). The parser now takes an address only as written, and refuses a host with an empty label; the schema allows sub-domains of letters, digits, hyphens and underscores, and range-checks the port. A test runs 24 addresses through both. Co-Authored-By: Claude Opus 5.5 (1M context) --- apimatic.schema.json | 2 +- src/types/portal/config/service-address.ts | 28 +++++++++------- test/types/apimatic-config/schema.test.ts | 33 +++++++++++++++++++ .../portal/config/service-address.test.ts | 19 ++++++++--- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apimatic.schema.json b/apimatic.schema.json index acf41f10a..a28461f5d 100644 --- a/apimatic.schema.json +++ b/apimatic.schema.json @@ -189,7 +189,7 @@ "apiUrl": { "type": "string", "description": "The address of APIMatic's Ask AI service, such as 'https://ask.apimatic.io', or of one on this machine while developing it, such as 'http://localhost:8787'. The address alone, without a path.", - "pattern": "^([hH][tT][tT][pP][sS]://([^/?#@\\s]+\\.)?[aA][pP][iI][mM][aA][tT][iI][cC]\\.[iI][oO]|[hH][tT][tT][pP][sS]?://([lL][oO][cC][aA][lL][hH][oO][sS][tT]|127\\.0\\.0\\.1|\\[::1\\]))(:[0-9]+)?/?$" + "pattern": "^([hH][tT][tT][pP][sS]://([A-Za-z0-9_-]+\\.)*[aA][pP][iI][mM][aA][tT][iI][cC]\\.[iI][oO]|[hH][tT][tT][pP][sS]?://([lL][oO][cC][aA][lL][hH][oO][sS][tT]|127\\.0\\.0\\.1|\\[::1\\]))(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}|0))?/?$" }, "suggestions": { "type": "array", diff --git a/src/types/portal/config/service-address.ts b/src/types/portal/config/service-address.ts index 935a3c9df..542e8cfaf 100644 --- a/src/types/portal/config/service-address.ts +++ b/src/types/portal/config/service-address.ts @@ -6,11 +6,7 @@ const APIMATIC_DOMAIN = 'apimatic.io'; // Only names that never leave the machine: a `*.localhost` name is resolved through DNS on some systems, Windows' Node among them. const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); -/** - * Where the Ask AI service is: APIMatic's own, over HTTPS, or one on this machine while developing it. `sync` - * sends the APIMatic login there, so no other service is accepted. It is an origin alone: the CLI and the chat - * add the service's own paths, such as `/v1/chat`. - */ +/** APIMatic's own Ask AI service, or one on this machine, since `sync` sends the APIMatic login there. */ export class ServiceAddress { private constructor(private readonly origin: string) {} @@ -23,11 +19,11 @@ export class ServiceAddress { `'${APIMATIC_DOMAIN}', or of one on this machine, such as 'http://localhost:8787'.` ]); } - // Checked as written too: the URL parser drops an empty query or fragment. - if (url.username !== '' || url.password !== '' || url.pathname !== '/' || /[?#]/.test(text)) { + // As written, so the schema can say the same: the URL parser would rewrite `127.1`, `%2e` or an empty port. + if (writtenOrigin(text) !== url.origin) { return err([ - `'${path}' must be the service's address alone, without a path, query or credentials, ` + - `for example 'https://ask.apimatic.io'.` + `'${path}' must be the service's address alone, written in full, without a path, query or ` + + `credentials, for example 'https://ask.apimatic.io'.` ]); } return ok(new ServiceAddress(url.origin)); @@ -40,9 +36,17 @@ export class ServiceAddress { } function isApimatic(url: URL): boolean { - return ( - url.protocol === 'https:' && (url.hostname === APIMATIC_DOMAIN || url.hostname.endsWith(`.${APIMATIC_DOMAIN}`)) - ); + const labels = url.hostname.split('.'); + return url.protocol === 'https:' && labels.slice(-2).join('.') === APIMATIC_DOMAIN && !labels.includes(''); +} + +/** The text as an origin reads: in lower case, without one trailing slash or the scheme's default port. */ +function writtenOrigin(text: string): string { + return text + .toLowerCase() + .replace(/\/$/, '') + .replace(/^(https:\/\/[^/]+):443$/, '$1') + .replace(/^(http:\/\/[^/]+):80$/, '$1'); } function isLoopback(url: URL): boolean { diff --git a/test/types/apimatic-config/schema.test.ts b/test/types/apimatic-config/schema.test.ts index 03d4d3193..a50156c08 100644 --- a/test/types/apimatic-config/schema.test.ts +++ b/test/types/apimatic-config/schema.test.ts @@ -79,6 +79,39 @@ describe('apimatic.schema.json', () => { }); }); + // Each address through both, as an editor and `portal generate` would read it. + it('agrees with the parser on every Ask AI service address, accepted or not', () => { + for (const apiUrl of [ + 'https://ask.apimatic.io', + 'HTTPS://ASK.APIMATIC.IO:8443', + 'https://ask.staging.apimatic.io/', + 'https://ask.apimatic.io:443', + 'http://localhost:8787', + 'https://localhost:65535', + 'http://127.0.0.1:0', + 'http://[::1]:8787', + 'https://ask.example.com', + 'http://ask.apimatic.io', + 'https://ask.apimatic.io/v1', + 'https://user@ask.apimatic.io', + 'http://localhost:@evil.example/', + 'http://localhost:99999', + 'http://localhost:65536', + 'http://localhost:08787', + 'https://x.y:1.apimatic.io', + 'https://.apimatic.io', + 'https://ask..apimatic.io', + 'http://127.1:8787', + 'http://[0:0::1]:8787', + 'https://ask%2eapimatic.io', + 'https://ask.apimatic.io:', + 'http://ask.localhost' + ]) { + const block = { ai: { copilot: { key: 'pk_test', apiUrl } } }; + expect(schemaVerdict({ portal: block }).valid, apiUrl).to.equal(portalAccepts(block)); + } + }); + describe('agrees with the parser on the portal block', () => { const valid: [string, object][] = [ ['an empty block', {}], diff --git a/test/types/portal/config/service-address.test.ts b/test/types/portal/config/service-address.test.ts index 16fa6123f..b95ef904f 100644 --- a/test/types/portal/config/service-address.test.ts +++ b/test/types/portal/config/service-address.test.ts @@ -11,8 +11,8 @@ describe('ServiceAddress', () => { "'portal.ai.copilot.apiUrl' must be the address of APIMatic's Ask AI service, starting with 'https://' and " + "ending in 'apimatic.io', or of one on this machine, such as 'http://localhost:8787'."; const NOT_ALONE = - "'portal.ai.copilot.apiUrl' must be the service's address alone, without a path, query or credentials, " + - "for example 'https://ask.apimatic.io'."; + "'portal.ai.copilot.apiUrl' must be the service's address alone, written in full, without a path, query or " + + "credentials, for example 'https://ask.apimatic.io'."; describe('parse', () => { it("reads APIMatic's own service, over HTTPS, or one on this machine, as its origin", () => { @@ -25,7 +25,9 @@ describe('ServiceAddress', () => { ['http://localhost:8787', 'http://localhost:8787'], ['https://localhost:8787/', 'https://localhost:8787'], ['http://127.0.0.1:8787', 'http://127.0.0.1:8787'], - ['http://[::1]:8787', 'http://[::1]:8787'] + ['http://[::1]:8787', 'http://[::1]:8787'], + ['https://ask.apimatic.io:443', 'https://ask.apimatic.io'], + ['http://localhost:80/', 'http://localhost'] ]; for (const [value, origin] of cases) { expect(address(value).toString(), value).to.equal(origin); @@ -39,6 +41,9 @@ describe('ServiceAddress', () => { 'http://ask.apimatic.io', 'https://ask.apimatic.io.example.com', 'https://evilapimatic.io', + 'https://.apimatic.io', + 'https://ask..apimatic.io', + 'http://localhost:99999', 'https://ask.apimatic.io@evil.example', 'http://localhost:@evil.example/', 'http://localhost.example.com', @@ -62,7 +67,13 @@ describe('ServiceAddress', () => { 'https://ask.apimatic.io#', 'https://user@ask.apimatic.io', 'https://user:secret@ask.apimatic.io', - 'http://localhost:8787/api' + 'http://localhost:8787/api', + // Addresses the URL parser would rewrite: refused, so what is accepted is what was written. + 'http://127.1:8787', + 'http://[0:0::1]:8787', + 'https://ask%2eapimatic.io', + 'https://ask.apimatic.io:', + 'http://localhost:08787' ]) { expect(refusal(value), value).to.deep.equal([NOT_ALONE]); } From 1af59d4958df6c0de9dd1312c3c0467bd364d334 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 16:56:09 +0500 Subject: [PATCH 35/41] fix(portal): remove the build's Ask AI page file however reading it goes takePages deleted api/copilot-pages.json only after reading it, so a read that failed left the file in the portal, to be deployed. It is now deleted whether the read works or not. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../copilot/generated-portal-context.ts | 20 ++++++++----------- .../copilot/generated-portal-context.test.ts | 3 ++- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts index 8dbadabd8..5e527eeb8 100644 --- a/src/types/portal/copilot/generated-portal-context.ts +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -38,20 +38,19 @@ export class GeneratedPortalContext { return new FilePath(this.directory, new FileName(BUNDLE_FILE_NAME)); } - /** - * The pages a build wrote for Ask AI, removing their file so it is not deployed; null for a build - * that wrote none, as one of a portal without Ask AI. - */ + /** The pages a build wrote for Ask AI, their file removed however it reads, so it is never deployed. */ public async takePages(): Promise> { const file = this.pagesFile; try { if (!(await this.fileService.fileExists(file))) { return ok(null); } - const contents = await this.fileService.getContents(file); - await this.fileService.deleteFile(file); - const pages = pagesOf(contents); - return pages === null ? err('the pages the build wrote could not be read') : ok(pages); + try { + const pages = pagesOf(await this.fileService.getContents(file)); + return pages === null ? err('the pages the build wrote could not be read') : ok(pages); + } finally { + await this.fileService.deleteFile(file); + } } catch (error) { return err(errorMessage(error)); } @@ -72,10 +71,7 @@ export class GeneratedPortalContext { } } - /** - * The bundle `generate` published into the portal, whether saved as a directory or, with `--zip`, as an - * archive; null for a portal without one. - */ + /** The bundle `generate` published, from the portal's directory or its `--zip` archive; null for none. */ public async bundle(): Promise> { const json = await this.bundleJson(); if (json.isErr() || json.value === null) { diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts index a02b9e525..83effa8e8 100644 --- a/test/types/portal/copilot/generated-portal-context.test.ts +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -75,11 +75,12 @@ describe('GeneratedPortalContext', () => { } }); - it('reports pages it cannot read rather than throwing', async () => { + it('reports pages it cannot read rather than throwing, and still removes them', async () => { write(COPILOT_PAGES_FILE, JSON.stringify(PAGES)); unreadable('copilot-pages.json'); expect((await portal.takePages())._unsafeUnwrapErr()).to.contain('EACCES: permission denied'); + expect(exists(COPILOT_PAGES_FILE)).to.be.false; }); }); From cc8bffca7ca4703da88b8acbbad006de6dac7f0e Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 16:56:09 +0500 Subject: [PATCH 36/41] fix(portal): tell a build that wrote no Ask AI pages apart from a portal without pages Both were reported as "The portal has no pages for Ask AI to answer from", which sends the author through their docs when the build is at fault. A build that wrote no page file now says to generate again, and whom to contact if the pages are still missing. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/actions/portal/generate.ts | 6 ++++- src/prompts/portal/generate.ts | 8 +++++++ test/actions/portal/generate.test.ts | 33 ++++++++++++++++++---------- test/prompts/portal/generate.test.ts | 27 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 57487424e..63be1a877 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -106,7 +106,11 @@ export class GenerateAction { return false; } const pages = taken.value; - if (pages === null || pages.length === 0) { + if (pages === null) { + this.prompts.copilotPagesNotBuilt(); + return false; + } + if (pages.length === 0) { this.prompts.copilotPagesNotFound(); return false; } diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 0b315814d..75b3f5ff6 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -136,6 +136,14 @@ export class PortalGeneratePrompts { log.warn('The portal has no pages for Ask AI to answer from, so none were added to it.'); } + // A fault of the build rather than of the docs, so the author is not sent looking through their pages. + public copilotPagesNotBuilt() { + log.warn( + 'The build wrote no pages for Ask AI, so none were added to the portal. Generate the portal again, ' + + `and if they are still missing, contact ${f.var('support@apimatic.io')}.` + ); + } + public copilotBundleNotWritten(reason: string) { log.warn( `The pages for Ask AI could not be added to the portal (${reason}), so it is saved without them. ` + diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index cab48a4a8..87fefe90c 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -301,18 +301,27 @@ describe('GenerateAction', () => { }); it('still saves the portal when it has no pages', async () => { - for (const write of [() => writePages([]), () => undefined]) { - prompts.copilotPagesNotFound.resetHistory(); - write(); - - const result = await execute(COPILOT_FIXTURE, true); - - expect(result.isSuccess()).to.be.true; - expect(inPortal('index.html')).to.be.true; - expect(inPortal('__copilot')).to.be.false; - expect(prompts.copilotPagesNotFound.calledOnce).to.be.true; - expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; - } + writePages([]); + + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotPagesNotFound.calledOnce).to.be.true; + expect(prompts.copilotPagesNotBuilt.called).to.be.false; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + }); + + it('still saves the portal when the build wrote no pages for Ask AI, naming it a fault of the build', async () => { + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotPagesNotBuilt.calledOnce).to.be.true; + expect(prompts.copilotPagesNotFound.called).to.be.false; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; }); }); diff --git a/test/prompts/portal/generate.test.ts b/test/prompts/portal/generate.test.ts index bbecbf3b1..645c13324 100644 --- a/test/prompts/portal/generate.test.ts +++ b/test/prompts/portal/generate.test.ts @@ -7,6 +7,33 @@ import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { SiteAddress } from '../../../src/types/portal/config/site-address.js'; describe('PortalGeneratePrompts', () => { + describe('when Ask AI gets no pages', () => { + const warned = (say: (prompts: PortalGeneratePrompts) => void) => { + const warn = sinon.stub(log, 'warn'); + try { + say(new PortalGeneratePrompts()); + } finally { + warn.restore(); + } + return stripVTControlCharacters(String(warn.firstCall.args[0])); + }; + + it('tells a portal with no pages that it has none', () => { + expect(warned((prompts) => prompts.copilotPagesNotFound())).to.equal( + 'The portal has no pages for Ask AI to answer from, so none were added to it.' + ); + }); + + it('tells a build that wrote none to generate again, not to look through the docs', () => { + const message = warned((prompts) => prompts.copilotPagesNotBuilt()); + + expect(message).to.contain('The build wrote no pages for Ask AI'); + expect(message).to.contain('Generate the portal again'); + expect(message).to.contain('support@apimatic.io'); + expect(message).to.not.contain('has no pages'); + }); + }); + describe('how Ask AI gets the pages', () => { const printed = (url: string | null) => { const site = url === null ? null : SiteAddress.parse(url, 'portal.site.url')._unsafeUnwrap(); From 984940b996628d1bbbe329cba1c53845940cd172 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Tue, 20 Oct 2026 16:56:09 +0500 Subject: [PATCH 37/41] refactor(portal): keep the Ask AI comments to a line, and drop a test that read a dependency's build The comments the review fixes added ran to several lines; each now says why in one, as the repository asks. The test that matched the chat package's minified code would break on a harmless release of it; the test that renders the chat's links under a base path stays, in a file named for what it covers. Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/src/components/copilot.tsx | 12 ++---------- portal-template/src/lib/copilot-client.ts | 11 ++--------- portal-template/src/lib/layout.tsx | 5 +---- src/types/portal/portal-copilot-entry.ts | 10 ++-------- ...t-links.test.ts => copilot-chat-links.test.ts} | 15 +-------------- 5 files changed, 8 insertions(+), 45 deletions(-) rename test/portal-template/{copilot-links.test.ts => copilot-chat-links.test.ts} (63%) diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx index fe08ca6d5..bd11a955f 100644 --- a/portal-template/src/components/copilot.tsx +++ b/portal-template/src/components/copilot.tsx @@ -23,11 +23,7 @@ export type CopilotChatProps = Readonly<{ panel: HTMLElement | null; }>; -/** - * The Ask AI chat, beside the docs layout rather than around it, so the page hydrates without waiting for - * this chunk. The module's one export, so the layout can load it on its own, and only for a portal that - * offers the chat. - */ +/** The module's one export, so the layout can load it on its own, and only for a portal that offers the chat. */ export default function CopilotChat({ copilot, open, onOpenChange, panel }: CopilotChatProps) { return ( @@ -37,11 +33,7 @@ export default function CopilotChat({ copilot, open, onOpenChange, panel }: Copi ); } -/** - * Keeps the chat's open state and the layout's as one: either side may open or close the panel. Each - * side passes on only a change of its own (both setters are stable), and setting a state to the value it - * holds changes nothing, so the two settle at once. - */ +/** Either side may open or close the panel; each passes on only its own change, so the two settle at once. */ function FollowLayout({ open, onOpenChange }: Readonly<{ open: boolean; onOpenChange: (open: boolean) => void }>) { const chat = useAIChat(); // The layout's own control closed or opened it. diff --git a/portal-template/src/lib/copilot-client.ts b/portal-template/src/lib/copilot-client.ts index 8219fa599..db8dad812 100644 --- a/portal-template/src/lib/copilot-client.ts +++ b/portal-template/src/lib/copilot-client.ts @@ -16,11 +16,7 @@ export interface AskingPage { title: string; } -/** - * A visitor's question, with the page it was asked from, as the chat's own message would carry it. The - * address keeps only the page: its query and fragment can hold what is the visitor's alone, such as an - * OAuth callback's code, and the service needs neither. - */ +/** The chat's own question, its page named without query or fragment, which can hold an OAuth callback's code. */ export function questionMessage(text: string, { href, title }: AskingPage) { const page = new URL(href); return { @@ -50,10 +46,7 @@ export class CopilotClient { readonly chatAddress = (): string => `${this.copilot.apiUrl}/v1/chat`; - /** - * Opens the session ahead of the first question, as the panel opens, so a portal the service - * turns away hides Ask AI before a visitor types. Other failures wait for the question to show them. - */ + /** Opens the session as the panel opens, so a portal the service turns away hides Ask AI before anyone types. */ readonly prepare = async (): Promise => { await this.currentSession().catch(() => undefined); }; diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index 2aaa5d811..247041465 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -22,10 +22,7 @@ export function PortalLayout({ tree, children }: LayoutProps) { type ChatProps = LayoutProps & Readonly<{ chat: NonNullable; copilot: PortalCopilot }>; -/** - * The layout with the Ask AI chat beside it, not around it: a page hydrates as soon as its own scripts - * have run, and the chat's chunk, loading meanwhile, fills the panel the layout keeps a place for. - */ +/** The chat beside the layout, not around it, so a page hydrates without waiting for the chat's chunk. */ function WithChat({ chat: Chat, copilot, tree, children }: ChatProps) { const [open, setOpen] = useState(false); const [panel, setPanel] = useState(null); diff --git a/src/types/portal/portal-copilot-entry.ts b/src/types/portal/portal-copilot-entry.ts index f970857a6..179d1e6e3 100644 --- a/src/types/portal/portal-copilot-entry.ts +++ b/src/types/portal/portal-copilot-entry.ts @@ -2,12 +2,7 @@ import { PortalConfig } from './portal-config.js'; const HEADER = "// Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead."; -/** - * The build project's `src/lib/copilot-entry.ts`, which the layout takes the Ask AI chat from. The chat's - * `import()` is written only for a portal that offers it: the build makes a chunk of any `import()` it - * finds, so a portal without the chat would otherwise ship the chat's code. Rewritten whole, as - * `portal serve` does when the block changes; rendered here so the module is tested without a build. - */ +/** The layout's `copilot-entry.ts`: the chat's `import()` only where offered, as a build chunks every one it finds. */ export class PortalCopilotEntry { private constructor(private readonly chat: boolean) {} @@ -18,8 +13,7 @@ export class PortalCopilotEntry { public toString(): string { const body = this.chat ? [ - '// In a chunk of its own, loaded only by this portal, which offers the chat. Asked for as this', - "// module runs, with the page's own scripts, rather than once the page has rendered.", + "// Asked for as the page's own scripts run, so it loads alongside them.", "import { lazy } from 'react';", '', "const chat = import('@/components/copilot');", diff --git a/test/portal-template/copilot-links.test.ts b/test/portal-template/copilot-chat-links.test.ts similarity index 63% rename from test/portal-template/copilot-links.test.ts rename to test/portal-template/copilot-chat-links.test.ts index 9fc479ca0..56b3d3235 100644 --- a/test/portal-template/copilot-links.test.ts +++ b/test/portal-template/copilot-chat-links.test.ts @@ -1,14 +1,8 @@ -import fs from 'node:fs'; -import { createRequire } from 'node:module'; import { createElement } from 'react'; import { renderToString } from 'react-dom/server'; import { expect } from 'chai'; -/** An installed module's code, as the portal's build bundles it. */ -const installed = (specifier: string) => fs.readFileSync(createRequire(import.meta.url).resolve(specifier), 'utf8'); - -// `copilot.tsx` hands the chat the service's paths unchanged, right only while the chat links them through -// the router, which adds the base once: a failure here is an upgrade that changed that, which only a browser would show. +// `copilot.tsx` passes the service's paths through unchanged: right only while the router adds the base, once. describe("the Ask AI chat's links under a base", () => { it('links a source by its path within the portal, served under the base once', async () => { // Their ES module builds: the CommonJS ones warn of a circular require when loaded. @@ -36,11 +30,4 @@ describe("the Ask AI chat's links under a base", () => { expect(html).to.contain('href="/docs/guides/intro"'); expect(html).to.contain('href="https://status.example.com"'); }); - - it("links a search's found pages the same way as the sources", () => { - const chat = installed('@fumadocs/ai-chat'); - - expect(chat).to.match(/jsxs?\(Link, \{\s*href: source\.url,/); - expect(chat).to.match(/jsxs?\(Link, \{\s*href: doc\.url,/); - }); }); From 6c3fcdf3dc1a7a3e905528ed0d224baa495041fb Mon Sep 17 00:00:00 2001 From: waseem196 Date: Wed, 21 Oct 2026 10:54:43 +0500 Subject: [PATCH 38/41] fix(portal): keep the page up when the Ask AI chat fails to load Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/without-chat-on-error.tsx | 14 ++++++++++++++ portal-template/src/lib/layout.tsx | 9 ++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 portal-template/src/components/without-chat-on-error.tsx diff --git a/portal-template/src/components/without-chat-on-error.tsx b/portal-template/src/components/without-chat-on-error.tsx new file mode 100644 index 000000000..b907d179a --- /dev/null +++ b/portal-template/src/components/without-chat-on-error.tsx @@ -0,0 +1,14 @@ +import { Component, type ReactNode } from 'react'; + +/** Drops the chat if its chunk fails to load (say, after a redeploy), so the page stays up without Ask AI rather than failing whole. */ +export class WithoutChatOnError extends Component, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + return this.state.failed ? null : this.props.children; + } +} diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index 247041465..14b233dc9 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -2,6 +2,7 @@ import { Suspense, useMemo, useState, type ReactNode } from 'react'; import type * as PageTree from 'fumadocs-core/page-tree'; import { DocsLayout as NotebookLayout } from 'fumadocs-ui/layouts/notebook'; import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; +import { WithoutChatOnError } from '@/components/without-chat-on-error'; import { CopilotChat } from './copilot-entry'; import type { PortalCopilot } from './portal-types'; import { baseOptions } from './layout.shared'; @@ -37,9 +38,11 @@ function WithChat({ chat: Chat, copilot, tree, children }: ChatProps) { {children} {/* The prerender waits for the chunk, so each page carries the chat's button. */} - - - + + + + + ); } From 15e68509f36853aff1a63376d2a0589ed28b4a8c Mon Sep 17 00:00:00 2001 From: waseem196 Date: Wed, 21 Oct 2026 18:23:55 +0500 Subject: [PATCH 39/41] refactor(portal): pass the site address to the link without a nested template literal Co-Authored-By: Claude Opus 5.5 (1M context) --- src/prompts/portal/generate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 75b3f5ff6..206fd87bd 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -127,7 +127,7 @@ export class PortalGeneratePrompts { return; } log.info( - `Ask AI picks up these pages by itself once the portal is deployed at ${f.link(`${site}`)}. ` + + `Ask AI picks up these pages by itself once the portal is deployed at ${f.link(site.toString())}. ` + `If the Ask AI service cannot reach it there, run ${syncCommand()} after deploying instead.` ); } From c474169bbe85273ffcc7dfc3d9cbc4c843711db8 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Wed, 21 Oct 2026 18:25:03 +0500 Subject: [PATCH 40/41] refactor(portal): pass the service address to the link without a nested template literal Co-Authored-By: Claude Opus 5.5 (1M context) --- src/prompts/portal/copilot/sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts index f39b6a8a9..8771e764a 100644 --- a/src/prompts/portal/copilot/sync.ts +++ b/src/prompts/portal/copilot/sync.ts @@ -85,7 +85,7 @@ export class PortalCopilotSyncPrompts { switch (error.code) { case ServiceErrorCode.NetworkError: log.error( - `The Ask AI service at ${f.link(`${service}`)} could not be reached. Check the address and try again.` + `The Ask AI service at ${f.link(service.toString())} could not be reached. Check the address and try again.` ); return; case ServiceErrorCode.Forbidden: From 640be819433fd7e5440d70d41b02fa864b652fe2 Mon Sep 17 00:00:00 2001 From: waseem196 Date: Wed, 21 Oct 2026 18:26:55 +0500 Subject: [PATCH 41/41] refactor(portal): read one archive entry synchronously, as the zip library does Co-Authored-By: Claude Opus 5.5 (1M context) --- src/infrastructure/zip-service.ts | 2 +- src/types/portal/copilot/generated-portal-context.ts | 2 +- test/infrastructure/zip-service.test.ts | 10 ++++------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/infrastructure/zip-service.ts b/src/infrastructure/zip-service.ts index a191c53b5..3dea906df 100644 --- a/src/infrastructure/zip-service.ts +++ b/src/infrastructure/zip-service.ts @@ -30,7 +30,7 @@ export class ZipService { } /** One file of an archive, as text, without expanding the rest; null for an archive without it. */ - public async readEntry(sourceFile: FilePath, entryPath: string): Promise> { + public readEntry(sourceFile: FilePath, entryPath: string): Result { try { const entry = new AdmZip(sourceFile.toString()).getEntry(entryPath); return ok(entry === null ? null : entry.getData().toString('utf8')); diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts index 5e527eeb8..e13513e98 100644 --- a/src/types/portal/copilot/generated-portal-context.ts +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -91,7 +91,7 @@ export class GeneratedPortalContext { return ok(null); } // Archived from the built directory, so the bundle is at the same path inside. - return await this.zipService.readEntry(archive, `${COPILOT_DIRECTORY_NAME}/${BUNDLE_FILE_NAME}`); + return this.zipService.readEntry(archive, `${COPILOT_DIRECTORY_NAME}/${BUNDLE_FILE_NAME}`); } catch (error) { return err(errorMessage(error)); } diff --git a/test/infrastructure/zip-service.test.ts b/test/infrastructure/zip-service.test.ts index 992647ec8..51614ec4b 100644 --- a/test/infrastructure/zip-service.test.ts +++ b/test/infrastructure/zip-service.test.ts @@ -86,22 +86,20 @@ describe('ZipService', () => { write('index.html', ''); (await new ZipService().archive(source, archive))._unsafeUnwrap(); - expect((await new ZipService().readEntry(archive, '__copilot/bundle.json'))._unsafeUnwrap()).to.equal( - '{"pages":[]}' - ); + expect(new ZipService().readEntry(archive, '__copilot/bundle.json')._unsafeUnwrap()).to.equal('{"pages":[]}'); }); it('is null for a file the archive does not hold', async () => { write('index.html', ''); (await new ZipService().archive(source, archive))._unsafeUnwrap(); - expect((await new ZipService().readEntry(archive, '__copilot/bundle.json'))._unsafeUnwrap()).to.be.null; + expect(new ZipService().readEntry(archive, '__copilot/bundle.json')._unsafeUnwrap()).to.be.null; }); - it('reports a file that is not an archive', async () => { + it('reports a file that is not an archive', () => { fs.writeFileSync(archive.toString(), 'not a zip'); - expect((await new ZipService().readEntry(archive, 'index.html'))._unsafeUnwrapErr()).to.not.be.empty; + expect(new ZipService().readEntry(archive, 'index.html')._unsafeUnwrapErr()).to.not.be.empty; }); });