pulumi-package-docs 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +98 -4
  2. package/bin/args.js +62 -3
  3. package/bin/args.test.js +60 -0
  4. package/bin/cli-bun.js +55 -23
  5. package/bin/cli.js +74 -28
  6. package/bin/cli.test.js +18 -0
  7. package/bin/static.js +196 -0
  8. package/bin/static.test.js +176 -0
  9. package/dist/client/assets/MemberDetail-Du4HYTda.js +1 -0
  10. package/dist/client/assets/{token-oQmZfAP3.js → examples-zkHFEaMt.js} +1 -1
  11. package/dist/client/assets/index-g58gbZKl.js +12 -0
  12. package/dist/client/assets/matchContext-BfDTxnb0.js +2 -0
  13. package/dist/client/assets/providers._name-DztqmroK.js +1 -0
  14. package/dist/client/assets/providers._name.functions._token-Dds2hD5R.js +1 -0
  15. package/dist/client/assets/providers._name.functions._token-DkZe4R_v.js +1 -0
  16. package/dist/client/assets/providers._name.index-B6cK3ZCZ.js +1 -0
  17. package/dist/client/assets/providers._name.index-CvFTUtAs.js +1 -0
  18. package/dist/client/assets/providers._name.resources._token-Bf7TPYcB.js +1 -0
  19. package/dist/client/assets/providers._name.resources._token-CIjv9yb8.js +1 -0
  20. package/dist/client/assets/routes-Dz3Kksbm.js +1 -0
  21. package/dist/mcp/stdio.js +449 -0
  22. package/dist/server/assets/{MemberDetail-C5sAHNZW.js → MemberDetail-CdHUSmQD.js} +2 -1
  23. package/dist/server/assets/{_tanstack-start-manifest_v-B0J-S-em.js → _tanstack-start-manifest_v-Drm3Ru6D.js} +15 -15
  24. package/dist/server/assets/api-D49ZDhMh.js +27 -0
  25. package/dist/server/assets/{providers._name.functions._token-Gkw4zt3M.js → providers._name.functions._token-BvkwmQ-L.js} +3 -2
  26. package/dist/server/assets/{providers._name.index-CEXkl-76.js → providers._name.index-DBkfEjsy.js} +3 -2
  27. package/dist/server/assets/{providers._name.resources._token-R_i2MmRl.js → providers._name.resources._token-Sl31Qlvd.js} +3 -2
  28. package/dist/server/assets/{schema.server-B-VvU1qb.js → providers.server-B1ZHZ5jE.js} +48 -1
  29. package/dist/server/assets/{router-1INob9fh.js → router-_zs8P-B2.js} +24 -87
  30. package/dist/server/assets/{routes-4jiXnS1p.js → routes-BUxagg9r.js} +1 -1
  31. package/dist/server/assets/token-DBEFeZIN.js +92 -0
  32. package/dist/server/server.js +987 -375
  33. package/examples/github-pages.yml +56 -0
  34. package/examples/gitlab-ci.yml +22 -0
  35. package/package.json +7 -3
  36. package/src/components/ThemeToggle.tsx +37 -0
  37. package/src/lib/pulumi/api.ts +18 -69
  38. package/src/lib/pulumi/mcp.server.test.ts +112 -0
  39. package/src/lib/pulumi/mcp.server.ts +11 -6
  40. package/src/lib/pulumi/providers.server.ts +80 -0
  41. package/src/lib/pulumi/stdio.server.ts +16 -0
  42. package/src/lib/pulumi/token.test.ts +44 -1
  43. package/src/lib/pulumi/token.ts +28 -0
  44. package/src/lib/static-docs.ts +15 -0
  45. package/src/router.tsx +3 -0
  46. package/src/routes/__root.tsx +6 -3
  47. package/src/routes/providers.$name.functions.$token.tsx +7 -4
  48. package/src/routes/providers.$name.index.tsx +2 -2
  49. package/src/routes/providers.$name.resources.$token.tsx +7 -4
  50. package/vite.config.ts +41 -1
  51. package/dist/client/assets/MemberDetail-BS9DbqEt.js +0 -1
  52. package/dist/client/assets/index-2FzxAhzN.js +0 -13
  53. package/dist/client/assets/providers._name-FmJEVkdV.js +0 -1
  54. package/dist/client/assets/providers._name.functions._token-BzjmNHea.js +0 -1
  55. package/dist/client/assets/providers._name.functions._token-D768U6I-.js +0 -1
  56. package/dist/client/assets/providers._name.index-C42yQ95z.js +0 -1
  57. package/dist/client/assets/providers._name.index-Dm0YiZWP.js +0 -1
  58. package/dist/client/assets/providers._name.resources._token-CIYPoqyA.js +0 -1
  59. package/dist/client/assets/providers._name.resources._token-D0OLvgo_.js +0 -1
  60. package/dist/client/assets/routes-DKNWlgPZ.js +0 -1
  61. package/dist/client/assets/useSelector-BIt42tGI.js +0 -1
  62. package/dist/server/assets/api-C55bS4R6.js +0 -64
