@kurtbruns/kestrel 1.20.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -1
- package/bin/kestrel.mjs +1 -1
- package/cli/seed.mjs +2 -8
- package/dist/public/dashboard/app.js +297 -297
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +86 -51
- package/dist/worker/index.js +221 -57
- package/docs/get-started/02-deploy.md +2 -2
- package/docs/get-started/05-ses.md +2 -2
- package/docs/get-started/07-verify.md +1 -1
- package/docs/guides/03-notifications.md +17 -13
- package/docs/guides/07-upgrade.md +16 -2
- package/docs/publishing/03-name-your-publication.md +8 -0
- package/docs/publishing/04-write-a-post.md +1 -1
- package/docs/publishing/06-troubleshooting.md +1 -0
- package/docs/reference/02-sending-domain-dns.md +3 -3
- package/package.json +10 -10
- package/packages/kestrel/lib/api.mjs +19 -12
- package/packages/kestrel/lib/login.mjs +2 -2
- package/packages/kestrel/lib/media-types.mjs +34 -0
- package/packages/kestrel/lib/oauth.mjs +19 -2
- package/packages/kestrel/lib/openapi.mjs +1 -1
- package/template/README.md +1 -1
- package/template/claude/CLAUDE.md +1 -1
- package/template/claude/skills/upgrade/SKILL.md +1 -0
|
@@ -167,7 +167,7 @@ Create Kestrel's tables in the production database:
|
|
|
167
167
|
npx wrangler d1 migrations apply DB --remote --env production
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
It lists the migrations it's about to apply and asks you to confirm. It applies only the ones the database hasn't seen, so running it again is harmless.
|
|
170
|
+
It lists the migrations it's about to apply and asks you to confirm. Run by Claude, or in any other shell that isn't interactive, it answers its own prompt and applies them without asking. It applies only the ones the database hasn't seen, so running it again is harmless.
|
|
171
171
|
|
|
172
172
|
## 6. Deploy
|
|
173
173
|
|
|
@@ -175,7 +175,7 @@ It lists the migrations it's about to apply and asks you to confirm. It applies
|
|
|
175
175
|
npx wrangler deploy --env production
|
|
176
176
|
```
|
|
177
177
|
|
|
178
|
-
This deploys the release your instance runs, with your settings.
|
|
178
|
+
This deploys the release your instance runs, with your settings. Near its end, the output names the hostname the app now answers on, then its schedule and a version ID. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker's **Triggers** tab in the Cloudflare dashboard.
|
|
179
179
|
|
|
180
180
|
Always name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler deploys the top level, local development, as a second Worker that answers nowhere and never sends. It takes the top level's `name` in `wrangler.jsonc`, such as `newsletter-dev`. If that happens, remove it by that name:
|
|
181
181
|
|
|
@@ -51,7 +51,7 @@ The same form sets a custom MAIL FROM domain, so SPF also passes for your domain
|
|
|
51
51
|
|
|
52
52
|
## 2. Add a DMARC record
|
|
53
53
|
|
|
54
|
-
DMARC tells inboxes what to do with mail that fails its checks, and
|
|
54
|
+
DMARC tells inboxes what to do with mail that fails its checks, and can ask them to send you reports. The `rua` tag that asks for them is optional, and the policy works without it. SES doesn't add this record, so you add it yourself. It starts in monitor mode, `p=none`, which blocks nothing, your own mail included. It only monitors: inboxes still deliver mail that fails, so it doesn't stop anyone sending as your domain.
|
|
55
55
|
|
|
56
56
|
1. In the Cloudflare dashboard, open your domain's DNS records, and add a record:
|
|
57
57
|
|
|
@@ -309,7 +309,7 @@ Replies to a post go to your From address, `newsletter@send.example.com`, which
|
|
|
309
309
|
|
|
310
310
|
1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher's inbox as the **Destination**, and save.
|
|
311
311
|
|
|
312
|
-
1. **(Optional)**
|
|
312
|
+
1. **(Optional)** Forward your DMARC reports too, if you collect them and the address in your DMARC record doesn't receive mail. Add a rule that forwards `dmarc` the same way. Then change the record's `rua` to `mailto:dmarc@send.example.com`. Reports come about once a day from each inbox provider that received your mail. A day or two after your next email goes out, check that they arrive. Email Routing's **Activity log** shows each forward's status.
|
|
313
313
|
|
|
314
314
|
1. Check that your From address receives mail. This now lists Cloudflare's three `route` mail servers:
|
|
315
315
|
|
|
@@ -102,7 +102,7 @@ The app writes a log line for each thing it does, and Cloudflare keeps them for
|
|
|
102
102
|
|
|
103
103
|
1. Filter on `event` equal to `send.scheduled`. Your two sends from section 4 are there, and `send.canceled` shows the one you canceled. Each line that records an action says who took it. Its `principal` is `human` or `service`, and its `client` names the program a person used, such as `editor`.
|
|
104
104
|
|
|
105
|
-
1. Filter on `level` equal to `error`. A healthy instance shows nothing.
|
|
105
|
+
1. Filter on `level` equal to `error`. A healthy instance shows nothing. An occasional `d1.unavailable` line at `warn` is a brief database stall the next run makes up for, and needs nothing. The same line every minute means work isn't getting done, so if you set up alerts, alert on it repeating as well as on `error`.
|
|
106
106
|
|
|
107
107
|
To follow one send, narrow the time range and filter on its `sendId`, since a long range may show only a sample. On the Free plan, Cloudflare stops keeping logs for the rest of the day once its daily allowance is used, as [its pricing page](https://developers.cloudflare.com/observability/pricing/) describes.
|
|
108
108
|
|
|
@@ -19,7 +19,7 @@ Cloudflare's email sends only from a domain set up with Email Routing. Use your
|
|
|
19
19
|
|
|
20
20
|
1. Add `newsletter.example.com` to Email Routing. Cloudflare adds subdomains from your domain's own settings: select `example.com`, then **Settings**, and add `newsletter` under **Subdomains**.
|
|
21
21
|
|
|
22
|
-
1. Cloudflare adds
|
|
22
|
+
1. Cloudflare adds the DNS records it needs for `newsletter.example.com`. Wait until the new row reads **Status: Enabled** and **DNS records: Locked**.
|
|
23
23
|
|
|
24
24
|
Avoid turning on Email Routing for a domain that already receives mail elsewhere, such as `example.com` with Google Workspace. Email Routing takes over that domain's MX records. Don't send notifications from `send.example.com` either, since your email provider sends from it. Email Routing there to forward readers' replies, as [Connect Amazon SES](../get-started/05-ses.md#8-receive-replies-at-your-from-address) sets up, is fine, since it only receives.
|
|
25
25
|
|
|
@@ -27,27 +27,25 @@ Avoid turning on Email Routing for a domain that already receives mail elsewhere
|
|
|
27
27
|
|
|
28
28
|
Cloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan. Notifications can go to several addresses, such as the publisher's and yours, and each one needs verifying.
|
|
29
29
|
|
|
30
|
-
1. Under **Email Routing**, go to **Destination
|
|
30
|
+
1. Under **Email Routing**, go to **Destination addresses**, and add each address saved for notifications that isn't already verified there.
|
|
31
31
|
|
|
32
|
-
1. Open the email Cloudflare sends to each
|
|
32
|
+
1. Open the email Cloudflare sends to each one, and select **Verify email address**. If it's the publisher's address, ask them to do this step.
|
|
33
33
|
|
|
34
|
-
A verified address belongs to your Cloudflare account, so every environment in it can use it.
|
|
34
|
+
A verified address belongs to your Cloudflare account, so every environment in it can use it. If the publisher adds or changes a notifications address later, verify the new one too.
|
|
35
35
|
|
|
36
36
|
## 3. Add the binding and deploy
|
|
37
37
|
|
|
38
38
|
A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email. Without it, they go through your provider.
|
|
39
39
|
|
|
40
|
-
1. In `wrangler.jsonc`, add the binding to
|
|
40
|
+
1. In `wrangler.jsonc`, add the binding to `env.production`, beside `r2_buckets` and not inside `vars`:
|
|
41
41
|
|
|
42
42
|
```jsonc
|
|
43
|
-
"send_email": [
|
|
44
|
-
{ "name": "NOTIFY", "allowed_destination_addresses": ["you@example.com"] } // ← the notifications addresses
|
|
45
|
-
],
|
|
43
|
+
"send_email": [{ "name": "NOTIFY" }],
|
|
46
44
|
```
|
|
47
45
|
|
|
48
|
-
|
|
46
|
+
Give the binding only its name. This file is committed to your repository, so keep the notifications addresses out of it.
|
|
49
47
|
|
|
50
|
-
1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in
|
|
48
|
+
1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in `env.production.vars`:
|
|
51
49
|
|
|
52
50
|
```jsonc
|
|
53
51
|
"NOTIFY_FROM": "Kestrel <alerts@newsletter.example.com>",
|
|
@@ -62,7 +60,8 @@ A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email.
|
|
|
62
60
|
1. Commit the change, and push it:
|
|
63
61
|
|
|
64
62
|
```bash
|
|
65
|
-
git
|
|
63
|
+
git add wrangler.jsonc
|
|
64
|
+
git commit -m "Send notifications through Cloudflare's email"
|
|
66
65
|
git push
|
|
67
66
|
```
|
|
68
67
|
|
|
@@ -72,17 +71,22 @@ A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email.
|
|
|
72
71
|
npx wrangler deploy --env production
|
|
73
72
|
```
|
|
74
73
|
|
|
74
|
+
The output lists the binding as `env.NOTIFY (unrestricted)`. Cloudflare still delivers only to the addresses you verified.
|
|
75
|
+
|
|
75
76
|
## Check it
|
|
76
77
|
|
|
77
78
|
1. In the editor, open **Settings → Notifications**. **Sent through** reads "Cloudflare Email, separate from your newsletter's provider", and **From address** shows `Kestrel <kestrel@newsletter.example.com>`.
|
|
78
79
|
|
|
79
|
-
1. Select **Send a test notification**.
|
|
80
|
+
1. Select **Send a test notification**. **Last notification** shows it as delivered.
|
|
81
|
+
|
|
82
|
+
1. Check that the test arrives at each notifications address. If it's in the spam folder, mark it **Not spam**.
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
1. In Email Routing's **Activity log**, the test shows as **Delivered**. Go by this rather than Email Routing's summary, which [Cloudflare says](https://developers.cloudflare.com/email-service/platform/limits/) can list it as dropped even when it's delivered.
|
|
82
85
|
|
|
83
86
|
## What notifications don't cover
|
|
84
87
|
|
|
85
88
|
- **The schedule stopping.** The same once-a-minute schedule that sends posts also notices problems. If the schedule itself stops, nothing tells you. [Read the logs](../get-started/07-verify.md#7-read-the-logs) to check that `sweep.tick` still arrives every minute.
|
|
89
|
+
- **The database being down.** Noticing a problem and telling you both need the database. [Read the logs](../get-started/07-verify.md#7-read-the-logs) to check for `d1.unavailable` repeating.
|
|
86
90
|
- **A notification that can't get through.** The app tries each one up to five times, a minute apart. A failure shows under **Settings → Notifications**, and in the logs as `notify.failed`.
|
|
87
91
|
- **Problems while no address is set.** With the address blank, the app sends nothing, and saves nothing up for later.
|
|
88
92
|
|
|
@@ -16,6 +16,14 @@ A release can ask you to change a setting or a record before it runs. The change
|
|
|
16
16
|
|
|
17
17
|
Each step here changes only your instance's repository. Nothing reaches your deployed app until section 5.
|
|
18
18
|
|
|
19
|
+
1. Start from a clean repository, so the upgrade's commit holds only the upgrade and going back undoes nothing else:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git status
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If it lists anything, commit it first, as its own commit, or move it out of the repository.
|
|
26
|
+
|
|
19
27
|
1. Install the release. Replace `X.Y.Z` with its version:
|
|
20
28
|
|
|
21
29
|
```bash
|
|
@@ -24,6 +32,12 @@ Each step here changes only your instance's repository. Nothing reaches your dep
|
|
|
24
32
|
|
|
25
33
|
Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.
|
|
26
34
|
|
|
35
|
+
If npm stops with `ERESOLVE` and names wrangler, your `package.json` pins a wrangler older than the release needs. The error's `peer wrangler@` line gives the range it needs. Install both together, with that range in place of `WRANGLER_RANGE`:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @kurtbruns/kestrel@X.Y.Z "wrangler@WRANGLER_RANGE"
|
|
39
|
+
```
|
|
40
|
+
|
|
27
41
|
1. Bring in the release's database migrations:
|
|
28
42
|
|
|
29
43
|
```bash
|
|
@@ -91,7 +105,7 @@ A release changes the database only by adding a migration. Applying it keeps eve
|
|
|
91
105
|
npx wrangler d1 migrations apply DB --remote --env production
|
|
92
106
|
```
|
|
93
107
|
|
|
94
|
-
It lists the migrations it's about to apply, and asks you to confirm. When a release has none, it says there's nothing to apply. Run it on every upgrade.
|
|
108
|
+
It lists the migrations it's about to apply, and asks you to confirm. Run by Claude, or in any other shell that isn't interactive, it answers its own prompt and applies them without asking. When a release has none, it says there's nothing to apply. Run it on every upgrade.
|
|
95
109
|
|
|
96
110
|
## 5. Deploy
|
|
97
111
|
|
|
@@ -99,7 +113,7 @@ It lists the migrations it's about to apply, and asks you to confirm. When a rel
|
|
|
99
113
|
npx wrangler deploy --env production
|
|
100
114
|
```
|
|
101
115
|
|
|
102
|
-
|
|
116
|
+
Near its end, the output names your app's hostname, then its schedule and a version ID.
|
|
103
117
|
|
|
104
118
|
## Check it
|
|
105
119
|
|
|
@@ -6,6 +6,14 @@ All three are optional, and you can change them at any time. How they appear in
|
|
|
6
6
|
|
|
7
7
|
Write the tagline as one line that says what the newsletter covers and how often it goes out, such as "Monthly notes on the birds of the Hudson Valley." Until your first post, it's the only line on your home page that says what readers are signing up for. It's also what AWS sees if it reviews your site when you [request production access](../get-started/06-production-access.md).
|
|
8
8
|
|
|
9
|
+
## Choose a logo
|
|
10
|
+
|
|
11
|
+
**Use a square PNG, JPEG, WebP, or GIF, at least 256 × 256 pixels, on a solid background.** Drop it on the logo under **Settings → Publication identity**, or choose **Upload**, which reads **Replace** once you have a logo. It saves right away, without **Save changes**. If that would change scheduled emails, Kestrel asks you to confirm first.
|
|
12
|
+
|
|
13
|
+
Kestrel makes a 256 × 256 PNG from your image before uploading it, so the original's file size doesn't matter. A wider or taller image is cropped to its center square, and a smaller one is scaled up and looks soft. An animated GIF keeps its first frame. Emails show the logo at 44 × 44 pixels, so a simple mark reads better than fine detail.
|
|
14
|
+
|
|
15
|
+
A transparent background shows whatever is behind the logo, and the built-in templates turn dark when a reader's mail app is in dark mode. A solid background keeps a dark mark visible there.
|
|
16
|
+
|
|
9
17
|
## When to provide a mailing address
|
|
10
18
|
|
|
11
19
|
**If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**
|
|
@@ -10,7 +10,7 @@ The editor takes Markdown, and Kestrel saves as you type. The same Markdown beco
|
|
|
10
10
|
|
|
11
11
|
- A blank line starts a new paragraph.
|
|
12
12
|
- The opening lines are the preview a mail client shows beside the subject.
|
|
13
|
-
- Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB. Kestrel may change its file name, turning spaces into hyphens and numbering a name the post already has, and inserts the new name for you.
|
|
13
|
+
- Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB. A file named for a different format than it really is, like a JPEG called `photo.png`, is refused until you rename it. Kestrel may change its file name, turning spaces into hyphens and numbering a name the post already has, and inserts the new name for you.
|
|
14
14
|
|
|
15
15
|
## Schedule and send
|
|
16
16
|
|
|
@@ -102,6 +102,7 @@ Nothing on your list changes. Kestrel refuses a file it can't read with certaint
|
|
|
102
102
|
- `kestrel api` says it couldn't renew your sign-in because its Access team answered 404 (not found). First check which instance it called: when you don't name one, it prints a line naming it before "the only instance you're signed in to", which may not be the one you meant. Name yours with `--url`. If it is yours, the Access team domain was most likely renamed or removed since you signed in. Sign in again with `npx kestrel login` and your instance's address, which finds the team it uses now.
|
|
103
103
|
- **"Access let you in, but didn't say who you are".** Your login carried neither an email nor a service token. Sign out, then sign in through the policy that lets you in by email. If you have no such policy, your instance's Access setup needs the fix.
|
|
104
104
|
- **Claude's changes read "via the API".** Claude called the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api` once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading "a service token" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.
|
|
105
|
+
- **A change reads "via the Kestrel CLI".** The person it names made it with `kestrel api` outside Claude Code, by hand or through a script, so there's nothing to fix. If Claude Code made it, update Claude Code to v2.1.172 or later: an older one reads this way, and the dashboard can stop saying "Claude is connected."
|
|
105
106
|
|
|
106
107
|
If someone else set up Kestrel for you, pass this along to them.
|
|
107
108
|
|
|
@@ -52,14 +52,14 @@ In Cloudflare, set every `CNAME` for DKIM to **DNS only**, not proxied. SES repo
|
|
|
52
52
|
|
|
53
53
|
### DMARC: publish a policy and collect reports
|
|
54
54
|
|
|
55
|
-
DMARC tells an inbox what to do with mail that fails both SPF and DKIM, and
|
|
55
|
+
DMARC tells an inbox what to do with mail that fails both SPF and DKIM, and can ask it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:
|
|
56
56
|
|
|
57
57
|
```
|
|
58
58
|
_dmarc.send.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
- `p=none` is monitor mode. Inboxes
|
|
62
|
-
- `rua` is where reports go.
|
|
61
|
+
- `p=none` is monitor mode, and it only monitors. Inboxes deliver failing mail as usual, so it doesn't stop anyone sending as your domain. It's where to start because it blocks nothing, your own mail included.
|
|
62
|
+
- `rua` is where reports go. It's optional, and the policy works without it, though [Google's DMARC setup page](https://knowledge.workspace.google.com/admin/security/set-up-dmarc) recommends including it. Reports arrive as attachments, usually one a day from each inbox provider.
|
|
63
63
|
|
|
64
64
|
Leave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.
|
|
65
65
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kurtbruns/kestrel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "An open-source, self-hosted newsletter app where you own the list and the archive. Work in the editor, or just ask Claude. Runs on Cloudflare Workers.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,21 +68,21 @@
|
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@css-inline/css-inline-wasm": "^0.21.2",
|
|
70
70
|
"aws4fetch": "^1.0.20",
|
|
71
|
-
"jose": "^6.2.
|
|
72
|
-
"marked": "^18.
|
|
71
|
+
"jose": "^6.2.14",
|
|
72
|
+
"marked": "^18.1.3"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
|
-
"wrangler": "^4.
|
|
75
|
+
"wrangler": "^4.156.0"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@biomejs/biome": "2.
|
|
79
|
-
"@cloudflare/vitest-plugin": "^1.4.
|
|
80
|
-
"@types/node": "^22.20.
|
|
81
|
-
"esbuild": "0.28.
|
|
82
|
-
"happy-dom": "^
|
|
78
|
+
"@biomejs/biome": "2.6.4",
|
|
79
|
+
"@cloudflare/vitest-plugin": "^1.4.11",
|
|
80
|
+
"@types/node": "^22.20.5",
|
|
81
|
+
"esbuild": "0.28.2",
|
|
82
|
+
"happy-dom": "^21.0.4",
|
|
83
83
|
"typescript": "~5.9.3",
|
|
84
84
|
"vitest": "^4.1.12",
|
|
85
|
-
"wrangler": "^4.
|
|
85
|
+
"wrangler": "^4.156.0"
|
|
86
86
|
},
|
|
87
87
|
"allowScripts": {
|
|
88
88
|
"esbuild": true,
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* Deployed, it sends the access token `kestrel login` stored, refreshing it from the
|
|
6
6
|
* refresh token when it is about to expire or Access has turned it away, and storing what
|
|
7
7
|
* comes back. Locally there is no Access: the target is this directory's dev server, and
|
|
8
|
-
* the credential is the dev server's human token, so a local run is a person
|
|
9
|
-
*
|
|
10
|
-
* unless `--client` names another program, so a person's own script
|
|
11
|
-
* never names the editor: the server trusts the name it is sent (SPEC,
|
|
12
|
-
* and the editor's name lifts a step only a person in the editor may take
|
|
8
|
+
* the credential is the dev server's human token, so a local run is a person through a
|
|
9
|
+
* client just as a deployed one is. Every request names its client in `Kestrel-Client`:
|
|
10
|
+
* `defaultClient` says which, unless `--client` names another program, so a person's own script
|
|
11
|
+
* can name itself. It never names the editor: the server trusts the name it is sent (SPEC,
|
|
12
|
+
* Trusted, not guarded), and the editor's name lifts a step only a person in the editor may take
|
|
13
|
+
* (SPEC §7).
|
|
13
14
|
*
|
|
14
15
|
* It prints the answer's body as it came, so a script or Claude reads a refusal whole; a
|
|
15
16
|
* non-2xx answer also prints its status, and a refusal's code, message, field, and next move,
|
|
@@ -24,10 +25,13 @@ import { readFileSync } from "node:fs";
|
|
|
24
25
|
import { basename } from "node:path";
|
|
25
26
|
import { readSignIn, readSignIns, withCredentialsLock, writeSignInHeld } from "./credentials.mjs";
|
|
26
27
|
import { devTarget } from "./dev-api.mjs";
|
|
28
|
+
import { fileMediaType } from "./media-types.mjs";
|
|
27
29
|
import {
|
|
28
|
-
|
|
30
|
+
CLAUDE_CODE_CLIENT,
|
|
31
|
+
defaultClient,
|
|
29
32
|
instanceOrigin,
|
|
30
33
|
isLoopback,
|
|
34
|
+
KESTREL_CLI_CLIENT,
|
|
31
35
|
needsRefresh,
|
|
32
36
|
refreshTokens,
|
|
33
37
|
SignInError,
|
|
@@ -54,14 +58,16 @@ const USAGE = `Usage: kestrel api <method> <path> [options]
|
|
|
54
58
|
--form <name=value> a multipart field, repeatable; name=@file attaches a file
|
|
55
59
|
--url <url> the instance (default: KESTREL_URL, else the only one you're signed in to)
|
|
56
60
|
--local this directory's dev server, started by kestrel dev
|
|
57
|
-
--client <name> your own script's Kestrel-Client name (default ${
|
|
58
|
-
|
|
61
|
+
--client <name> your own script's Kestrel-Client name (default ${CLAUDE_CODE_CLIENT}
|
|
62
|
+
when Claude Code runs this, otherwise ${KESTREL_CLI_CLIENT}); never
|
|
63
|
+
editor, which only the editor names itself
|
|
59
64
|
|
|
60
65
|
The routes, fields, and refusals come from the instance's own /api/openapi.json, so they
|
|
61
66
|
match the release it runs. A field the route doesn't take is refused before the request.`;
|
|
62
67
|
|
|
63
|
-
// The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11).
|
|
64
|
-
|
|
68
|
+
// The name only the editor sends; `kestrel api` refuses to claim it (SPEC §7, §11). Mirrored
|
|
69
|
+
// from shared/who_acted.ts, since the CLI is plain Node; a spec holds the two equal.
|
|
70
|
+
export const EDITOR_CLIENT = "editor";
|
|
65
71
|
|
|
66
72
|
// The server's rule for a client name (shared/who_acted.ts), mirrored since the CLI is plain
|
|
67
73
|
// Node; a spec holds the two equal. Checking it here, after the trim fetch applies to a header
|
|
@@ -227,7 +233,8 @@ async function requestBody({ data, type, forms }) {
|
|
|
227
233
|
const value = field.slice(at + 1);
|
|
228
234
|
if (value.startsWith("@")) {
|
|
229
235
|
const file = value.slice(1);
|
|
230
|
-
|
|
236
|
+
const type = fileMediaType(file);
|
|
237
|
+
form.append(name, new Blob([readFileSync(file)], { type }), basename(file));
|
|
231
238
|
} else {
|
|
232
239
|
form.append(name, value);
|
|
233
240
|
}
|
|
@@ -416,7 +423,7 @@ export async function apiCommand(argv) {
|
|
|
416
423
|
let call;
|
|
417
424
|
try {
|
|
418
425
|
const args = parseApiArgs(argv);
|
|
419
|
-
const client = args.client ??
|
|
426
|
+
const client = args.client ?? defaultClient();
|
|
420
427
|
call = await openTarget(args, client);
|
|
421
428
|
if (args.mode === "list") {
|
|
422
429
|
console.log(listRoutes(await readOpenApi(call), args.resource));
|
|
@@ -13,7 +13,7 @@ import { signedInFetch } from "./api.mjs";
|
|
|
13
13
|
import { credentialsFile, readSignIn, readSignIns, writeSignIn } from "./credentials.mjs";
|
|
14
14
|
import {
|
|
15
15
|
authorizeUrl,
|
|
16
|
-
|
|
16
|
+
defaultClient,
|
|
17
17
|
discover,
|
|
18
18
|
exchangeCode,
|
|
19
19
|
instanceOrigin,
|
|
@@ -86,7 +86,7 @@ export async function signIn(origin, { browser }) {
|
|
|
86
86
|
} finally {
|
|
87
87
|
listener.close();
|
|
88
88
|
}
|
|
89
|
-
const res = await signedInFetch(origin, PROTECTED_PATH, { client:
|
|
89
|
+
const res = await signedInFetch(origin, PROTECTED_PATH, { client: defaultClient() });
|
|
90
90
|
const body = await res.json().catch(() => null);
|
|
91
91
|
if (!res.ok) {
|
|
92
92
|
const move = unauthorizedMove(body);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A file's media type, read from its extension. An instance reads an upload's format from its
|
|
3
|
+
* bytes and holds the label to them, but this client also calls instances on older releases,
|
|
4
|
+
* which read only what the multipart part declares and refuse one with no type, so it states one:
|
|
5
|
+
* `kestrel api --form` does, and so does `kestrel seed`, which imports this rather than keep
|
|
6
|
+
* its own copy (the client may not import from cli/, but cli/ imports from the client).
|
|
7
|
+
*/
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The extensions of the raster formats an instance takes as an image. The Worker's list is
|
|
12
|
+
* src/lib/image_types.ts, mirrored since the client is plain Node; a spec holds the two equal.
|
|
13
|
+
*/
|
|
14
|
+
export const IMAGE_TYPE_BY_EXTENSION = {
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** What a file goes as when its extension says nothing: a statement of no kind, not a guess. */
|
|
23
|
+
const UNKNOWN_MEDIA_TYPE = "application/octet-stream";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The media type a file's name says it is, in any case (a camera's `IMG_0042.JPG`), read just as
|
|
27
|
+
* the server reads a name's claim (shared/images.ts), so the two agree on a file called `.png`.
|
|
28
|
+
*/
|
|
29
|
+
export function fileMediaType(path) {
|
|
30
|
+
const name = basename(path);
|
|
31
|
+
return (
|
|
32
|
+
IMAGE_TYPE_BY_EXTENSION[name.slice(name.lastIndexOf(".")).toLowerCase()] ?? UNKNOWN_MEDIA_TYPE
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -19,8 +19,25 @@ import { createServer } from "node:http";
|
|
|
19
19
|
/** The path discovery and the sign-in's check ask: protected, and it answers who signed in. */
|
|
20
20
|
export const PROTECTED_PATH = "/api/whoami";
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// The names `kestrel api` gives itself (SPEC §11, Who acted). Mirrored from shared/who_acted.ts,
|
|
23
|
+
// since the client is plain Node; a spec holds them equal.
|
|
24
|
+
export const CLAUDE_CODE_CLIENT = "claude-code";
|
|
25
|
+
export const KESTREL_CLI_CLIENT = "kestrel-cli";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The name a request carries unless told another: Claude Code's when Claude Code ran this
|
|
29
|
+
* process, and the command's own otherwise, so what a person runs never reads as Claude's.
|
|
30
|
+
* CLAUDE_CODE_CHILD_SESSION is set only by Claude Code, on what its tools launch. CLAUDECODE
|
|
31
|
+
* won't do: IDE extensions also set it in their integrated terminals, where a person types.
|
|
32
|
+
*
|
|
33
|
+
* That makes the name truthful in the normal case, not proven, as with every client name. Claude
|
|
34
|
+
* Code older than v2.1.172 doesn't set the variable, so its calls read as the command's own; and
|
|
35
|
+
* a `screen` session or a background launcher Claude Code started passes it on, so what a person
|
|
36
|
+
* later runs inside one reads as Claude Code's.
|
|
37
|
+
*/
|
|
38
|
+
export function defaultClient() {
|
|
39
|
+
return process.env.CLAUDE_CODE_CHILD_SESSION === "1" ? CLAUDE_CODE_CLIENT : KESTREL_CLI_CLIENT;
|
|
40
|
+
}
|
|
24
41
|
|
|
25
42
|
// How long before its expiry an access token is refreshed rather than sent.
|
|
26
43
|
const REFRESH_MARGIN_MS = 60_000;
|
|
@@ -278,7 +278,7 @@ export function describeRoute(doc, method, path) {
|
|
|
278
278
|
}
|
|
279
279
|
const raw = types.filter((t) => t !== "application/json" && t !== "multipart/form-data");
|
|
280
280
|
if (raw.length) {
|
|
281
|
-
how.push(`the file itself, with --data @file --type ${raw
|
|
281
|
+
how.push(`the file itself, with --data @file --type <its own type> (${raw.join(", ")})`);
|
|
282
282
|
}
|
|
283
283
|
out.push(` Send it as ${how.join("; or ")}.`);
|
|
284
284
|
const example = op.requestBody.content["application/json"]?.example;
|
package/template/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Open the editor at http://localhost:8787/dashboard/. Email goes to a stand-in th
|
|
|
23
23
|
| `npm run sync-migrations` | Copies the installed release's migrations into `migrations/` |
|
|
24
24
|
| `npm run check-context` | Compares the Claude files in `.claude/` with the installed release's |
|
|
25
25
|
| `npx kestrel login <url>` | Signs Claude Code in to the deployed instance as you, in your browser |
|
|
26
|
-
| `npx kestrel api <method> <path>` | Calls the API as you
|
|
26
|
+
| `npx kestrel api <method> <path>` | Calls the API as you (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
|
|
27
27
|
| `npx wrangler deploy --env production` | Deploys the instance |
|
|
28
28
|
| `npx wrangler d1 migrations apply DB --remote --env production` | Applies new migrations to the production database |
|
|
29
29
|
|
|
@@ -29,7 +29,7 @@ Deploying, migrating the production database, and anything that sends mail or ch
|
|
|
29
29
|
- **Locally,** with `npm run dev` running, add `--local`: it calls this folder's dev server as its stand-in person, via Claude Code, with no sign-in. `GET /api/dev/outbox` shows the email the stand-in caught.
|
|
30
30
|
- **`npx kestrel api --list` lists the instance's routes** (`--list sends` one resource's), and **`npx kestrel api help <method> <path>` describes one**: its parameters, body fields, answer, and refusals with their next moves. Both read the instance's own OpenAPI document, so they match the release it runs.
|
|
31
31
|
- **Read `GET /api/reference.md` first:** how to authenticate, what a status and an error body tell you and the refusals any route can give, paging, acting safely, and every route on one line. **Before calling a route, read its resource's reference,** `GET /api/reference/<resource>.md` (the index names it, such as `sends.md`): the object the routes return, and each route's parameters, body, answer, refusals (each with what to do next), and an example. Read a resource's reference once per conversation, not before every call.
|
|
32
|
-
- **Posts with images:** upload each image from
|
|
32
|
+
- **Posts with images:** upload each image from its original file, asking the person to save it into this folder (the Claude desktop app saves a pasted image to a temporary folder, converted and under a generated name, so it can be uploaded but isn't the file they had), then reference it in the Markdown by the `filename` the upload answers, with alt text, ``, never by the upload's `url`. That name may differ from the one you sent, since spaces and characters that would break a reference become hyphens, and a name the post already has gets a number. An upload never replaces an image: to swap one, upload the new image, reference its `filename`, and delete the old one. The preview warns on a `url` reference, on a name the post doesn't have, on a reference that didn't become an image, and on an uploaded image the post doesn't use.
|
|
33
33
|
- **Never overwrite someone else's edit.** A save names the revision it was based on (the posts reference says how). On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask.
|
|
34
34
|
- **Offer only what a post's or a send's `actions` list,** as the reference's Acting safely says, so a post with no `schedule` or `send_now` (no email provider connected) isn't offered for sending. Read the answer to an act, or its refusal, before telling the publisher what happened or deciding again: it carries the resource as it now stands.
|
|
35
35
|
- **Resolve a wedged send only on the publisher's choice.** Relay the `wedged` condition's message and ask which `resolution` they want, in the words the editor's Resolve dialog shows them: `unsent` is **Assume not sent**, which puts those readers in front of the next post, and `accepted` is **Assume sent**, which records them as sent. Never choose it yourself.
|
|
@@ -11,6 +11,7 @@ An upgrade moves the instance to a newer release of `@kurtbruns/kestrel`. The gu
|
|
|
11
11
|
## What's yours to judge
|
|
12
12
|
|
|
13
13
|
- **Which release, and what it asks.** The installed version is in `package.json`. Read every changelog section between it and the target (the release notes at `https://github.com/kurtbruns/kestrel/releases`, or the new `CHANGELOG.md` once installed). Name each **Breaking** entry and each step in an *Upgrading* paragraph, and say what it means for this instance.
|
|
14
|
+
- **Anything not yet committed.** If the page's `git status` lists anything, show the person and ask whether to commit it first, as its own commit. If they'd rather not, the upgrade waits until the repository is clean. Never let it go into the upgrade's commit: the page's **Go back** reverts that commit, and would undo it too.
|
|
14
15
|
- **The context check.** `npm run check-context` compares this repository's Claude files with the release's. Show the person what it reports. When Kestrel changed a file the person has edited, show what changed, merge it with their edits, and run the command it suggests to record the merge. A change marked as bearing on safety comes first.
|
|
15
16
|
- **The local try.** Ask the person to try what they care about on `npm run dev` before anything reaches production.
|
|
16
17
|
- **The checks.** Report each one's result. A failure stops the upgrade until it's fixed or the person decides.
|