@kurtbruns/kestrel 1.20.0 → 1.22.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.
@@ -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. The output ends with the hostname the app now answers on. 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.
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 sends you reports about it. SES doesn't add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.
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)** Give your DMARC reports an inbox too. If 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`.
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 MX, SPF, and DKIM records for `newsletter.example.com`, and locks them. Wait until they show as added.
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 Addresses**, and add each address saved for notifications.
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 address, and select **Verify email address**. If it's the publisher's address, ask them to do this step.
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 the `production` block:
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
- `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified. If the publisher adds or changes a notifications address later, verify the new one and add it here too.
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 the block's `vars`:
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 commit -am "Send notifications through Cloudflare's email"
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**. It arrives at each notifications address, and **Last notification** shows it as delivered.
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
- If the test isn't delivered to an address, **Last notification** shows Cloudflare's reason, such as an address that isn't verified, after the address when there are several. The other addresses still get it. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check the notifications inbox rather than the summary.
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
 
@@ -2,16 +2,26 @@
2
2
 
3
3
  A new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.
4
4
 
5
- In this guide, you read what changed, install the release, and note where your database stands. Then you apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.
5
+ In this guide, you first read what changed and check that your repository is clean, which changes nothing. Then you install the release, note where your database stands, apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.
6
6
 
7
- ## 1. Read what changed
7
+ ## 1. Check before you change anything
8
8
 
9
- A release can ask you to change a setting or a record before it runs. The changelog says so, release by release.
9
+ A release can ask you to change a setting or a record before it runs. The changelog says so, release by release. The upgrade also has to start from a clean repository.
10
10
 
11
11
  1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.
12
12
 
13
13
  1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from…** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.
14
14
 
15
+ 1. Check that your repository is clean, so the upgrade's commit holds only the upgrade and going back undoes nothing else:
16
+
17
+ ```bash
18
+ git status
19
+ ```
20
+
21
+ It has to say `nothing to commit, working tree clean`. If it lists anything, untracked files included, the upgrade waits until it doesn't.
22
+
23
+ Nothing has changed yet. If you aren't going on, stop here.
24
+
15
25
  ## 2. Install the release
16
26
 
17
27
  Each step here changes only your instance's repository. Nothing reaches your deployed app until section 5.
@@ -24,6 +34,12 @@ Each step here changes only your instance's repository. Nothing reaches your dep
24
34
 
25
35
  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
36
 
37
+ 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`:
38
+
39
+ ```bash
40
+ npm install @kurtbruns/kestrel@X.Y.Z "wrangler@WRANGLER_RANGE"
41
+ ```
42
+
27
43
  1. Bring in the release's database migrations:
28
44
 
29
45
  ```bash
@@ -91,7 +107,7 @@ A release changes the database only by adding a migration. Applying it keeps eve
91
107
  npx wrangler d1 migrations apply DB --remote --env production
92
108
  ```
93
109
 
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.
110
+ 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
111
 
96
112
  ## 5. Deploy
97
113
 
@@ -99,7 +115,7 @@ It lists the migrations it's about to apply, and asks you to confirm. When a rel
99
115
  npx wrangler deploy --env production
100
116
  ```
101
117
 
102
- The output ends with your app's hostname.
118
+ Near its end, the output names your app's hostname, then its schedule and a version ID.
103
119
 
104
120
  ## Check it
105
121
 
@@ -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
 
@@ -97,11 +97,13 @@ Nothing on your list changes. Kestrel refuses a file it can't read with certaint
97
97
  [Connect Claude](08-connect-claude.md) covers connecting it. Most failures are in your instance's Cloudflare Access setup, and [Allow Claude to connect](../guides/01-allow-claude.md#check-it) lists their fixes. When connecting fails:
98
98
 
99
99
  - **Claude says it couldn't connect, or the sign-in failed.** Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer. If both are right, your instance's Access setup needs the fix.
100
+ - **Claude Code says Dynamic Client Registration was rejected.** `claude mcp login` fails with `Dynamic Client Registration rejected (HTTP 404)`, and a new session shows the connector as failed with the same words. Start `claude`, run `/mcp`, choose the connector, choose Authenticate, and sign in in the browser. The sign-in is stored, so later sessions connect. Nothing on your instance is wrong. `claude mcp login` looks for sign-in details at the standard well-known addresses before reading your instance's own answer, so it never reaches Access. The MCP specification has a client do that only as a fallback.
100
101
  - Signing in shows `invalid_target`, or `kestrel login` says your instance "doesn't offer a sign-in for programs". Your instance's Access setup needs the fix.
101
102
  - `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in, and your instance's Access setup needs the fix. If either names `access_unidentified`, it's the "Access let you in" problem below. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.
102
103
  - `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
104
  - **"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
105
  - **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.
106
+ - **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
107
 
106
108
  If someone else set up Kestrel for you, pass this along to them.