@@ -0,0 +1,56 @@
1
+ # Publishes the local provider docs for this Pulumi project to GitHub Pages.
2
+ # Copy to .github/workflows/docs.yml in your Pulumi project's repo, then enable
3
+ # Pages for the repo with "Source: GitHub Actions" (Settings -> Pages).
4
+ name: Docs
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: read
13
+ pages: write
14
+ id-token: write
15
+
16
+ # One deployment at a time; don't cancel one that's already publishing.
17
+ concurrency:
18
+ group: pages
19
+ cancel-in-progress: false
20
+
21
+ jobs:
22
+ build:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v7
26
+
27
+ - uses: actions/setup-node@v7
28
+ with:
29
+ node-version: 24
30
+
31
+ # Puts the `pulumi` CLI on PATH; the docs are generated from the schemas
32
+ # it fetches for each package in Pulumi.yaml.
33
+ - uses: pulumi/actions@v7
34
+
35
+ # A project site is served from https://<owner>.github.io/<repo>/, so the
36
+ # site is built for that path. For a user/org site (<owner>.github.io),
37
+ # drop --base.
38
+ - name: Generate docs
39
+ run: npx -y pulumi-package-docs static --out ./site --base "/${GITHUB_REPOSITORY#*/}/"
40
+
41
+ - uses: actions/upload-pages-artifact@v5
42
+ with:
43
+ path: ./site
44
+ # The generated site includes a `.nojekyll` file, and this action
45
+ # drops dotfiles unless asked not to.
46
+ include-hidden-files: true
47
+
48
+ deploy:
49
+ needs: build
50
+ runs-on: ubuntu-latest
51
+ environment:
52
+ name: github-pages
53
+ url: ${{ steps.deployment.outputs.page_url }}
54
+ steps:
55
+ - id: deployment
56
+ uses: actions/deploy-pages@v5
@@ -0,0 +1,22 @@
1
+ # Publishes the local provider docs for this Pulumi project to GitLab Pages.
2
+ # Copy this job into your Pulumi project's .gitlab-ci.yml.
3
+ pages:
4
+ image: node:24
5
+ stage: deploy
6
+ before_script:
7
+ # Puts the `pulumi` CLI on PATH; the docs are generated from the schemas
8
+ # it fetches for each package in Pulumi.yaml.
9
+ - curl -fsSL https://get.pulumi.com | sh
10
+ - export PATH="$PATH:$HOME/.pulumi/bin"
11
+ script:
12
+ # CI_PAGES_URL is where this site will be served from — its path is what
13
+ # the docs have to be built for ("/" with unique domains enabled, "/<project>/"
14
+ # otherwise).
15
+ - export DOCS_BASE="$(node -e 'console.log(new URL(process.env.CI_PAGES_URL).pathname)')"
16
+ - npx -y pulumi-package-docs static --out public --base "$DOCS_BASE"
17
+ artifacts:
18
+ paths:
19
+ # GitLab Pages publishes this directory, and only under this name.
20
+ - public
21
+ rules:
22
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulumi-package-docs",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Local documentation viewer for the parameterized/local Pulumi providers used by a Pulumi project.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "bin",
20
20
  "dist",
