kennel 0.1.8

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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/bin/kennel.mjs +3 -0
  4. package/dist/automations/notifications.d.ts +40 -0
  5. package/dist/automations/notifications.js +220 -0
  6. package/dist/cli/dispatch.d.ts +1 -0
  7. package/dist/cli/dispatch.js +51 -0
  8. package/dist/cli/usage.d.ts +1 -0
  9. package/dist/cli/usage.js +14 -0
  10. package/dist/deploy/cloudflare.d.ts +6 -0
  11. package/dist/deploy/cloudflare.js +91 -0
  12. package/dist/deploy/environment.d.ts +2 -0
  13. package/dist/deploy/environment.js +47 -0
  14. package/dist/deploy/smoke-tests.d.ts +3 -0
  15. package/dist/deploy/smoke-tests.js +71 -0
  16. package/dist/deploy/trigger-dev.d.ts +2 -0
  17. package/dist/deploy/trigger-dev.js +255 -0
  18. package/dist/main.d.ts +1 -0
  19. package/dist/main.js +8 -0
  20. package/dist/mcp/agent-tools.d.ts +8 -0
  21. package/dist/mcp/agent-tools.js +247 -0
  22. package/dist/mcp/config.d.ts +25 -0
  23. package/dist/mcp/config.js +171 -0
  24. package/dist/mcp/data.d.ts +14 -0
  25. package/dist/mcp/data.js +120 -0
  26. package/dist/mcp/function-source.d.ts +7 -0
  27. package/dist/mcp/function-source.js +40 -0
  28. package/dist/mcp/generate.d.ts +1 -0
  29. package/dist/mcp/generate.js +47 -0
  30. package/dist/mcp/install-page.d.ts +1 -0
  31. package/dist/mcp/install-page.js +360 -0
  32. package/dist/notifications/email-worker.d.ts +8 -0
  33. package/dist/notifications/email-worker.js +137 -0
  34. package/dist/package/mcp-data.d.ts +1 -0
  35. package/dist/package/mcp-data.js +1 -0
  36. package/dist/package/mcp-email-worker.d.ts +1 -0
  37. package/dist/package/mcp-email-worker.js +1 -0
  38. package/dist/package/mcp-http.d.ts +1 -0
  39. package/dist/package/mcp-http.js +1 -0
  40. package/dist/package/mcp-stdio.d.ts +1 -0
  41. package/dist/package/mcp-stdio.js +1 -0
  42. package/dist/package/mcp.d.ts +1 -0
  43. package/dist/package/mcp.js +1 -0
  44. package/dist/publish/cloudflare-init.d.ts +12 -0
  45. package/dist/publish/cloudflare-init.js +98 -0
  46. package/dist/publish/config.d.ts +28 -0
  47. package/dist/publish/config.js +190 -0
  48. package/dist/publish/options.d.ts +13 -0
  49. package/dist/publish/options.js +78 -0
  50. package/dist/schema/doctor.d.ts +1 -0
  51. package/dist/schema/doctor.js +69 -0
  52. package/dist/schema/documents.d.ts +18 -0
  53. package/dist/schema/documents.js +127 -0
  54. package/dist/schema/pointers.d.ts +5 -0
  55. package/dist/schema/pointers.js +48 -0
  56. package/dist/schema/types.d.ts +36 -0
  57. package/dist/schema/types.js +1 -0
  58. package/dist/schema/validation.d.ts +8 -0
  59. package/dist/schema/validation.js +212 -0
  60. package/dist/shared/json.d.ts +4 -0
  61. package/dist/shared/json.js +56 -0
  62. package/dist/shared/object.d.ts +2 -0
  63. package/dist/shared/object.js +3 -0
  64. package/dist/shared/package-manager.d.ts +11 -0
  65. package/dist/shared/package-manager.js +39 -0
  66. package/dist/shared/path.d.ts +4 -0
  67. package/dist/shared/path.js +40 -0
  68. package/dist/shared/process.d.ts +7 -0
  69. package/dist/shared/process.js +12 -0
  70. package/dist/shared/project-config.d.ts +2 -0
  71. package/dist/shared/project-config.js +26 -0
  72. package/dist/site/dev.d.ts +10 -0
  73. package/dist/site/dev.js +128 -0
  74. package/dist/site/vitepress.d.ts +21 -0
  75. package/dist/site/vitepress.js +66 -0
  76. package/package.json +74 -0
  77. package/templates/cloudflare/email-code-worker.js +344 -0