107
109
 
@@ -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 asks it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:
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 report on failing mail, but deliver it as usual.
62
- - `rua` is where reports go. They arrive as attachments, usually one a day from each inbox provider.
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.20.0",
3
+ "version": "1.22.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.12",
72
- "marked": "^18.0.12"
71
+ "jose": "^6.2.14",
72
+ "marked": "^18.1.3"
73
73
  },
74
74
  "peerDependencies": {
75
- "wrangler": "^4.152.0"
75
+ "wrangler": "^4.156.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@biomejs/biome": "2.5.6",
79
- "@cloudflare/vitest-plugin": "^1.4.7",
80
- "@types/node": "^22.20.1",
81
- "esbuild": "0.28.1",
82
- "happy-dom": "^20.14.5",
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.152.0"
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 via Claude
9
- * Code just as a deployed one is. Every request carries `Kestrel-Client: claude-code`
10
- * unless `--client` names another program, so a person's own script can name itself. It
11
- * never names the editor: the server trusts the name it is sent (SPEC, Trusted, not guarded),
12
- * and the editor's name lifts a step only a person in the editor may take (SPEC §7).
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
- DEFAULT_CLIENT,
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 ${DEFAULT_CLIENT});
58
- never editor, which only the editor names itself
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
- const EDITOR_CLIENT = "editor";
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
- form.append(name, new Blob([readFileSync(file)]), basename(file));
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 ?? DEFAULT_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
- DEFAULT_CLIENT,
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: DEFAULT_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,26 @@ 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
- /** The name `kestrel api` gives its requests, unless told another (SPEC §11, Who acted). */
23
- export const DEFAULT_CLIENT = "claude-code";
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, as in a terminal a person opened themselves.
30
+ * CLAUDE_CODE_CHILD_SESSION is set only by Claude Code, on what it launches. 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; a
35
+ * command typed after Claude Code's `!` runs in its shell, with output landing in the transcript,
36
+ * so it reads as Claude Code's; and a `screen` session or a background launcher Claude Code
37
+ * started passes the variable on, so what a person later runs inside one reads as Claude Code's.
38
+ */
39
+ export function defaultClient() {
40
+ return process.env.CLAUDE_CODE_CHILD_SESSION === "1" ? CLAUDE_CODE_CLIENT : KESTREL_CLI_CLIENT;
41
+ }
24
42
 
25
43
  // How long before its expiry an access token is refreshed rather than sent.
26
44
  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[0]}`);
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;
@@ -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, via Claude Code (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
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 a file on disk (an image pasted into the chat isn't one; ask the person to save it into this folder), then reference it in the Markdown by the `filename` the upload answers, with alt text, `![a short description](cover.jpg)`, 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.
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, `![a short description](cover.jpg)`, 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.
@@ -6,11 +6,13 @@ argument-hint: "[X.Y.Z]"
6
6
 
7
7
  # Upgrade the instance
8
8
 
9
- An upgrade moves the instance to a newer release of `@kurtbruns/kestrel`. The guide's upgrade page is the procedure: `node_modules/@kurtbruns/kestrel/docs/guides/07-upgrade.md`. Read it now, and again after installing the new release, whose page then replaces it. Follow it section by section, running each step's commands as written. This skill holds only what the page leaves to you, and never adds or replaces a step. If anything here seems to disagree with the page, the page wins; say so.
9
+ An upgrade moves the instance to a newer release of `@kurtbruns/kestrel`. The guide's upgrade page is the procedure: `node_modules/@kurtbruns/kestrel/docs/guides/07-upgrade.md`. Read it now, and again after installing the new release, whose page then replaces it from the step after the install. Follow it section by section, running each step's commands as written. This skill holds only what the page leaves to you, and never adds or replaces a step. If anything here seems to disagree with the page, the page wins; say so.
10
10
 
11
11
  ## What's yours to judge
12
12
 
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.
13
+ - **Which release, and what it asks.** The installed version is in `package.json`. Read every changelog section between it and the target, in the release notes at `https://github.com/kurtbruns/kestrel/releases`, and work out what each **Breaking** entry and each step in an *Upgrading* paragraph means for this instance.
14
+ - **Anything not yet committed.** If the page's `git status` lists anything, report what it lists and stop until it lists nothing. How it gets clean is the person's call, so don't offer to commit it or move it.
15
+ - **The go-ahead.** The page's section 1 changes nothing, and neither do you until this is settled. Name the releases, each **Breaking** entry, and each *Upgrading* step, say what each means for this instance, and ask whether to go ahead. Run nothing from section 2 until they say yes. "Not now" ends the upgrade with nothing changed.
14
16
  - **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
17
  - **The local try.** Ask the person to try what they care about on `npm run dev` before anything reaches production.
16
18
  - **The checks.** Report each one's result. A failure stops the upgrade until it's fixed or the person decides.