21
+ "examples",
21
22
  "src",
22
23
  "tsconfig.json",
23
24
  "vite.config.ts"
@@ -28,11 +29,14 @@
28
29
  "scripts": {
29
30
  "dev": "vite dev --port 3000",
30
31
  "generate-routes": "tsr generate",
31
- "build": "vite build",
32
+ "build": "vite build && npm run build:mcp",
33
+ "build:mcp": "vite build --config vite.mcp.config.ts",
32
34
  "preview": "vite preview",
33
35
  "start": "node bin/cli.js",
36
+ "static": "node bin/cli.js static",
34
37
  "compile:bun": "node bin/generate-embedded-assets.js && bun build ./bin/cli-bun.js --compile --outfile pulumi-package-docs",
35
38
  "test": "vitest run",
39
+ "test:stdio": "node scripts/smoke-test-stdio.js node bin/cli.js --stdio && node scripts/smoke-test-stdio.js ./pulumi-package-docs --stdio",
36
40
  "test:watch": "vitest",
37
41
  "lint": "oxlint",
38
42
  "format": "oxfmt . && oxlint --fix",
@@ -54,7 +58,7 @@
54
58
  "@types/node": "^26.6.1",
55
59
  "@types/react": "^19.2.0",
56
60
  "@types/react-dom": "^19.2.0",
57
- "oxfmt": "^0.68.0",
61
+ "oxfmt": "^0.70.0",
58
62
  "oxlint": "^1.83.0",
59
63
  "typescript": "^7.0.2",
60
64
  "vitest": "^5.0.1"
@@ -119,3 +119,40 @@ function MonitorIcon() {
119
119
  </IconSvg>
120
120
  )
121
121
  }
122
+
123
+ // Wires the static build's theme switch up without hydration: one delegated
124
+ // click listener, using the same `data-theme` + localStorage contract as
125
+ // ThemeToggle above (and as the pre-hydration script in `__root.tsx`).
126
+ const staticThemeScript = `(function(){var g=document.currentScript.previousElementSibling;if(!g)return;
127
+ function apply(m){try{if(m==='system'){delete document.documentElement.dataset.theme;localStorage.removeItem('theme')}else{document.documentElement.dataset.theme=m;localStorage.setItem('theme',m)}}catch(e){if(m==='system'){delete document.documentElement.dataset.theme}else{document.documentElement.dataset.theme=m}}sync(m)}
128
+ function sync(m){var b=g.querySelectorAll('button[data-theme-mode]');for(var i=0;i<b.length;i++){b[i].setAttribute('aria-pressed',String(b[i].getAttribute('data-theme-mode')===m))}}
129
+ g.addEventListener('click',function(e){var b=e.target.closest('button[data-theme-mode]');if(b){apply(b.getAttribute('data-theme-mode'))}});
130
+ var s=null;try{s=localStorage.getItem('theme')}catch(e){}
131
+ sync(s==='light'||s==='dark'?s:'system')})()`
132
+
133
+ /**
134
+ * The theme switch for the static build. Same markup as `ThemeToggle`, but
135
+ * driven by an inline script rather than React, since static pages ship
136
+ * without the hydration bundle.
137
+ */
138
+ export function StaticThemeToggle() {
139
+ return (
140
+ <>
141
+ <div className="theme-switch" role="group" aria-label="Theme">
142
+ {OPTIONS.map(({ mode, label, Icon }) => (
143
+ <button
144
+ key={mode}
145
+ type="button"
146
+ className="theme-switch-option"
147
+ data-theme-mode={mode}
148
+ aria-pressed={mode === 'system'}
149
+ aria-label={label}
150
+ >
151
+ <Icon />
152
+ </button>
153
+ ))}
154
+ </div>
155
+ <script dangerouslySetInnerHTML={{ __html: staticThemeScript }} />
156
+ </>
157
+ )
158
+ }
@@ -1,77 +1,26 @@
1
1
  import { createServerFn } from '@tanstack/react-start'