@@ -0,0 +1,360 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, relative, resolve } from 'node:path';
3
+ import { isObject } from '../shared/object.js';
4
+ const defaultSources = [
5
+ { text: 'Claude Code deep links', url: 'https://code.claude.com/docs/en/deep-links' },
6
+ { text: 'Claude Code MCP', url: 'https://code.claude.com/docs/en/mcp' },
7
+ { text: 'Codex app deep links', url: 'https://developers.openai.com/codex/app/commands#deep-links' },
8
+ { text: 'Codex MCP', url: 'https://developers.openai.com/codex/mcp' },
9
+ { text: 'Cursor deep links', url: 'https://cursor.com/docs/reference/deeplinks' },
10
+ { text: 'Cursor MCP', url: 'https://cursor.com/docs/mcp' },
11
+ {
12
+ text: 'Copilot Studio MCP onboarding',
13
+ url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent',
14
+ },
15
+ {
16
+ text: 'Microsoft 365 agent connectors for MCP',
17
+ url: 'https://learn.microsoft.com/en-us/microsoftteams/platform/m365-apps/agent-connectors',
18
+ },
19
+ ];
20
+ function codeBlock(language, source) {
21
+ return [`\`\`\`${language}`, source, '```'].join('\n');
22
+ }
23
+ function frontmatter(title, description) {
24
+ return ['---', `title: ${JSON.stringify(title)}`, `description: ${JSON.stringify(description)}`, '---'].join('\n');
25
+ }
26
+ function escapeHtmlAttribute(value) {
27
+ return String(value).replace(/&/g, '&').replace(/"/g, '"');
28
+ }
29
+ function normalizeBasePath(base) {
30
+ if (typeof base !== 'string' || !base.trim() || base === '/')
31
+ return '/';
32
+ return `/${base.replace(/^\/+|\/+$/g, '')}/`;
33
+ }
34
+ function publicSiteUrl(config) {
35
+ const publication = config.publication ?? {};
36
+ const domains = publication.domains ?? {};
37
+ const url = config.url
38
+ ?? config.siteUrl
39
+ ?? publication.url
40
+ ?? domains.primary
41
+ ?? publication.hostname
42
+ ?? config.hostname;
43
+ if (typeof url !== 'string' || !url.trim())
44
+ return undefined;
45
+ const trimmed = url.trim().replace(/\/+$/, '');
46
+ return /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
47
+ }
48
+ function publicMcpSiteUrl(config) {
49
+ const publicUrl = publicMcpEndpoint(config);
50
+ if (!publicUrl)
51
+ return undefined;
52
+ try {
53
+ return new URL(publicUrl).origin;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ function list(items = []) {
60
+ return items.map((item) => ` - \`${item}\``).join('\n');
61
+ }
62
+ function enabledInstallConfig(config) {
63
+ if (!isObject(config.install) || config.install.enabled === false)
64
+ return null;
65
+ if (config.install.enabled !== true && !publicMcpEndpoint(config))
66
+ return null;
67
+ return config.install;
68
+ }
69
+ function mcpEndpoints(config) {
70
+ const endpoints = isObject(config.mcp?.endpoints) ? config.mcp.endpoints : {};
71
+ const installMcp = isObject(config.install?.mcp) ? config.install.mcp : {};
72
+ return {
73
+ portal: stringValue(endpoints.portal),
74
+ protected: stringValue(installMcp.protectedUrl) ?? stringValue(endpoints.protected),
75
+ public: stringValue(installMcp.publicUrl) ?? stringValue(endpoints.public),
76
+ };
77
+ }
78
+ function publicMcpEndpoint(config) {
79
+ return mcpEndpoints(config).public;
80
+ }
81
+ function stringValue(value) {
82
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
83
+ }
84
+ function installMarkdownUrl(config) {
85
+ const siteUrl = publicMcpSiteUrl(config) ?? publicSiteUrl(config);
86
+ if (!siteUrl)
87
+ return undefined;
88
+ return `${siteUrl}${normalizeBasePath(config.base)}${installPagePath(config).replace(/^\/+/, '')}`;
89
+ }
90
+ function appGuideUrl(baseUrl, client) {
91
+ if (!baseUrl)
92
+ return undefined;
93
+ const fragment = client === 'Claude Code' ? 'claude-code' : client.toLowerCase();
94
+ return `${baseUrl.replace(/#.*$/, '')}#${fragment}`;
95
+ }
96
+ function quickStartPrompt(quickStart = {}, mcp, client) {
97
+ const promptKey = client === 'Claude Code' ? 'claudePrompt' : client === 'Cursor' ? 'cursorPrompt' : 'codexPrompt';
98
+ if (typeof quickStart[promptKey] === 'string')
99
+ return quickStart[promptKey];
100
+ if (typeof quickStart.prompt === 'string')
101
+ return quickStart.prompt;
102
+ const setupStep = client === 'Claude Code'
103
+ ? `Run: claude mcp add --transport http ${mcp.serverName} ${mcp.publicUrl}`
104
+ : client === 'Cursor'
105
+ ? `Add hosted MCP server ${mcp.serverName}: ${mcp.publicUrl}`
106
+ : `Run: codex mcp add ${mcp.serverName} --url ${mcp.publicUrl}`;
107
+ const guideUrl = appGuideUrl(mcp.installMarkdownUrl, client);
108
+ return [
109
+ `Set up ${mcp.displayName} in ${client}.`,
110
+ guideUrl ? `Guide: ${guideUrl}` : undefined,
111
+ setupStep,
112
+ `Verify tools; search ${mcp.displayName} for "${client} MCP setup". If blocked, give the next command.`,
113
+ ].filter(Boolean).join('\n');
114
+ }
115
+ function quickStartCard(input) {
116
+ return [
117
+ `<a href="${escapeHtmlAttribute(input.href)}" style="display:flex;flex:1 1 0;min-width:180px;gap:12px;align-items:center;justify-content:center;min-height:64px;border:1px solid var(--vp-c-brand-1);border-radius:8px;padding:12px 16px;text-decoration:none;color:var(--vp-c-text-1);background:var(--vp-c-bg-soft);cursor:pointer;font-weight:700;transition:border-color .15s ease,background-color .15s ease,transform .15s ease;">`,
118
+ ` <span aria-hidden="true" style="display:grid;place-items:center;flex:0 0 auto;width:34px;height:34px;border-radius:8px;background:var(--vp-c-bg);color:${input.color};">${input.icon}</span>`,
119
+ ` <span>${input.label}</span>`,
120
+ '</a>',
121
+ ].join('\n');
122
+ }
123
+ function quickStartSection(quickStart = {}, mcp) {
124
+ if (quickStart.enabled === false)
125
+ return [];
126
+ const codexPrompt = quickStartPrompt(quickStart, mcp, 'Codex');
127
+ const claudePrompt = quickStartPrompt(quickStart, mcp, 'Claude Code');
128
+ const cursorPrompt = quickStartPrompt(quickStart, mcp, 'Cursor');
129
+ const codexUrl = `codex://new?prompt=${encodeURIComponent(codexPrompt)}`;
130
+ const claudeUrl = `claude://claude.ai/new?q=${encodeURIComponent(claudePrompt)}`;
131
+ const cursorUrl = `cursor://anysphere.cursor-deeplink/prompt?text=${encodeURIComponent(cursorPrompt)}`;
132
+ const codexIcon = '<svg width="28" height="28" viewBox="0 0 24 24" role="img" aria-label="OpenAI"><path fill="currentColor" d="M22.282 9.821a6 6 0 0 0-.516-4.91a6.05 6.05 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a6 6 0 0 0-3.998 2.9a6.05 6.05 0 0 0 .743 7.097a5.98 5.98 0 0 0 .51 4.911a6.05 6.05 0 0 0 6.515 2.9A6 6 0 0 0 13.26 24a6.06 6.06 0 0 0 5.772-4.206a6 6 0 0 0 3.997-2.9a6.06 6.06 0 0 0-.747-7.073M13.26 22.43a4.48 4.48 0 0 1-2.876-1.04l.141-.081l4.779-2.758a.8.8 0 0 0 .392-.681v-6.737l2.02 1.168a.07.07 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494M3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085l4.783 2.759a.77.77 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646M2.34 7.896a4.5 4.5 0 0 1 2.366-1.973V11.6a.77.77 0 0 0 .388.677l5.815 3.354l-2.02 1.168a.08.08 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.08.08 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667m2.01-3.023l-.141-.085l-4.774-2.782a.78.78 0 0 0-.785 0L9.409 9.23V6.897a.07.07 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.8.8 0 0 0-.393.681zm1.097-2.365l2.602-1.5l2.607 1.5v2.999l-2.597 1.5l-2.607-1.5Z"/></svg>';
133
+ const claudeIcon = '<svg width="28" height="28" viewBox="0 0 24 24" role="img" aria-label="Claude"><path fill="currentColor" d="m4.714 15.956l4.718-2.648l.079-.23l-.08-.128h-.23l-.79-.048l-2.695-.073l-2.337-.097l-2.265-.122l-.57-.121l-.535-.704l.055-.353l.48-.321l.685.06l1.518.104l2.277.157l1.651.098l2.447.255h.389l.054-.158l-.133-.097l-.103-.098l-2.356-1.596l-2.55-1.688l-1.336-.972l-.722-.491L2 6.223l-.158-1.008l.656-.722l.88.06l.224.061l.893.686l1.906 1.476l2.49 1.833l.364.304l.146-.104l.018-.072l-.164-.274l-1.354-2.446l-1.445-2.49l-.644-1.032l-.17-.619a3 3 0 0 1-.103-.729L6.287.133L6.7 0l.995.134l.42.364l.619 1.415L9.735 4.14l1.555 3.03l.455.898l.243.832l.09.255h.159V9.01l.127-1.706l.237-2.095l.23-2.695l.08-.76l.376-.91l.747-.492l.583.28l.48.685l-.067.444l-.286 1.851l-.558 2.903l-.365 1.942h.213l.243-.242l.983-1.306l1.652-2.064l.728-.82l.85-.904l.547-.431h1.032l.759 1.129l-.34 1.166l-1.063 1.347l-.88 1.142l-1.263 1.7l-.79 1.36l.074.11l.188-.02l2.853-.606l1.542-.28l1.84-.315l.832.388l.09.395l-.327.807l-1.967.486l-2.307.462l-3.436.813l-.043.03l.049.061l1.548.146l.662.036h1.62l3.018.225l.79.522l.473.638l-.08.485l-1.213.62l-1.64-.389l-3.825-.91l-1.31-.329h-.183v.11l1.093 1.068l2.003 1.81l2.508 2.33l.127.578l-.321.455l-.34-.049l-2.204-1.657l-.85-.747l-1.925-1.62h-.127v.17l.443.649l2.343 3.521l.122 1.08l-.17.353l-.607.213l-.668-.122l-1.372-1.924l-1.415-2.168l-1.141-1.943l-.14.08l-.674 7.254l-.316.37l-.728.28l-.607-.461l-.322-.747l.322-1.476l.388-1.924l.316-1.53l.285-1.9l.17-.632l-.012-.042l-.14.018l-1.432 1.967l-2.18 2.945l-1.724 1.845l-.413.164l-.716-.37l.066-.662l.401-.589l2.386-3.036l1.439-1.882l.929-1.086l-.006-.158h-.055L4.138 18.56l-1.13.146l-.485-.456l.06-.746l.231-.243l1.907-1.312Z"/></svg>';
134
+ const cards = [
135
+ quickStartCard({
136
+ href: codexUrl,
137
+ label: quickStart.codexLabel ?? 'OpenAI Codex',
138
+ icon: codexIcon,
139
+ color: '#111111',
140
+ }),
141
+ quickStartCard({
142
+ href: claudeUrl,
143
+ label: quickStart.claudeLabel ?? 'Claude Code',
144
+ icon: claudeIcon,
145
+ color: '#D97757',
146
+ }),
147
+ quickStartCard({
148
+ href: cursorUrl,
149
+ label: quickStart.cursorLabel ?? 'Cursor',
150
+ icon: '<svg width="28" height="28" viewBox="0 0 24 24" role="img" aria-label="Cursor"><path fill="currentColor" d="M12 2 3 7.25v9.5L12 22l9-5.25v-9.5zm0 2.32 6.98 4.07-4.17 2.44L12 9.19 9.19 10.83 5.02 8.39zm-7 5.8 4 2.34v4.65l-4-2.34zm6 9.28v-4.66l1 .59 1-.59v4.66l-1 .58zm3-2.29v-4.65l4-2.34v4.65zm-.99-5.25L12 12.45l-1.01-.59L12 11.27z"/></svg>',
151
+ color: '#111111',
152
+ }),
153
+ ];
154
+ const layoutStyle = cards.length >= 4
155
+ ? 'display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:20px 0;'
156
+ : 'display:flex;flex-wrap:wrap;gap:12px;margin:20px 0;';
157
+ return [
158
+ '## AI Quick Start',
159
+ '',
160
+ `<div class="ai-quick-start" style="${layoutStyle}">`,
161
+ ...cards,
162
+ '</div>',
163
+ ];
164
+ }
165
+ function installPagePath(config) {
166
+ const install = enabledInstallConfig(config);
167
+ const configured = String(install?.path ?? 'install.md').replace(/^\/+/, '');
168
+ if (!configured || configured.split('/').includes('..'))
169
+ throw new Error(`Invalid install.path: ${install?.path}`);
170
+ return configured.endsWith('.md') ? configured : `${configured}.md`;
171
+ }
172
+ function repositoryPrompt(config, displayName, remoteUrl) {
173
+ return config.setupPrompt ?? [
174
+ `Set up the ${displayName} MCP server from this repository.`,
175
+ 'Read mcp/README.md, run npm install and npm run build in mcp/, then configure this local stdio MCP server for this client.',
176
+ `Use the Git remote ${remoteUrl}.`,
177
+ ].join(' ');
178
+ }
179
+ function cloudflarePortalSection(portal = {}, mcp) {
180
+ if (portal.enabled === false || !portal.url)
181
+ return [];
182
+ const serverId = portal.serverId ?? mcp.serverName;
183
+ const upstreamUrl = portal.upstreamUrl ?? mcp.publicUrl;
184
+ const portalId = portal.portalId ?? `${serverId}-portal`;
185
+ const hostname = portal.hostname ?? new URL(String(portal.url)).hostname;
186
+ const gatewayCname = portal.gatewayCname ?? 'gateway.agents.cloudflare.com';
187
+ return [
188
+ '## Cloudflare Managed Portal',
189
+ '',
190
+ "Cloudflare MCP server portals are managed under **Zero Trust** > **Access controls** > **AI controls**. They require a dedicated hostname that points at Cloudflare's MCP portal gateway, then expose MCP at `/mcp`.",
191
+ '',
192
+ 'Target portal:',
193
+ '',
194
+ codeBlock('text', String(portal.url)),
195
+ '',
196
+ 'Upstream MCP server:',
197
+ '',
198
+ codeBlock('text', upstreamUrl),
199
+ '',
200
+ 'Cloudflare setup:',
201
+ '',
202
+ `1. Add an MCP server with server ID \`${serverId}\`, authentication \`${portal.authentication ?? 'Unauthenticated'}\`, and HTTP URL \`${upstreamUrl}\`.`,
203
+ `2. Create an MCP server portal with portal ID \`${portalId}\`, hostname \`${hostname}\`, and include the \`${serverId}\` server.`,
204
+ `3. Create a proxied CNAME for \`${hostname}\` pointing to \`${gatewayCname}\` if the portal was created through the API or Terraform.`,
205
+ `4. Add Access policies for the portal and for the \`${serverId}\` MCP server entry so allowed users can see the server in the portal.`,
206
+ '',
207
+ 'Equivalent Cloudflare API endpoints:',
208
+ '',
209
+ codeBlock('text', 'POST /accounts/{account_id}/access/ai-controls/mcp/servers\nPOST /accounts/{account_id}/access/ai-controls/mcp/portals'),
210
+ '',
211
+ portal.accessNote ?? 'Use a Cloudflare token with Zero Trust Access/AI Controls write access before provisioning the managed portal.',
212
+ '',
213
+ 'For Claude or other MCP clients that need browser-based portal auth, Cloudflare recommends `mcp-remote`:',
214
+ '',
215
+ codeBlock('json', JSON.stringify({ mcpServers: { [serverId]: { command: 'npx', args: ['-y', 'mcp-remote@latest', portal.url] } } }, null, 2)),
216
+ ];
217
+ }
218
+ function localRepositorySection(repository = {}, mcp) {
219
+ if (repository.enabled === false || !repository.remoteUrl || !repository.localPath)
220
+ return [];
221
+ const prompt = repositoryPrompt(repository, mcp.displayName, repository.remoteUrl);
222
+ const codexUrl = `codex://new?originUrl=${encodeURIComponent(repository.remoteUrl)}&prompt=${encodeURIComponent(prompt)}`;
223
+ const claudeUrl = `claude-cli://open?cwd=${encodeURIComponent(repository.localPath)}&q=${encodeURIComponent(prompt)}`;
224
+ const configuredServerDirectory = repository.localServerDirectory ?? 'mcp';
225
+ const serverDirectory = String(configuredServerDirectory).startsWith('/')
226
+ ? String(configuredServerDirectory)
227
+ : `${String(repository.localPath).replace(/\/+$/, '')}/${String(configuredServerDirectory).replace(/^\/+/, '')}`;
228
+ const localServerCommand = repository.localServerCommand ?? `node ${serverDirectory}/dist/server.js`;
229
+ return [
230
+ '## Local Repository Setup',
231
+ '',
232
+ 'Use these links only when you want to open the Git-backed repository in an agent and configure the local MCP server from `mcp/`.',
233
+ '',
234
+ `- [Open in Codex](${codexUrl}): uses Codex \`originUrl\` matching to find a local workspace with the \`${repository.remoteUrl}\` remote.`,
235
+ `- [Open in Claude Code](${claudeUrl}): uses Claude Code \`cwd\` matching for this local clone. Claude Code's \`repo=\` deeplink is GitHub \`owner/name\` based, so use \`cwd=\` for non-GitHub repositories.`,
236
+ '',
237
+ 'If the clone is not present locally:',
238
+ '',
239
+ codeBlock('bash', `git clone ${repository.remoteUrl}.git ${repository.localPath}`),
240
+ '',
241
+ 'Configure the local stdio MCP server:',
242
+ '',
243
+ codeBlock('bash', [
244
+ `claude mcp add --transport stdio ${mcp.serverName} -- ${localServerCommand}`,
245
+ `codex mcp add ${mcp.serverName} -- ${localServerCommand}`,
246
+ ].join('\n')),
247
+ ];
248
+ }
249
+ function sourceSection(sources = defaultSources) {
250
+ return sources.length ? [`Sources checked: ${sources.map((source) => `[${source.text}](${source.url})`).join(', ')}.`] : [];
251
+ }
252
+ function renderInstallPage(config) {
253
+ const install = enabledInstallConfig(config);
254
+ if (!install)
255
+ return undefined;
256
+ const endpoints = mcpEndpoints(config);
257
+ const coreMcp = isObject(config.mcp) ? config.mcp : {};
258
+ const installMcp = isObject(install.mcp) ? install.mcp : {};
259
+ const mcp = {
260
+ description: installMcp.description ?? config.description ?? '',
261
+ displayName: installMcp.displayName ?? install.title ?? config.title ?? 'Index',
262
+ protectedUrl: endpoints.protected ?? '',
263
+ publicUrl: endpoints.public ?? '',
264
+ serverName: installMcp.serverName ?? coreMcp.serverName ?? config.name ?? 'index',
265
+ tools: Array.isArray(installMcp.tools) ? installMcp.tools.map(String) : [],
266
+ installMarkdownUrl: installMarkdownUrl(config),
267
+ };
268
+ if (!mcp.publicUrl)
269
+ throw new Error('mcp.endpoints.public or install.mcp.publicUrl is required when install is enabled');
270
+ const title = install.title ?? 'Install';
271
+ const description = install.description ?? `Connect ${mcp.displayName} MCP to Codex, Claude, Microsoft Teams, and Copilot.`;
272
+ const lines = [
273
+ frontmatter(title, description),
274
+ '',
275
+ `# ${title}`,
276
+ '',
277
+ ...quickStartSection(install.quickStart, mcp),
278
+ '',
279
+ 'Use the public Cloudflare MCP endpoint when you want an agent to use this knowledge base without Git credentials or a local clone.',
280
+ '',
281
+ codeBlock('text', mcp.publicUrl),
282
+ '',
283
+ mcp.protectedUrl ? `The custom domain is protected by Cloudflare Access, so use \`${mcp.publicUrl}\` for no-creds agent setup.` : undefined,
284
+ '',
285
+ ...cloudflarePortalSection({
286
+ url: endpoints.portal,
287
+ upstreamUrl: endpoints.public,
288
+ ...install.cloudflarePortal,
289
+ }, mcp),
290
+ '',
291
+ '## Codex',
292
+ '',
293
+ 'Add the hosted MCP server:',
294
+ '',
295
+ codeBlock('bash', `codex mcp add ${mcp.serverName} --url ${mcp.publicUrl}`),
296
+ '',
297
+ `Restart Codex Desktop or open a new thread, then run \`/mcp\` to confirm \`${mcp.serverName}\` is connected.`,
298
+ '',
299
+ '## Claude Code',
300
+ '',
301
+ 'Add the hosted MCP server:',
302
+ '',
303
+ codeBlock('bash', `claude mcp add --transport http ${mcp.serverName} ${mcp.publicUrl}`),
304
+ '',
305
+ 'Restart Claude Code or open a new session. The tools should appear as:',
306
+ '',
307
+ codeBlock('text', mcp.tools.map((tool) => `mcp__${mcp.serverName}__${tool}`).join('\n')),
308
+ '',
309
+ '## Microsoft Teams and Copilot',
310
+ '',
311
+ 'Use the same public MCP endpoint for Microsoft 365 Copilot and Teams agent experiments:',
312
+ '',
313
+ codeBlock('text', mcp.publicUrl),
314
+ '',
315
+ 'Fastest Copilot Studio path:',
316
+ '',
317
+ '1. Open the target agent in Copilot Studio.',
318
+ '2. Go to **Tools**.',
319
+ '3. Select **Add a tool** > **New tool** > **Model Context Protocol**.',
320
+ '4. Enter:',
321
+ ` - Server name: \`${mcp.displayName}\``,
322
+ ` - Server description: \`${mcp.description}\``,
323
+ ` - Server URL: \`${mcp.publicUrl}\``,
324
+ ' - Authentication: `None`',
325
+ '5. Create the tool, add the MCP tools to the agent, then test with: `Search CDP Index for MCP setup.`',
326
+ '',
327
+ 'Teams / Microsoft 365 Copilot agent path:',
328
+ '',
329
+ '1. Use Microsoft 365 Agents Toolkit in VS Code.',
330
+ '2. Create a new declarative agent.',
331
+ '3. Add an action and choose **Start with an MCP server**.',
332
+ `4. Enter \`${mcp.publicUrl}\`.`,
333
+ '5. Select the tools to expose, usually:',
334
+ list(mcp.tools),
335
+ '6. Test locally through Microsoft 365 Copilot, then package and publish the agent to Teams/Microsoft 365 through the tenant approval flow.',
336
+ '',
337
+ mcp.protectedUrl ? `Do not use \`${mcp.protectedUrl}\` for Copilot or Teams unless the Microsoft connector is configured with Cloudflare Access service-token headers. Without that, Microsoft clients receive the Cloudflare Access login redirect instead of MCP JSON-RPC.` : undefined,
338
+ '',
339
+ ...localRepositorySection(install.repository, mcp),
340
+ '',
341
+ ...sourceSection(Array.isArray(install.sources) ? install.sources : defaultSources),
342
+ '',
343
+ ];
344
+ return `${lines.filter((line) => line !== undefined).join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
345
+ }
346
+ export function writeInstallPage(root, config) {
347
+ const source = renderInstallPage(config);
348
+ if (!source)
349
+ return undefined;
350
+ const resolvedRoot = resolve(root);
351
+ const target = resolve(resolvedRoot, installPagePath(config));
352
+ if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}/`)) {
353
+ throw new Error(`install.path must resolve inside project root: ${relative(resolvedRoot, target)}`);
354
+ }
355
+ if (existsSync(target) && readFileSync(target, 'utf8') === source)
356
+ return target;
357
+ mkdirSync(dirname(target), { recursive: true });
358
+ writeFileSync(target, source);
359
+ return target;
360
+ }
@@ -0,0 +1,8 @@
1
+ type GeneratedNotificationWorker = {
2
+ testPath: string;
3
+ workerPath: string;
4
+ wranglerPath: string;
5
+ };
6
+ export declare function writeNotificationEmailWorker(root: string, config: any, configPath: string): GeneratedNotificationWorker | null;
7
+ export declare function notificationEmailConfig(config: any): Record<string, any> | null;
8
+ export {};
@@ -0,0 +1,137 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, relative, resolve } from 'node:path';
3
+ import { isObject } from '../shared/object.js';
4
+ import { publicationConfig, publishAccountId, publishProjectName } from '../publish/config.js';
5
+ export function writeNotificationEmailWorker(root, config, configPath) {
6
+ const email = notificationEmailConfig(config);
7
+ if (!email)
8
+ return null;
9
+ const worker = isObject(email.worker) ? email.worker : {};
10
+ const wranglerPath = resolveInside(root, stringValue(worker.wranglerConfig) ?? 'email-worker/wrangler.toml', 'email worker wrangler config');
11
+ const workerPath = resolveInside(root, stringValue(worker.workerPath) ?? relative(root, resolve(dirname(wranglerPath), 'worker.ts')), 'email worker path');
12
+ const testPath = resolveInside(root, stringValue(worker.testPath) ?? relative(root, resolve(dirname(wranglerPath), 'test-send.ts')), 'email worker test path');
13
+ mkdirSync(dirname(workerPath), { recursive: true });
14
+ mkdirSync(dirname(wranglerPath), { recursive: true });
15
+ mkdirSync(dirname(testPath), { recursive: true });
16
+ writeFileSync(workerPath, workerSource(workerPath, configPath));
17
+ writeFileSync(testPath, testSource(email));
18
+ writeFileSync(wranglerPath, wranglerSource(root, config, email, worker, workerPath, wranglerPath));
19
+ return { testPath, workerPath, wranglerPath };
20
+ }
21
+ export function notificationEmailConfig(config) {
22
+ const publication = publicationConfig(config);
23
+ const notifications = isObject(publication.notifications) ? publication.notifications : {};
24
+ const email = isObject(notifications.email) ? notifications.email : {};
25
+ if (email.provider !== 'cloudflare-email-worker')
26
+ return null;
27
+ return email;
28
+ }
29
+ function workerSource(workerPath, configPath) {
30
+ return `import { createCloudflareEmailWorker } from '@raggle-ai/kennel-mcp/notifications/email-worker';
31
+ import indexConfig from '${relativeImport(dirname(workerPath), configPath)}';
32
+
33
+ export default createCloudflareEmailWorker(indexConfig);
34
+ `;
35
+ }
36
+ function testSource(email) {
37
+ const endpointEnv = stringValue(email.endpointSecret) ?? stringValue(email.endpointEnv) ?? 'CLOUDFLARE_EMAIL_ENDPOINT';
38
+ const tokenEnv = stringValue(email.tokenSecret) ?? stringValue(email.tokenEnv) ?? 'CLOUDFLARE_EMAIL_TOKEN';
39
+ const defaultGroup = stringValue(email.defaultGroup) ?? 'email-groups/test-admins.txt';
40
+ return `import { readFile } from 'node:fs/promises';
41
+ import { isAbsolute, resolve } from 'node:path';
42
+
43
+ async function main() {
44
+ const endpoint = requireEnv('${endpointEnv}');
45
+ const token = requireEnv('${tokenEnv}');
46
+ const group = process.env.EMAIL_TEST_GROUP_PATH ?? ${JSON.stringify(defaultGroup)};
47
+ const recipients = await readEmailRecipients(group);
48
+ const response = await fetch(endpoint, {
49
+ body: JSON.stringify({
50
+ notificationType: process.env.EMAIL_TEST_NOTIFICATION_TYPE ?? 'test',
51
+ preheader: process.env.EMAIL_TEST_PREHEADER ?? 'Verifying the Kennel email worker configuration.',
52
+ text: \`Cloudflare Email Service test sent from Kennel at \${new Date().toISOString()}.\`,
53
+ to: recipients,
54
+ }),
55
+ headers: {
56
+ authorization: \`Bearer \${token}\`,
57
+ 'content-type': 'application/json',
58
+ },
59
+ method: 'POST',
60
+ });
61
+ const body = await response.json().catch(async () => ({ error: await response.text() }));
62
+
63
+ if (!response.ok) {
64
+ throw new Error(\`Cloudflare email test failed (\${response.status}): \${JSON.stringify(body)}\`);
65
+ }
66
+
67
+ console.log(JSON.stringify(body, null, 2));
68
+ }
69
+
70
+ async function readEmailRecipients(filePath: string) {
71
+ const text = await readFile(isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath), 'utf8');
72
+ return text
73
+ .split(/\\r?\\n/u)
74
+ .map((line) => line.trim())
75
+ .filter((line) => line && !line.startsWith('#'));
76
+ }
77
+
78
+ function requireEnv(name: string) {
79
+ const value = process.env[name]?.trim();
80
+ if (!value) throw new Error(\`Missing required environment variable: \${name}\`);
81
+ return value;
82
+ }
83
+
84
+ main().catch((error) => {
85
+ console.error(error instanceof Error ? error.message : error);
86
+ process.exitCode = 1;
87
+ });
88
+ `;
89
+ }
90
+ function wranglerSource(root, config, email, worker, workerPath, wranglerPath) {
91
+ const name = stringValue(worker.name)
92
+ ?? `${publishProjectName(config, root)}-email`;
93
+ const accountId = stringValue(worker.accountId) ?? publishAccountId(config);
94
+ const compatibilityDate = stringValue(worker.compatibilityDate) ?? '2026-06-23';
95
+ const workersDev = worker.workersDev === false ? 'false' : 'true';
96
+ const senders = allowedSenderAddresses(email, worker);
97
+ const lines = [
98
+ `name = ${tomlString(name)}`,
99
+ `main = ${tomlString(relative(dirname(wranglerPath), workerPath).replace(/\\/gu, '/'))}`,
100
+ `compatibility_date = ${tomlString(compatibilityDate)}`,
101
+ ...(accountId ? [`account_id = ${tomlString(accountId)}`] : []),
102
+ `workers_dev = ${workersDev}`,
103
+ '',
104
+ '[[send_email]]',
105
+ 'name = "EMAIL"',
106
+ 'remote = true',
107
+ ...(senders.length ? [`allowed_sender_addresses = [${senders.map(tomlString).join(', ')}]`] : []),
108
+ ];
109
+ return `${lines.join('\n')}\n`;
110
+ }
111
+ function allowedSenderAddresses(email, worker) {
112
+ const configured = Array.isArray(worker.allowedSenderAddresses)
113
+ ? worker.allowedSenderAddresses.map(String)
114
+ : [];
115
+ const from = isObject(email.from) ? stringValue(email.from.address) : undefined;
116
+ return Array.from(new Set([...configured, ...(from ? [from] : [])].map((entry) => entry.trim()).filter(Boolean)));
117
+ }
118
+ function resolveInside(root, filePath, label) {
119
+ const resolvedRoot = resolve(root);
120
+ const target = resolve(resolvedRoot, filePath);
121
+ if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}/`)) {
122
+ throw new Error(`${label} must resolve inside project root: ${filePath}`);
123
+ }
124
+ return target;
125
+ }
126
+ function relativeImport(fromDir, target) {
127
+ let specifier = relative(fromDir, target).replace(/\\/gu, '/');
128
+ if (!specifier.startsWith('.'))
129
+ specifier = `./${specifier}`;
130
+ return specifier;
131
+ }
132
+ function stringValue(value) {
133
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
134
+ }
135
+ function tomlString(value) {
136
+ return JSON.stringify(value);
137
+ }
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/data';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/data';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/notifications/email-worker';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/notifications/email-worker';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/http';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/http';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/stdio';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp/stdio';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp';
@@ -0,0 +1 @@
1
+ export * from '@raggle-ai/kennel-mcp';
@@ -0,0 +1,12 @@
1
+ export declare const CLOUDFLARE_WORKER_MAIN = ".kennel/generated/cloudflare/email-code-worker.js";
2
+ export declare const CLOUDFLARE_WRANGLER_CONFIG = ".kennel/generated/cloudflare/wrangler.jsonc";
3
+ export declare const LEGACY_CLOUDFLARE_WRANGLER_CONFIG = "wrangler.worker.jsonc";
4
+ export type CloudflareSetup = {
5
+ root: string;
6
+ configPath: string;
7
+ config: any;
8
+ wranglerPath: string;
9
+ hostname: string;
10
+ hostnames: string[];
11
+ };
12
+ export declare function publishInitCloudflare(input: string[]): CloudflareSetup;
@@ -0,0 +1,98 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative } from 'node:path';
3
+ import { readJson, readJsonc, writeJson } from '../shared/json.js';
4
+ import { isObject } from '../shared/object.js';
5
+ import { displayPath } from '../shared/path.js';
6
+ import { projectConfigPath } from '../shared/project-config.js';
7
+ import { displayBasePath, vitepressOutputDirectory } from '../site/vitepress.js';
8
+ import { publishAccountId, publishAuthDefaults, publishAuthWithWranglerFallbacks, publicationAdminMcp, publishHostname, publishHostnames, publishWorkerName, publicationConfig, publicationDomains, } from './config.js';
9
+ import { splitPublishArgs } from './options.js';
10
+ export const CLOUDFLARE_WORKER_MAIN = '.kennel/generated/cloudflare/email-code-worker.js';
11
+ export const CLOUDFLARE_WRANGLER_CONFIG = '.kennel/generated/cloudflare/wrangler.jsonc';
12
+ export const LEGACY_CLOUDFLARE_WRANGLER_CONFIG = 'wrangler.worker.jsonc';
13
+ function workerTemplate() {
14
+ return readFileSync(new URL('../../templates/cloudflare/email-code-worker.js', import.meta.url), 'utf8');
15
+ }
16
+ function readCloudflareWranglerConfig(root) {
17
+ for (const name of [CLOUDFLARE_WRANGLER_CONFIG, LEGACY_CLOUDFLARE_WRANGLER_CONFIG]) {
18
+ const filePath = join(root, name);
19
+ if (existsSync(filePath))
20
+ return readJsonc(filePath);
21
+ }
22
+ return {};
23
+ }
24
+ export function publishInitCloudflare(input) {
25
+ const options = splitPublishArgs(input);
26
+ const configPath = projectConfigPath(options.targetPath, options.configFile);
27
+ const root = dirname(configPath);
28
+ const config = readJson(configPath);
29
+ const publication = publicationConfig(config);
30
+ const adminMcp = publicationAdminMcp(config);
31
+ const domains = publicationDomains(config);
32
+ const wranglerPath = join(root, CLOUDFLARE_WRANGLER_CONFIG);
33
+ const wrangler = readCloudflareWranglerConfig(root);
34
+ const vars = isObject(wrangler.vars) ? wrangler.vars : {};
35
+ const auth = publishAuthWithWranglerFallbacks(publishAuthDefaults(config), vars);
36
+ const workerName = publishWorkerName(config, root);
37
+ const hostname = publishHostname(config, options);
38
+ const hostnames = publishHostnames(config, options);
39
+ config.publication = {
40
+ ...publication,
41
+ provider: 'cloudflare-workers',
42
+ workerName,
43
+ domains: {
44
+ ...domains,
45
+ ...(hostname ? { primary: hostname } : {}),
46
+ ...(hostnames.length > 1 ? { aliases: hostnames.slice(1) } : {}),
47
+ },
48
+ access: auth,
49
+ };
50
+ delete config.publish;
51
+ writeJson(configPath, config);
52
+ const workerPath = join(root, CLOUDFLARE_WORKER_MAIN);
53
+ mkdirSync(dirname(workerPath), { recursive: true });
54
+ writeFileSync(workerPath, workerTemplate());
55
+ wrangler.name = workerName;
56
+ const accountId = publishAccountId(config);
57
+ if (accountId)
58
+ wrangler.account_id = accountId;
59
+ if (typeof wrangler.account_id !== 'string' && typeof process.env.CLOUDFLARE_ACCOUNT_ID === 'string') {
60
+ wrangler.account_id = process.env.CLOUDFLARE_ACCOUNT_ID;
61
+ }
62
+ wrangler.main = relative(dirname(wranglerPath), workerPath);
63
+ wrangler.compatibility_date = wrangler.compatibility_date ?? new Date().toISOString().slice(0, 10);
64
+ wrangler.assets = {
65
+ binding: 'ASSETS',
66
+ directory: relative(dirname(wranglerPath), join(root, vitepressOutputDirectory(root))),
67
+ run_worker_first: true,
68
+ };
69
+ if (hostnames.length > 0) {
70
+ wrangler.routes = hostnames.map((pattern) => ({ pattern, custom_domain: true }));
71
+ }
72
+ if (auth.mode === 'email-code-worker') {
73
+ wrangler.send_email = [{ name: 'EMAIL', remote: true }];
74
+ }
75
+ else {
76
+ delete wrangler.send_email;
77
+ }
78
+ wrangler.vars = {
79
+ ...vars,
80
+ ...(auth.allowedDomains.length > 0 ? { KENNEL_AUTH_ALLOWED_DOMAINS: auth.allowedDomains.join(',') } : {}),
81
+ ...(auth.allowedEmails.length > 0 ? { KENNEL_AUTH_ALLOWED_EMAILS: auth.allowedEmails.join(',') } : {}),
82
+ ...(auth.fromEmail ? { KENNEL_AUTH_FROM_EMAIL: auth.fromEmail } : {}),
83
+ ...(auth.emailSubject ? { KENNEL_AUTH_EMAIL_SUBJECT: auth.emailSubject } : {}),
84
+ ...(typeof adminMcp.hostname === 'string' && adminMcp.hostname.trim() ? { KENNEL_ADMIN_MCP_HOSTNAMES: adminMcp.hostname.trim() } : {}),
85
+ ...(typeof adminMcp.role === 'string' && adminMcp.role.trim() ? { KENNEL_ADMIN_MCP_ROLE: adminMcp.role.trim() } : {}),
86
+ ...(typeof adminMcp.upstream === 'string' && adminMcp.upstream.trim() ? { KENNEL_ADMIN_MCP_UPSTREAM: adminMcp.upstream.trim() } : {}),
87
+ ...(typeof config.base === 'string' && config.base.trim() ? { KENNEL_SITE_BASE: displayBasePath(config) } : {}),
88
+ };
89
+ mkdirSync(dirname(wranglerPath), { recursive: true });
90
+ writeJson(wranglerPath, wrangler);
91
+ console.log(`ok ${displayPath(configPath)}`);
92
+ console.log(`ok ${displayPath(workerPath)}`);
93
+ console.log(`ok ${displayPath(wranglerPath)}`);
94
+ console.log('required secret: KENNEL_AUTH_SECRET');
95
+ if (!hostname)
96
+ console.log('optional: set publication.domains.primary or pass --hostname to attach a custom domain');
97
+ return { root, configPath, config, wranglerPath, hostname: hostnames[0] ?? hostname, hostnames };
98
+ }