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.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/kennel.mjs +3 -0
- package/dist/automations/notifications.d.ts +40 -0
- package/dist/automations/notifications.js +220 -0
- package/dist/cli/dispatch.d.ts +1 -0
- package/dist/cli/dispatch.js +51 -0
- package/dist/cli/usage.d.ts +1 -0
- package/dist/cli/usage.js +14 -0
- package/dist/deploy/cloudflare.d.ts +6 -0
- package/dist/deploy/cloudflare.js +91 -0
- package/dist/deploy/environment.d.ts +2 -0
- package/dist/deploy/environment.js +47 -0
- package/dist/deploy/smoke-tests.d.ts +3 -0
- package/dist/deploy/smoke-tests.js +71 -0
- package/dist/deploy/trigger-dev.d.ts +2 -0
- package/dist/deploy/trigger-dev.js +255 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +8 -0
- package/dist/mcp/agent-tools.d.ts +8 -0
- package/dist/mcp/agent-tools.js +247 -0
- package/dist/mcp/config.d.ts +25 -0
- package/dist/mcp/config.js +171 -0
- package/dist/mcp/data.d.ts +14 -0
- package/dist/mcp/data.js +120 -0
- package/dist/mcp/function-source.d.ts +7 -0
- package/dist/mcp/function-source.js +40 -0
- package/dist/mcp/generate.d.ts +1 -0
- package/dist/mcp/generate.js +47 -0
- package/dist/mcp/install-page.d.ts +1 -0
- package/dist/mcp/install-page.js +360 -0
- package/dist/notifications/email-worker.d.ts +8 -0
- package/dist/notifications/email-worker.js +137 -0
- package/dist/package/mcp-data.d.ts +1 -0
- package/dist/package/mcp-data.js +1 -0
- package/dist/package/mcp-email-worker.d.ts +1 -0
- package/dist/package/mcp-email-worker.js +1 -0
- package/dist/package/mcp-http.d.ts +1 -0
- package/dist/package/mcp-http.js +1 -0
- package/dist/package/mcp-stdio.d.ts +1 -0
- package/dist/package/mcp-stdio.js +1 -0
- package/dist/package/mcp.d.ts +1 -0
- package/dist/package/mcp.js +1 -0
- package/dist/publish/cloudflare-init.d.ts +12 -0
- package/dist/publish/cloudflare-init.js +98 -0
- package/dist/publish/config.d.ts +28 -0
- package/dist/publish/config.js +190 -0
- package/dist/publish/options.d.ts +13 -0
- package/dist/publish/options.js +78 -0
- package/dist/schema/doctor.d.ts +1 -0
- package/dist/schema/doctor.js +69 -0
- package/dist/schema/documents.d.ts +18 -0
- package/dist/schema/documents.js +127 -0
- package/dist/schema/pointers.d.ts +5 -0
- package/dist/schema/pointers.js +48 -0
- package/dist/schema/types.d.ts +36 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/validation.d.ts +8 -0
- package/dist/schema/validation.js +212 -0
- package/dist/shared/json.d.ts +4 -0
- package/dist/shared/json.js +56 -0
- package/dist/shared/object.d.ts +2 -0
- package/dist/shared/object.js +3 -0
- package/dist/shared/package-manager.d.ts +11 -0
- package/dist/shared/package-manager.js +39 -0
- package/dist/shared/path.d.ts +4 -0
- package/dist/shared/path.js +40 -0
- package/dist/shared/process.d.ts +7 -0
- package/dist/shared/process.js +12 -0
- package/dist/shared/project-config.d.ts +2 -0
- package/dist/shared/project-config.js +26 -0
- package/dist/site/dev.d.ts +10 -0
- package/dist/site/dev.js +128 -0
- package/dist/site/vitepress.d.ts +21 -0
- package/dist/site/vitepress.js +66 -0
- package/package.json +74 -0
- package/templates/cloudflare/email-code-worker.js +344 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raggle AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Kennel CLI
|
|
2
|
+
|
|
3
|
+
Schema-backed folder ledger validator for structured workspaces.
|
|
4
|
+
|
|
5
|
+
Kennel keeps structured folders easy to inspect and validate. It looks upward from the target path for the nearest `schema.json` or `_schema.json`, then validates schema-declared YAML files or Markdown frontmatter documents.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm test
|
|
12
|
+
node bin/kennel.mjs --help
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Install locally from this checkout:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install /Users/andrew/Documents/GitHub/kennel/cli
|
|
19
|
+
kennel --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Install from npm after publication:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install --global kennel
|
|
26
|
+
kennel --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Publish to npm:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd /Users/andrew/Documents/GitHub/kennel/cli
|
|
33
|
+
npm publish --access public --registry=https://registry.npmjs.org
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Run against folder schemas:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
kennel schema clients/reapit
|
|
40
|
+
kennel validate clients
|
|
41
|
+
kennel validate projects
|
|
42
|
+
kennel validate partners
|
|
43
|
+
kennel doctor clients/reapit
|
|
44
|
+
kennel dev /Users/andrewmagu/src/cdp-index --config index.json
|
|
45
|
+
kennel publish init cloudflare .
|
|
46
|
+
kennel deploy .
|
|
47
|
+
kennel dp .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Kennel does not create schemas or new records. Manage `schema.json` or `_schema.json` files and folder templates outside the CLI, then use Kennel to inspect and validate them.
|
|
51
|
+
|
|
52
|
+
`kennel publish init cloudflare` is the exception for deploy wiring: it scaffolds
|
|
53
|
+
a Cloudflare Worker email-code gate for an existing docs workspace by writing
|
|
54
|
+
`publication` in `raggle.json` by default, then writing generated Cloudflare
|
|
55
|
+
files under `.kennel/generated/cloudflare/`. The generated Worker comes from the
|
|
56
|
+
CLI's packaged `templates/cloudflare/email-code-worker.js` asset, and the
|
|
57
|
+
generated Wrangler config is derived from the selected local JSON config file,
|
|
58
|
+
so Kennel can protect any static site output that deploys through the Kennel
|
|
59
|
+
Cloudflare path.
|
|
60
|
+
|
|
61
|
+
`kennel publish`, `kennel deploy`, and the short alias `kennel dp` read the
|
|
62
|
+
selected local config file, build the docs, set a generated
|
|
63
|
+
`KENNEL_AUTH_SECRET`, deploy with Wrangler, and smoke-test the protected URL.
|
|
64
|
+
Pass `--config <json-file>` to use another local JSON config name such as
|
|
65
|
+
`index.json`. If the selected config defines `publication.cloudflare.token`,
|
|
66
|
+
Kennel loads `CLOUDFLARE_API_TOKEN` from Doppler only for the publish command.
|
|
67
|
+
`kennel publish cloudflare` remains available when the provider should be
|
|
68
|
+
explicit.
|
|
69
|
+
|
|
70
|
+
## Folder Shape
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
clients/
|
|
74
|
+
schema.json
|
|
75
|
+
example-client/
|
|
76
|
+
client.yml
|
|
77
|
+
people/
|
|
78
|
+
notes/
|
|
79
|
+
comms/
|
|
80
|
+
evidence/
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The nearest parent `schema.json` or `_schema.json` owns the expected shape for folders below it. When both names exist in the same folder, `schema.json` is preferred.
|
|
84
|
+
|
|
85
|
+
`kennel validate` discovers files from the nearest schema's `x-kennel.documents` declaration. If a schema omits that declaration, Kennel keeps the compatibility default of `client.yml` and `index.md`.
|
|
86
|
+
|
|
87
|
+
Markdown files are validated using YAML frontmatter. YAML files are parsed as YAML.
|
|
88
|
+
|
|
89
|
+
## Folder Requirements
|
|
90
|
+
|
|
91
|
+
Schemas can include a Kennel-only extension for folder shape checks:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"x-kennel": {
|
|
96
|
+
"documents": [
|
|
97
|
+
"client.yml",
|
|
98
|
+
{ "path": "index.md", "parser": "markdown-frontmatter" },
|
|
99
|
+
{ "glob": "*.yml", "parser": "yaml" }
|
|
100
|
+
],
|
|
101
|
+
"requiredDirectories": ["people", "notes", "comms", "evidence"],
|
|
102
|
+
"requiredFiles": ["README.md"],
|
|
103
|
+
"directoryContents": [
|
|
104
|
+
{
|
|
105
|
+
"path": "people",
|
|
106
|
+
"glob": "*.md",
|
|
107
|
+
"minFiles": 1,
|
|
108
|
+
"whenPointerMinItems": "/contacts"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"fileReferences": [
|
|
112
|
+
{
|
|
113
|
+
"pointer": "/contacts/*/path",
|
|
114
|
+
"required": true,
|
|
115
|
+
"whenPointerMinItems": "/contacts"
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
String document entries match a file name anywhere under the schema root. Object entries can use `path` or `glob` plus an optional parser.
|
|
123
|
+
|
|
124
|
+
`requiredDirectories` and `requiredFiles` are checked next to each validated document and by `kennel doctor`.
|
|
125
|
+
|
|
126
|
+
`directoryContents` checks that a sibling directory contains enough matching files. Use `whenPointerMinItems` to apply the rule only when an array in the document is non-empty, or `whenPointerIn` with `{ "pointer": "/client/status", "values": ["active-client"] }` to apply it for selected field values.
|
|
127
|
+
|
|
128
|
+
`fileReferences` checks that document path fields point to existing files under the same folder. It supports JSON pointers with `*` wildcards, such as `/contacts/*/path`.
|
|
129
|
+
|
|
130
|
+
## MCP Analytics
|
|
131
|
+
|
|
132
|
+
Generated MCP runtimes can emit tool-call metadata and explicit conversation snapshots to analytics providers. Use `mcp.analytics.provider` for one provider, or `mcp.analytics.providers` to fan out to multiple providers:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"mcp": {
|
|
137
|
+
"analytics": {
|
|
138
|
+
"enabled": true,
|
|
139
|
+
"providers": ["raggle", "posthog", "langfuse"],
|
|
140
|
+
"raggle": {
|
|
141
|
+
"projectId": "cdp-index",
|
|
142
|
+
"apiTokenEnv": "RAGGLE_OTEL_API_TOKEN"
|
|
143
|
+
},
|
|
144
|
+
"posthog": {
|
|
145
|
+
"apiKeyEnv": "POSTHOG_PROJECT_API_KEY"
|
|
146
|
+
},
|
|
147
|
+
"langfuse": {
|
|
148
|
+
"baseUrl": "https://cloud.langfuse.com",
|
|
149
|
+
"publicKeyEnv": "LANGFUSE_PUBLIC_KEY",
|
|
150
|
+
"secretKeyEnv": "LANGFUSE_SECRET_KEY"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The Raggle provider uses the `raggle-otel` package and defaults to `RAGGLE_OTEL_API_TOKEN`, `RAGGLE_OTEL_PROJECT_ID`, and `RAGGLE_OTEL_BASE_URL` when values are not set in config. PostHog keeps the existing `/capture/` behavior. Langfuse sends trace events through its public ingestion API with Langfuse basic auth credentials.
|
|
158
|
+
|
|
159
|
+
## MCP Repo Integrations
|
|
160
|
+
|
|
161
|
+
`kennel mcp generate` also emits an MCP server runtime (`@raggle-ai/kennel-mcp`) with core tools for searching index docs and tracked repositories. The runtime additionally exposes repo integration tools that let an agent run basic GitHub or GitLab commands against a repository linked from the index registry (`repos/index.yaml`):
|
|
162
|
+
|
|
163
|
+
- `submit_repo_feature_request` — open a feature request issue on a tracked repo, submitted on behalf of a user.
|
|
164
|
+
- `list_repo_issues` — list recent issues on a tracked repo.
|
|
165
|
+
- `get_repo_issue` — read one issue by number.
|
|
166
|
+
|
|
167
|
+
The provider (GitHub or GitLab) is detected from each registry entry's `url`, or can be declared with an optional `provider` field. By default the tools read `GITHUB_TOKEN` and `GITLAB_TOKEN` from the runtime environment, so they work without extra configuration once a token is present.
|
|
168
|
+
|
|
169
|
+
To let people supply their own GitHub or GitLab setup (custom token env, self-hosted/Enterprise API base URL, allowed repos, default labels), add an `mcp.repoIntegrations` block to the local config:
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"mcp": {
|
|
174
|
+
"repoIntegrations": {
|
|
175
|
+
"enabled": true,
|
|
176
|
+
"github": {
|
|
177
|
+
"tokenEnv": "CDP_INDEX_GITHUB_TOKEN",
|
|
178
|
+
"apiBaseUrl": "https://github.example.com/api/v3"
|
|
179
|
+
},
|
|
180
|
+
"gitlab": {
|
|
181
|
+
"tokenEnv": "CDP_INDEX_GITLAB_TOKEN",
|
|
182
|
+
"apiBaseUrl": "https://gitlab.example.com/api/v4"
|
|
183
|
+
},
|
|
184
|
+
"allowedRepos": ["cdp-index", "index"],
|
|
185
|
+
"defaultLabels": ["feature-request"]
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`allowedRepos` is an optional allowlist of registry repo names; when omitted, every registry repo is available. Set `enabled: false` to disable the repo integration tools while keeping the rest of the MCP server active.
|
|
192
|
+
|
|
193
|
+
Registry entries that should support issue creation need a `url` (and optionally `provider`):
|
|
194
|
+
|
|
195
|
+
```yaml
|
|
196
|
+
repos:
|
|
197
|
+
- name: index
|
|
198
|
+
url: https://github.com/bakerstreetco/index
|
|
199
|
+
provider: github
|
|
200
|
+
- name: cdp-index
|
|
201
|
+
url: https://gitlab.com/baker-street/cdp-index
|
|
202
|
+
provider: gitlab
|
|
203
|
+
```
|
package/bin/kennel.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type SlackMessageResult = {
|
|
2
|
+
ts: string;
|
|
3
|
+
};
|
|
4
|
+
export type EmailSendRequest = {
|
|
5
|
+
notificationType?: string;
|
|
6
|
+
preheader?: string;
|
|
7
|
+
priority?: string;
|
|
8
|
+
recipients: string[];
|
|
9
|
+
subject: string;
|
|
10
|
+
template?: string;
|
|
11
|
+
text: string;
|
|
12
|
+
};
|
|
13
|
+
export type CloudflareEmailSenderOptions = {
|
|
14
|
+
endpoint: string;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
token: string;
|
|
17
|
+
};
|
|
18
|
+
export type SlackNotifierOptions = {
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
token: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function readEmailRecipients(filePath: string, root?: string): Promise<string[]>;
|
|
23
|
+
export declare function createSlackNotifier({ fetch: fetchImpl, token }: SlackNotifierOptions): ({ channelId, text, }: {
|
|
24
|
+
channelId: string;
|
|
25
|
+
text: string;
|
|
26
|
+
}) => Promise<SlackMessageResult>;
|
|
27
|
+
export declare function createCloudflareEmailSender({ endpoint, fetch: fetchImpl, token, }: CloudflareEmailSenderOptions): ({ notificationType, preheader, priority, recipients, subject, template, text }: EmailSendRequest) => Promise<any>;
|
|
28
|
+
export declare function sendEmail({ recipients, sendmailPath, subject, text, }: EmailSendRequest & {
|
|
29
|
+
sendmailPath?: string;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
recipients: string[];
|
|
32
|
+
provider: string;
|
|
33
|
+
}>;
|
|
34
|
+
export declare function markdownToEmailHtml(markdown: string): string;
|
|
35
|
+
export declare function formatSendSummary({ emailRecipients, emailSent, slackChannelId, slackTs, }: {
|
|
36
|
+
emailRecipients: string[];
|
|
37
|
+
emailSent: boolean;
|
|
38
|
+
slackChannelId: string;
|
|
39
|
+
slackTs?: string;
|
|
40
|
+
}): string;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
const SLACK_API_BASE_URL = 'https://slack.com/api/';
|
|
5
|
+
export async function readEmailRecipients(filePath, root = process.cwd()) {
|
|
6
|
+
const text = await readFile(isAbsolute(filePath) ? filePath : resolve(root, filePath), 'utf8');
|
|
7
|
+
return text
|
|
8
|
+
.split(/\r?\n/u)
|
|
9
|
+
.map((line) => line.trim())
|
|
10
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
11
|
+
}
|
|
12
|
+
export function createSlackNotifier({ fetch: fetchImpl = fetch, token }) {
|
|
13
|
+
return async function postSlackMessage({ channelId, text, }) {
|
|
14
|
+
const response = await fetchImpl(new URL('chat.postMessage', SLACK_API_BASE_URL), {
|
|
15
|
+
body: JSON.stringify({
|
|
16
|
+
channel: channelId,
|
|
17
|
+
text,
|
|
18
|
+
}),
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: `Bearer ${token}`,
|
|
21
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
22
|
+
},
|
|
23
|
+
method: 'POST',
|
|
24
|
+
});
|
|
25
|
+
const body = (await response.json());
|
|
26
|
+
if (!body.ok) {
|
|
27
|
+
throw new Error(`Slack chat.postMessage failed: ${body.error ?? JSON.stringify(body)}`);
|
|
28
|
+
}
|
|
29
|
+
return { ts: body.ts };
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function createCloudflareEmailSender({ endpoint, fetch: fetchImpl = fetch, token, }) {
|
|
33
|
+
return async function sendCloudflareEmail({ notificationType, preheader, priority, recipients, subject, template, text }) {
|
|
34
|
+
if (recipients.length === 0) {
|
|
35
|
+
throw new Error('No email recipients configured.');
|
|
36
|
+
}
|
|
37
|
+
const response = await fetchImpl(endpoint, {
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
html: markdownToEmailHtml(text),
|
|
40
|
+
...(notificationType ? { notificationType } : {}),
|
|
41
|
+
...(preheader ? { preheader } : {}),
|
|
42
|
+
...(priority ? { priority } : {}),
|
|
43
|
+
subject,
|
|
44
|
+
...(template ? { template } : {}),
|
|
45
|
+
text,
|
|
46
|
+
to: recipients,
|
|
47
|
+
}),
|
|
48
|
+
headers: {
|
|
49
|
+
authorization: `Bearer ${token}`,
|
|
50
|
+
'content-type': 'application/json',
|
|
51
|
+
},
|
|
52
|
+
method: 'POST',
|
|
53
|
+
});
|
|
54
|
+
const body = await response.json().catch(async () => ({ error: await response.text() }));
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`Cloudflare email failed (${response.status}): ${JSON.stringify(body)}`);
|
|
57
|
+
}
|
|
58
|
+
return body;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export async function sendEmail({ recipients, sendmailPath = process.env.SENDMAIL_PATH ?? '/usr/sbin/sendmail', subject, text, }) {
|
|
62
|
+
if (recipients.length === 0) {
|
|
63
|
+
throw new Error('No email recipients configured.');
|
|
64
|
+
}
|
|
65
|
+
if (process.env.CLOUDFLARE_EMAIL_ENDPOINT && process.env.CLOUDFLARE_EMAIL_TOKEN) {
|
|
66
|
+
const sender = createCloudflareEmailSender({
|
|
67
|
+
endpoint: process.env.CLOUDFLARE_EMAIL_ENDPOINT,
|
|
68
|
+
token: process.env.CLOUDFLARE_EMAIL_TOKEN,
|
|
69
|
+
});
|
|
70
|
+
await sender({ recipients, subject, text });
|
|
71
|
+
return { recipients, provider: 'cloudflare' };
|
|
72
|
+
}
|
|
73
|
+
const message = [
|
|
74
|
+
`To: ${recipients.join(', ')}`,
|
|
75
|
+
`Subject: ${sanitizeHeader(subject)}`,
|
|
76
|
+
'Content-Type: text/plain; charset=utf-8',
|
|
77
|
+
'',
|
|
78
|
+
text,
|
|
79
|
+
].join('\n');
|
|
80
|
+
await runSendmail(sendmailPath, message);
|
|
81
|
+
return { recipients, provider: 'sendmail' };
|
|
82
|
+
}
|
|
83
|
+
export function markdownToEmailHtml(markdown) {
|
|
84
|
+
const lines = markdown.replace(/\r\n/gu, '\n').split('\n');
|
|
85
|
+
const blocks = [];
|
|
86
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
87
|
+
const line = lines[index];
|
|
88
|
+
if (line.trim() === '') {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const codeFence = line.match(/^```([a-zA-Z0-9_-]*)\s*$/u);
|
|
92
|
+
if (codeFence) {
|
|
93
|
+
const code = [];
|
|
94
|
+
index += 1;
|
|
95
|
+
while (index < lines.length && !lines[index].startsWith('```')) {
|
|
96
|
+
code.push(lines[index]);
|
|
97
|
+
index += 1;
|
|
98
|
+
}
|
|
99
|
+
const language = codeFence[1] ? ` class="language-${escapeAttribute(codeFence[1])}"` : '';
|
|
100
|
+
blocks.push(`<pre><code${language}>${escapeHtml(code.join('\n').trim())}</code></pre>`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (isTableHeader(lines, index)) {
|
|
104
|
+
const { html, nextIndex } = renderTable(lines, index);
|
|
105
|
+
blocks.push(html);
|
|
106
|
+
index = nextIndex;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (line.startsWith('- ')) {
|
|
110
|
+
const items = [];
|
|
111
|
+
while (index < lines.length && lines[index].startsWith('- ')) {
|
|
112
|
+
items.push(`<li>${renderInline(lines[index].slice(2))}</li>`);
|
|
113
|
+
index += 1;
|
|
114
|
+
}
|
|
115
|
+
blocks.push(`<ul>\n${items.join('\n')}\n</ul>`);
|
|
116
|
+
index -= 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const paragraph = [line];
|
|
120
|
+
while (index + 1 < lines.length &&
|
|
121
|
+
lines[index + 1].trim() !== '' &&
|
|
122
|
+
!lines[index + 1].startsWith('- ') &&
|
|
123
|
+
!lines[index + 1].startsWith('```') &&
|
|
124
|
+
!isTableHeader(lines, index + 1)) {
|
|
125
|
+
paragraph.push(lines[index + 1]);
|
|
126
|
+
index += 1;
|
|
127
|
+
}
|
|
128
|
+
blocks.push(`<p>${paragraph.map(renderInline).join('<br>')}</p>`);
|
|
129
|
+
}
|
|
130
|
+
return `<!doctype html>
|
|
131
|
+
<html>
|
|
132
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.45; color: #111827;">
|
|
133
|
+
${blocks.join('\n')}
|
|
134
|
+
</body>
|
|
135
|
+
</html>`;
|
|
136
|
+
}
|
|
137
|
+
export function formatSendSummary({ emailRecipients, emailSent, slackChannelId, slackTs, }) {
|
|
138
|
+
const parts = [
|
|
139
|
+
slackTs ? `Slack posted to ${slackChannelId} at ${slackTs}` : `Slack not posted to ${slackChannelId}`,
|
|
140
|
+
emailSent ? `email sent to ${emailRecipients.join(', ')}` : 'email not sent',
|
|
141
|
+
];
|
|
142
|
+
return parts.join('; ');
|
|
143
|
+
}
|
|
144
|
+
async function runSendmail(sendmailPath, message) {
|
|
145
|
+
await new Promise((resolvePromise, reject) => {
|
|
146
|
+
const child = spawn(sendmailPath, ['-t'], {
|
|
147
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
148
|
+
});
|
|
149
|
+
let stderr = '';
|
|
150
|
+
child.stderr.on('data', (chunk) => {
|
|
151
|
+
stderr += String(chunk);
|
|
152
|
+
});
|
|
153
|
+
child.on('error', reject);
|
|
154
|
+
child.on('close', (code) => {
|
|
155
|
+
if (code === 0) {
|
|
156
|
+
resolvePromise();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
reject(new Error(`sendmail exited with code ${code}: ${stderr.trim()}`));
|
|
160
|
+
});
|
|
161
|
+
child.stdin.end(message);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function isTableHeader(lines, index) {
|
|
165
|
+
return hasTableCells(lines[index]) && Boolean(lines[index + 1] && isTableDivider(lines[index + 1]));
|
|
166
|
+
}
|
|
167
|
+
function renderTable(lines, startIndex) {
|
|
168
|
+
const headers = splitTableRow(lines[startIndex]);
|
|
169
|
+
const rows = [];
|
|
170
|
+
let index = startIndex + 2;
|
|
171
|
+
while (index < lines.length && hasTableCells(lines[index]) && lines[index].trim() !== '') {
|
|
172
|
+
rows.push(splitTableRow(lines[index]));
|
|
173
|
+
index += 1;
|
|
174
|
+
}
|
|
175
|
+
const html = [
|
|
176
|
+
'<table style="border-collapse: collapse; margin: 12px 0;">',
|
|
177
|
+
'<thead>',
|
|
178
|
+
`<tr>${headers
|
|
179
|
+
.map((header) => `<th style="border: 1px solid #d1d5db; padding: 4px 8px; text-align: left;">${renderInline(header)}</th>`)
|
|
180
|
+
.join('')}</tr>`,
|
|
181
|
+
'</thead>',
|
|
182
|
+
'<tbody>',
|
|
183
|
+
...rows.map((row) => `<tr>${headers
|
|
184
|
+
.map((_, cellIndex) => `<td style="border: 1px solid #d1d5db; padding: 4px 8px;">${renderInline(row[cellIndex] ?? '')}</td>`)
|
|
185
|
+
.join('')}</tr>`),
|
|
186
|
+
'</tbody>',
|
|
187
|
+
'</table>',
|
|
188
|
+
].join('\n');
|
|
189
|
+
return { html, nextIndex: index - 1 };
|
|
190
|
+
}
|
|
191
|
+
function hasTableCells(line) {
|
|
192
|
+
return Boolean(line && line.includes('|'));
|
|
193
|
+
}
|
|
194
|
+
function isTableDivider(line) {
|
|
195
|
+
return splitTableRow(line).every((cell) => /^:?-{3,}:?$/u.test(cell));
|
|
196
|
+
}
|
|
197
|
+
function splitTableRow(line) {
|
|
198
|
+
return line
|
|
199
|
+
.trim()
|
|
200
|
+
.replace(/^\|/u, '')
|
|
201
|
+
.replace(/\|$/u, '')
|
|
202
|
+
.split('|')
|
|
203
|
+
.map((cell) => cell.trim());
|
|
204
|
+
}
|
|
205
|
+
function renderInline(value) {
|
|
206
|
+
return escapeHtml(value).replace(/\*([^*\n]+)\*/gu, '<strong>$1</strong>');
|
|
207
|
+
}
|
|
208
|
+
function escapeHtml(value) {
|
|
209
|
+
return value
|
|
210
|
+
.replace(/&/gu, '&')
|
|
211
|
+
.replace(/</gu, '<')
|
|
212
|
+
.replace(/>/gu, '>')
|
|
213
|
+
.replace(/"/gu, '"');
|
|
214
|
+
}
|
|
215
|
+
function escapeAttribute(value) {
|
|
216
|
+
return escapeHtml(value).replace(/'/gu, ''');
|
|
217
|
+
}
|
|
218
|
+
function sanitizeHeader(value) {
|
|
219
|
+
return value.replace(/[\r\n]+/gu, ' ').trim();
|
|
220
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function dispatch(argv: string[]): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { deploy, publish } from '../deploy/cloudflare.js';
|
|
2
|
+
import { generateMcp } from '../mcp/generate.js';
|
|
3
|
+
import { doctor } from '../schema/doctor.js';
|
|
4
|
+
import { schemaFor, validateTarget } from '../schema/validation.js';
|
|
5
|
+
import { dev } from '../site/dev.js';
|
|
6
|
+
import { publishInitCloudflare } from '../publish/cloudflare-init.js';
|
|
7
|
+
import { usage } from './usage.js';
|
|
8
|
+
export async function dispatch(argv) {
|
|
9
|
+
const [, , command, ...args] = argv;
|
|
10
|
+
if (!command || command === '-h' || command === '--help') {
|
|
11
|
+
usage();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (command === 'schema') {
|
|
15
|
+
schemaFor(args[0] ?? '.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (command === 'validate') {
|
|
19
|
+
const valid = validateTarget(args[0] ?? '.');
|
|
20
|
+
process.exit(valid ? 0 : 1);
|
|
21
|
+
}
|
|
22
|
+
if (command === 'doctor') {
|
|
23
|
+
doctor(args[0] ?? '.');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (command === 'dev') {
|
|
27
|
+
dev(args);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (command === 'mcp' && args[0] === 'generate') {
|
|
31
|
+
await generateMcp(args.slice(1));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (command === 'publish' && args[0] === 'init' && args[1] === 'cloudflare') {
|
|
35
|
+
publishInitCloudflare(args.slice(2));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (command === 'publish') {
|
|
39
|
+
publish(args);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (command === 'deploy') {
|
|
43
|
+
deploy(args);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (command === 'dp') {
|
|
47
|
+
deploy(args);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Unknown command: ${command}`);
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function usage(): void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function usage() {
|
|
2
|
+
console.log(`Usage:
|
|
3
|
+
kennel schema <path>
|
|
4
|
+
kennel validate <path>
|
|
5
|
+
kennel doctor <path>
|
|
6
|
+
kennel dev [path] [--config <json-file>] [--port <port>] [--script <script>] [--dry-run] [-- <args...>]
|
|
7
|
+
kennel mcp generate [path] [--config <json-file>] [--data-only|--function-only]
|
|
8
|
+
kennel publish init cloudflare [path] [--config <json-file>] [--force] [--hostname <hostname>]
|
|
9
|
+
kennel publish [path] [--config <json-file>] [--dry-run] [--force] [--hostname <hostname>] [--test-email <email>] [--skip-test] [--use-doppler-token]
|
|
10
|
+
kennel publish cloudflare [path] [--config <json-file>] [--dry-run] [--force] [--hostname <hostname>] [--test-email <email>] [--skip-test] [--use-doppler-token]
|
|
11
|
+
kennel deploy [path] [--config <json-file>] [legacy alias for publish]
|
|
12
|
+
kennel dp [path] [--config <json-file>] [legacy alias for publish]
|
|
13
|
+
`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ChildCommand } from '../shared/process.js';
|
|
2
|
+
import { type PublishOptions } from '../publish/options.js';
|
|
3
|
+
export declare function runPublishCommand(root: string, child: ChildCommand, options: Pick<PublishOptions, 'dryRun'>): void;
|
|
4
|
+
export declare function publishCloudflare(input: string[]): void;
|
|
5
|
+
export declare function publish(input: string[]): void;
|
|
6
|
+
export declare function deploy(input: string[]): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { relative } from 'node:path';
|
|
5
|
+
import { readJson } from '../shared/json.js';
|
|
6
|
+
import { localBin } from '../shared/package-manager.js';
|
|
7
|
+
import { commandLine } from '../shared/process.js';
|
|
8
|
+
import { packageJsonPath, publishBuildCommand, publishTestUrl, publishWranglerArgs, publishWranglerCommand, publicationAccess, } from '../publish/config.js';
|
|
9
|
+
import { publishInitCloudflare } from '../publish/cloudflare-init.js';
|
|
10
|
+
import { splitPublishArgs } from '../publish/options.js';
|
|
11
|
+
import { withCloudflareEnvironment } from './environment.js';
|
|
12
|
+
import { runAccessHttpTest, runHttpTest } from './smoke-tests.js';
|
|
13
|
+
import { syncTriggerDev } from './trigger-dev.js';
|
|
14
|
+
export function runPublishCommand(root, child, options) {
|
|
15
|
+
console.log(commandLine(child.command, child.args));
|
|
16
|
+
if (options.dryRun)
|
|
17
|
+
return;
|
|
18
|
+
const result = spawnSync(child.command, child.args, {
|
|
19
|
+
cwd: root,
|
|
20
|
+
stdio: 'inherit',
|
|
21
|
+
env: process.env,
|
|
22
|
+
});
|
|
23
|
+
if (result.status !== 0)
|
|
24
|
+
process.exit(result.status ?? 1);
|
|
25
|
+
}
|
|
26
|
+
export function publishCloudflare(input) {
|
|
27
|
+
const options = splitPublishArgs(input);
|
|
28
|
+
const setup = publishInitCloudflare([
|
|
29
|
+
'--force',
|
|
30
|
+
'--config',
|
|
31
|
+
options.configFile,
|
|
32
|
+
...(options.hostname ? ['--hostname', options.hostname] : []),
|
|
33
|
+
options.targetPath,
|
|
34
|
+
]);
|
|
35
|
+
const { root, configPath, config, wranglerPath, hostname } = setup;
|
|
36
|
+
const packagePath = packageJsonPath(root);
|
|
37
|
+
if (!existsSync(packagePath))
|
|
38
|
+
throw new Error(`No package.json found next to ${configPath}`);
|
|
39
|
+
const pkg = readJson(packagePath);
|
|
40
|
+
withCloudflareEnvironment(root, config, options, () => {
|
|
41
|
+
if (options.build) {
|
|
42
|
+
runPublishCommand(root, publishBuildCommand(root, pkg), options);
|
|
43
|
+
}
|
|
44
|
+
const wrangler = publishWranglerCommand(root);
|
|
45
|
+
const configArg = relative(root, wranglerPath);
|
|
46
|
+
const secret = randomBytes(48).toString('base64');
|
|
47
|
+
const secretCommand = localBin(root, 'wrangler')
|
|
48
|
+
? `printf %s [generated-secret] | ${commandLine(wrangler, ['secret', 'put', 'KENNEL_AUTH_SECRET', '--config', configArg])}`
|
|
49
|
+
: `printf %s [generated-secret] | ${commandLine(wrangler, ['wrangler', 'secret', 'put', 'KENNEL_AUTH_SECRET', '--config', configArg])}`;
|
|
50
|
+
console.log(secretCommand);
|
|
51
|
+
if (!options.dryRun) {
|
|
52
|
+
const result = spawnSync('sh', ['-c', `printf %s "$KENNEL_AUTH_SECRET_VALUE" | ${commandLine(wrangler, publishWranglerArgs(root, ['secret', 'put', 'KENNEL_AUTH_SECRET', '--config', configArg]))}`], {
|
|
53
|
+
cwd: root,
|
|
54
|
+
stdio: 'inherit',
|
|
55
|
+
env: {
|
|
56
|
+
...process.env,
|
|
57
|
+
KENNEL_AUTH_SECRET_VALUE: secret,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
if (result.status !== 0)
|
|
61
|
+
process.exit(result.status ?? 1);
|
|
62
|
+
}
|
|
63
|
+
if (options.deploy) {
|
|
64
|
+
runPublishCommand(root, {
|
|
65
|
+
command: wrangler,
|
|
66
|
+
args: publishWranglerArgs(root, ['deploy', '--config', configArg]),
|
|
67
|
+
}, options);
|
|
68
|
+
}
|
|
69
|
+
syncTriggerDev(root, config, options);
|
|
70
|
+
if (!options.skipTest) {
|
|
71
|
+
if (publicationAccess(config).mode === 'cloudflare-access') {
|
|
72
|
+
runAccessHttpTest(root, hostname, options);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
runHttpTest(root, hostname, options.testEmail, options);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const url = publishTestUrl(hostname);
|
|
79
|
+
if (url)
|
|
80
|
+
console.log(`url ${url}`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function publish(input) {
|
|
84
|
+
if (input[0] === 'cloudflare' || input[0] === 'cloudflare-workers') {
|
|
85
|
+
return publishCloudflare(input.slice(1));
|
|
86
|
+
}
|
|
87
|
+
return publishCloudflare(input);
|
|
88
|
+
}
|
|
89
|
+
export function deploy(input) {
|
|
90
|
+
return publish(input);
|
|
91
|
+
}
|