2
2
 
3
- import { getTargetDir, loadPulumiProject } from '@/lib/pulumi/discovery.server'
4
- import { resolveProviderSchema } from '@/lib/pulumi/schema.server'
5
- import type { ProviderEntry, PulumiProject } from '@/lib/pulumi/types'
6
-
7
- export interface ProviderSummary {
8
- name: string
9
- source: string
10
- version?: string
11
- parameters?: string[]
12
- resourceCount: number
13
- functionCount: number
14
- origin: ProviderEntry['origin']
15
- error?: string
16
- }
17
-
18
- export interface ProjectSummary {
19
- targetDir: string
20
- project: Pick<PulumiProject, 'root' | 'name' | 'runtime'> | null
21
- providers: ProviderSummary[]
22
- }
3
+ import {
4
+ loadProjectSummary,
5
+ loadProviderDetail,
6
+ } from '@/lib/pulumi/providers.server'
7
+ import type {
8
+ ProjectSummary,
9
+ ProviderDetail,
10
+ } from '@/lib/pulumi/providers.server'
11
+
12
+ export type {
13
+ ProjectSummary,
14
+ ProviderDetail,
15
+ ProviderSummary,
16
+ } from '@/lib/pulumi/providers.server'
23
17
 
24
18
  export const listProviders = createServerFn({ method: 'GET' }).handler(
25
- async (): Promise<ProjectSummary> => {
26
- const targetDir = getTargetDir()
27
- const project = loadPulumiProject(targetDir)
28
-
29
- if (!project) {
30
- return { targetDir, project: null, providers: [] }
31
- }
32
-
33
- const entries = await Promise.all(
34
- project.packages.map((ref) => resolveProviderSchema(project.root, ref)),
35
- )
36
-
37
- const providers: ProviderSummary[] = entries.map((entry) => ({
38
- name: entry.ref.name,
39
- source: entry.ref.source,
40
- version: entry.ref.version,
41
- parameters: entry.ref.parameters,
42
- resourceCount: Object.keys(entry.schema?.resources ?? {}).length,
43
- functionCount: Object.keys(entry.schema?.functions ?? {}).length,
44
- origin: entry.origin,
45
- error: entry.error,
46
- }))
47
-
48
- return {
49
- targetDir,
50
- project: {
51
- root: project.root,
52
- name: project.name,
53
- runtime: project.runtime,
54
- },
55
- providers,
56
- }
57
- },
19
+ (): Promise<ProjectSummary> => loadProjectSummary(),
58
20
  )
59
21
 
60
- export interface ProviderDetail extends ProviderEntry {
61
- /** The Pulumi project's runtime (e.g. "nodejs", "python", "go"), used to render Inputs/Outputs names in that language's casing. */
62
- runtime?: string
63
- }
64
-
65
22
  export const getProvider = createServerFn({ method: 'GET' })
66
23
  .validator((name: string) => name)
67
- .handler(async ({ data: name }): Promise<ProviderDetail | null> => {
68
- const targetDir = getTargetDir()
69
- const project = loadPulumiProject(targetDir)
70
- if (!project) return null
71
-
72
- const ref = project.packages.find((p) => p.name === name)
73
- if (!ref) return null
74
-
75
- const entry = await resolveProviderSchema(project.root, ref)
76
- return { ...entry, runtime: project.runtime }
77
- })
24
+ .handler(({ data: name }): Promise<ProviderDetail | null> =>
25
+ loadProviderDetail(name),
26
+ )
@@ -0,0 +1,112 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
6
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
7
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
8
+
9
+ import { createMcpServer } from '@/lib/pulumi/mcp.server'
10
+
11
+ let projectDir: string
12
+ const originalEnv = process.env.PULUMI_LOCAL_DOCS_DIR
13
+
14
+ beforeEach(() => {
15
+ projectDir = mkdtempSync(join(tmpdir(), 'pulumi-package-docs-mcp-test-'))
16
+ writeFileSync(
17
+ join(projectDir, 'Pulumi.yaml'),
18
+ `name: my-project\nruntime: nodejs\n`,
19
+ )
20
+ process.env.PULUMI_LOCAL_DOCS_DIR = projectDir
21
+ })
22
+
23
+ afterEach(() => {
24
+ rmSync(projectDir, { recursive: true, force: true })
25
+ if (originalEnv === undefined) delete process.env.PULUMI_LOCAL_DOCS_DIR
26
+ else process.env.PULUMI_LOCAL_DOCS_DIR = originalEnv
27
+ })
28
+
29
+ /**
30
+ * Drives the MCP server the same way the stdio entry point does — a plain in-process
31
+ * server with a transport attached, no HTTP server and no TanStack Start runtime.
32
+ */
33
+ async function connectClient(): Promise<Client> {
34
+ const [clientTransport, serverTransport] =
35
+ InMemoryTransport.createLinkedPair()
36
+ const client = new Client({ name: 'test-client', version: '0.0.0' })
37
+ await Promise.all([
38
+ createMcpServer().connect(serverTransport),
39
+ client.connect(clientTransport),
40
+ ])
41
+ return client
42
+ }
43
+
44
+ function parseTextResult(result: unknown): unknown {
45
+ const { content } = result as {
46
+ content?: Array<{ type: string; text: string }>
47
+ }
48
+ expect(content?.[0]?.type).toBe('text')
49
+ return JSON.parse(content![0]!.text)
50
+ }
51
+
52
+ describe('createMcpServer', () => {
53
+ it('advertises the provider docs tools', async () => {
54
+ const client = await connectClient()
55
+
56
+ const { tools } = await client.listTools()
57
+
58
+ expect(tools.map((tool) => tool.name).sort()).toEqual([
59
+ 'get_function',
60
+ 'get_resource',
61
+ 'list_functions',
62
+ 'list_providers',
63
+ 'list_resources',
64
+ 'search_members',
65
+ ])
66
+ await client.close()
67
+ })
68
+
69
+ it('reports the discovered project from list_providers', async () => {
70
+ const client = await connectClient()
71
+
72
+ const result = await client.callTool({ name: 'list_providers' })
73
+
74
+ expect(parseTextResult(result)).toMatchObject({
75
+ targetDir: projectDir,
76
+ project: { root: projectDir, name: 'my-project', runtime: 'nodejs' },
77
+ providers: [],
78
+ })
79
+ await client.close()
80
+ })
81
+
82
+ it('returns no matches from search_members when the project has no packages', async () => {
83
+ const client = await connectClient()
84
+
85
+ const result = await client.callTool({
86
+ name: 'search_members',
87
+ arguments: { query: 'bucket' },
88
+ })
89
+
90
+ expect(parseTextResult(result)).toEqual({
91
+ query: 'bucket',
92
+ matchCount: 0,
93
+ matches: [],
94
+ })
95
+ await client.close()
96
+ })
97
+
98
+ it('surfaces a tool error for a provider that is not declared in Pulumi.yaml', async () => {
99
+ const client = await connectClient()
100
+
101
+ const result = await client.callTool({
102
+ name: 'list_resources',
103
+ arguments: { provider: 'nope' },
104
+ })
105
+
106
+ expect(result.isError).toBe(true)
107
+ expect(JSON.stringify(result.content)).toContain(
108
+ 'No local provider named \\"nope\\"',
109
+ )
110
+ await client.close()
111
+ })
112
+ })
@@ -2,8 +2,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
3
3
  import { z } from 'zod'
4
4
 
5
- import { getProvider, listProviders } from '@/lib/pulumi/api'
6
- import type { ProviderDetail } from '@/lib/pulumi/api'
7
5
  import { resolveDescription } from '@/lib/pulumi/description'
8
6
  import { getTargetDir, loadPulumiProject } from '@/lib/pulumi/discovery.server'
9
7
  import {
@@ -14,6 +12,11 @@ import {
14
12
  } from '@/lib/pulumi/examples'
15
13
  import { formatType } from '@/lib/pulumi/format'
16
14
  import { formatPropertyName } from '@/lib/pulumi/naming'
15
+ import {
16
+ loadProjectSummary,
17
+ loadProviderDetail,
18
+ } from '@/lib/pulumi/providers.server'
19
+ import type { ProviderDetail } from '@/lib/pulumi/providers.server'
17
20
  import { resolveProviderSchema } from '@/lib/pulumi/schema.server'
18
21
  import { tokenDisplayName } from '@/lib/pulumi/token'
19
22
  import type {
@@ -30,7 +33,7 @@ function textResult(data: unknown): CallToolResult {
30
33
  async function requireProvider(
31
34
  providerName: string,
32
35
  ): Promise<ProviderDetail & { schema: NonNullable<ProviderDetail['schema']> }> {
33
- const detail = await getProvider({ data: providerName })
36
+ const detail = await loadProviderDetail(providerName)
34
37
  if (!detail) {
35
38
  throw new Error(
36
39
  `No local provider named "${providerName}" was found. Call list_providers to see the providers declared in this project's Pulumi.yaml.`,
@@ -100,8 +103,10 @@ function memberDetail(
100
103
  }
101
104
 
102
105
  /**
103
- * Builds a fresh MCP server with the local-provider-docs tools registered. Called once
104
- * per HTTP request (see `src/routes/mcp.ts`) so concurrent requests never share state.
106
+ * Builds a fresh MCP server with the local-provider-docs tools registered. Transport
107
+ * agnostic: `src/routes/mcp.ts` calls it once per HTTP request (so concurrent requests
108
+ * never share state), and `src/lib/pulumi/stdio.server.ts` calls it once for the
109
+ * lifetime of a stdio process.
105
110
  */
106
111
  export function createMcpServer(): McpServer {
107
112
  const server = new McpServer({
@@ -116,7 +121,7 @@ export function createMcpServer(): McpServer {
116
121
  description:
117
122
  "List the local/parameterized Pulumi providers declared in this project's Pulumi.yaml (added with `pulumi package add`), with their source, version, and resource/function counts.",
118
123
  },
119
- async () => textResult(await listProviders()),
124
+ async () => textResult(await loadProjectSummary()),
120
125
  )
121
126
 
122
127
  server.registerTool(
@@ -0,0 +1,80 @@
1
+ import { getTargetDir, loadPulumiProject } from '@/lib/pulumi/discovery.server'
2
+ import { resolveProviderSchema } from '@/lib/pulumi/schema.server'
3
+ import type { ProviderEntry, PulumiProject } from '@/lib/pulumi/types'
4
+
5
+ export interface ProviderSummary {
6
+ name: string
7
+ source: string
8
+ version?: string
9
+ parameters?: string[]
10
+ resourceCount: number
11
+ functionCount: number
12
+ origin: ProviderEntry['origin']
13
+ error?: string
14
+ }
15
+
16
+ export interface ProjectSummary {
17
+ targetDir: string
18
+ project: Pick<PulumiProject, 'root' | 'name' | 'runtime'> | null
19
+ providers: ProviderSummary[]
20
+ }
21
+
22
+ export interface ProviderDetail extends ProviderEntry {
23
+ /** The Pulumi project's runtime (e.g. "nodejs", "python", "go"), used to render Inputs/Outputs names in that language's casing. */
24
+ runtime?: string
25
+ }
26
+
27
+ /**
28
+ * The project + provider summaries shown on the index route and by the MCP
29
+ * `list_providers` tool. Plain async functions (no TanStack Start server-fn
30
+ * wrapper) so they can run either inside the web server or in the stdio MCP
31
+ * process, which has no HTTP server at all.
32
+ */
33
+ export async function loadProjectSummary(): Promise<ProjectSummary> {
34
+ const targetDir = getTargetDir()
35
+ const project = loadPulumiProject(targetDir)
36
+
37
+ if (!project) {
38
+ return { targetDir, project: null, providers: [] }
39
+ }
40
+
41
+ const entries = await Promise.all(
42
+ project.packages.map((ref) => resolveProviderSchema(project.root, ref)),
43
+ )
44
+
45
+ const providers: ProviderSummary[] = entries.map((entry) => ({
46
+ name: entry.ref.name,
47
+ source: entry.ref.source,
48
+ version: entry.ref.version,
49
+ parameters: entry.ref.parameters,
50
+ resourceCount: Object.keys(entry.schema?.resources ?? {}).length,
51
+ functionCount: Object.keys(entry.schema?.functions ?? {}).length,
52
+ origin: entry.origin,
53
+ error: entry.error,
54
+ }))
55
+
56
+ return {
57
+ targetDir,
58
+ project: {
59
+ root: project.root,
60
+ name: project.name,
61
+ runtime: project.runtime,
62
+ },
63
+ providers,
64
+ }
65
+ }
66
+
67
+ /** The fully resolved schema for one local provider, or null if the project or provider isn't found. */
68
+ export async function loadProviderDetail(
69
+ name: string,
70
+ ): Promise<ProviderDetail | null> {
71
+ const targetDir = getTargetDir()
72
+ const project = loadPulumiProject(targetDir)
73
+ if (!project) return null
74
+
75
+ const ref = project.packages.find((p) => p.name === name)
76
+ if (!ref) return null
77
+
78
+ const entry = await resolveProviderSchema(project.root, ref)
79
+ return { ...entry, runtime: project.runtime }
80
+ }
@@ -0,0 +1,16 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
2
+
3
+ import { createMcpServer } from '@/lib/pulumi/mcp.server'
4
+
5
+ /**
6
+ * Serves the same provider-docs tools as the `/mcp` HTTP route over stdio, for MCP
7
+ * clients that spawn a server process instead of connecting to a URL.
8
+ *
9
+ * Unlike the HTTP route this is a single long-lived server (one process, one client),
10
+ * and stdout is the protocol channel — nothing on this path may write to it, so any
11
+ * diagnostics belong on stderr.
12
+ */
13
+ export async function runStdioMcpServer(): Promise<void> {
14
+ const server = createMcpServer()
15
+ await server.connect(new StdioServerTransport())
16
+ }
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { tokenDisplayName } from '@/lib/pulumi/token'
3
+ import {
4
+ findTokenBySlug,
5
+ tokenDisplayName,
6
+ tokenSlug,
7
+ } from '@/lib/pulumi/token'
4
8
 
5
9
  describe('tokenDisplayName', () => {
6
10
  it('returns the segment after the last colon for a resource token', () => {
@@ -19,3 +23,42 @@ describe('tokenDisplayName', () => {
19
23
  expect(tokenDisplayName('plainName')).toBe('plainName')
20
24
  })
21
25
  })
26
+
27
+ describe('tokenSlug', () => {
28
+ it('replaces the token separators with dashes', () => {
29
+ expect(tokenSlug('random:index/randomString:RandomString')).toBe(
30
+ 'random-index-randomString-RandomString',
31
+ )
32
+ })
33
+
34
+ it('leaves an already-safe token unchanged', () => {
35
+ expect(tokenSlug('plainName')).toBe('plainName')
36
+ })
37
+
38
+ it('collapses a run of unsafe characters into a single dash', () => {
39
+ expect(tokenSlug('aws:s3//bucket:Bucket')).toBe('aws-s3-bucket-Bucket')
40
+ })
41
+ })
42
+
43
+ describe('findTokenBySlug', () => {
44
+ const tokens = [
45
+ 'random:index/randomString:RandomString',
46
+ 'random:index/randomPet:RandomPet',
47
+ ]
48
+
49
+ it('finds the token a slug was made from', () => {
50
+ expect(findTokenBySlug(tokens, 'random-index-randomPet-RandomPet')).toBe(
51
+ 'random:index/randomPet:RandomPet',
52
+ )
53
+ })
54
+
55
+ it('also accepts the raw token', () => {
56
+ expect(
57
+ findTokenBySlug(tokens, 'random:index/randomString:RandomString'),
58
+ ).toBe('random:index/randomString:RandomString')
59
+ })
60
+
61
+ it('returns undefined when nothing matches', () => {
62
+ expect(findTokenBySlug(tokens, 'nope')).toBeUndefined()
63
+ })
64
+ })
@@ -8,3 +8,31 @@ export function tokenDisplayName(token: string): string {
8
8
  const short = token.split(':').pop()
9
9
  return short && short.length > 0 ? short : token
10
10
  }
11
+
12
+ /**
13
+ * A URL- and filesystem-safe form of a token, used as the `$token` route param.
14
+ *
15
+ * Tokens contain `:` and `/`, which only survive a URL as percent-escapes —
16
+ * and static hosts (GitHub/GitLab Pages) decode those before matching a file,
17
+ * so `random%3Aindex%2FrandomString%3ARandomString` would never resolve to the
18
+ * page generated for it. Replacing the separators keeps one URL shape that
19
+ * works both when served dynamically and as prerendered files on disk.
20
+ */
21
+ export function tokenSlug(token: string): string {
22
+ return token.replace(/[^a-zA-Z0-9._~-]+/g, '-')
23
+ }
24
+
25
+ /**
26
+ * Resolves a `$token` route param back to the schema token it names. Accepts
27
+ * either a slug (what links produce) or the raw token, so a URL pasted from
28
+ * schema output still works.
29
+ */
30
+ export function findTokenBySlug(
31
+ tokens: Iterable<string>,
32
+ slug: string,
33
+ ): string | undefined {
34
+ for (const token of tokens) {
35
+ if (token === slug || tokenSlug(token) === slug) return token
36
+ }
37
+ return undefined
38
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * True when the app is being built as a static site (`pulumi-package-docs
3
+ * static`), for publishing to GitHub/GitLab Pages.
4
+ *
5
+ * A static build has no server behind it, so its pages can't call the server
6
+ * functions in `@/lib/pulumi/api` on the client. Every page is prerendered
7
+ * with its data already baked in and shipped without the hydration script:
8
+ * links then behave as ordinary anchors and each page load is self-contained,
9
+ * which also keeps a large provider's schema out of the pages that don't need
10
+ * it.
11
+ *
12
+ * Vite inlines `import.meta.env.VITE_*` at build time, so this folds away to a
13
+ * constant in both builds.
14
+ */
15
+ export const isStaticDocs = import.meta.env.VITE_STATIC_DOCS === 'true'
package/src/router.tsx CHANGED
@@ -4,6 +4,9 @@ import { routeTree } from './routeTree.gen'
4
4
  export function getRouter() {
5
5
  const router = createTanStackRouter({
6
6
  routeTree,
7
+ // Set from Vite's `base`, so a static build published under a project
8
+ // path (e.g. https://<user>.github.io/<repo>/) still links correctly.
9
+ basepath: import.meta.env.BASE_URL,
7
10
  scrollRestoration: true,
8
11
  defaultPreload: 'intent',
9
12
  defaultPreloadStaleTime: 0,
@@ -6,7 +6,8 @@ import {
6
6
  } from '@tanstack/react-router'
7
7
 
8
8
  import { PulumiLogo } from '@/components/PulumiLogo'
9
- import { ThemeToggle } from '@/components/ThemeToggle'
9
+ import { StaticThemeToggle, ThemeToggle } from '@/components/ThemeToggle'
10
+ import { isStaticDocs } from '@/lib/static-docs'
10
11
  import appCss from '../styles.css?url'
11
12
 
12
13
  // Runs before hydration so an explicit theme choice applies before first
@@ -57,12 +58,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
57
58
  <span className="site-brand-divider" aria-hidden="true" />
58
59
  <span className="site-brand-name">Package Docs</span>
59
60
  </Link>
60
- <ThemeToggle />
61
+ {isStaticDocs ? <StaticThemeToggle /> : <ThemeToggle />}
61
62
  </div>
62
63
  </header>
63
64
  {children}
64
65
 
65
- <Scripts />
66
+ {/* A static build has no server to call back into, so it ships
67
+ without the hydration script and navigates as plain links. */}
68
+ {isStaticDocs ? null : <Scripts />}
66
69
  </body>
67
70
  </html>
68
71
  )