@kurtbruns/kestrel 1.17.0 → 1.19.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.
Files changed (45) hide show
  1. package/.dev.vars.example +24 -2
  2. package/CHANGELOG.md +73 -1
  3. package/bin/kestrel.mjs +1 -1
  4. package/cli/dev.mjs +3 -3
  5. package/cli/init.mjs +98 -4
  6. package/dist/public/dashboard/app.js +327 -327
  7. package/dist/public/dashboard/app.js.map +4 -4
  8. package/dist/public/dashboard/index.html +6 -3
  9. package/dist/public/dashboard/styles.css +133 -21
  10. package/dist/worker/index.js +5940 -4434
  11. package/docs/README.md +5 -3
  12. package/docs/get-started/02-deploy.md +25 -21
  13. package/docs/get-started/03-access.md +20 -8
  14. package/docs/get-started/04-aws-account.md +68 -0
  15. package/docs/get-started/05-ses.md +326 -0
  16. package/docs/get-started/06-production-access.md +67 -0
  17. package/docs/get-started/{05-verify.md → 07-verify.md} +16 -26
  18. package/docs/get-started/{06-go-live.md → 08-go-live.md} +4 -2
  19. package/docs/guides/01-allow-claude.md +2 -2
  20. package/docs/guides/02-automate.md +1 -1
  21. package/docs/guides/03-notifications.md +12 -12
  22. package/docs/guides/07-upgrade.md +4 -4
  23. package/docs/guides/09-as-your-list-grows.md +8 -18
  24. package/docs/publishing/02-sending-rules.md +2 -2
  25. package/docs/publishing/03-name-your-publication.md +2 -0
  26. package/docs/publishing/04-write-a-post.md +1 -1
  27. package/docs/publishing/06-troubleshooting.md +37 -9
  28. package/docs/publishing/08-connect-claude.md +1 -1
  29. package/docs/publishing/09-share-your-subscribe-link.md +3 -1
  30. package/docs/publishing/10-check-your-setup.md +15 -9
  31. package/docs/publishing/README.md +8 -3
  32. package/docs/reference/01-configuration.md +18 -16
  33. package/docs/reference/02-sending-domain-dns.md +4 -4
  34. package/migrations/0015_provider_account.sql +18 -0
  35. package/migrations/0016_held_receipts.sql +26 -0
  36. package/migrations/0017_provider_account_rejected.sql +24 -0
  37. package/migrations/0018_notify_addresses.sql +13 -0
  38. package/migrations/0019_sending.sql +67 -0
  39. package/migrations/0020_notifications_not_sending.sql +40 -0
  40. package/package.json +1 -1
  41. package/packages/kestrel/lib/api.mjs +28 -3
  42. package/packages/kestrel/lib/oauth.mjs +5 -3
  43. package/template/claude/CLAUDE.md +5 -3
  44. package/template/wrangler.jsonc +6 -6
  45. package/docs/get-started/04-ses.md +0 -310
package/.dev.vars.example CHANGED
@@ -27,8 +27,8 @@ MIN_LEAD_SECONDS="60"
27
27
  # On a list send, local dev stands in for a real provider instead of accepting everything
28
28
  # at once: the provider's batch size and pace, its failures, and delivery receipts that
29
29
  # arrive after the send (delivered, bounced, complained), all through the real send loop.
30
- # Test sends and confirmations go straight to the dev outbox (GET /api/dev/outbox),
31
- # unsimulated. The profile names the provider modeled:
30
+ # Test sends, confirmations, and notifications go straight to the dev outbox
31
+ # (GET /api/dev/outbox), unfaulted. The profile names the provider modeled:
32
32
  # ses Amazon SES: a second of its send rate a request (up to 50), one request a
33
33
  # second, no key (a lost request waits for Resolve), and its daily quota running
34
34
  # out once a send; a slice a minute on the default SUBREQUEST_BUDGET, as SES is
@@ -38,6 +38,28 @@ MIN_LEAD_SECONDS="60"
38
38
  # plain instant fake. "not-connected" runs the app as a deployment with no email provider
39
39
  # yet: every send refused, signups closed, a due send held. Switched back, the held send
40
40
  # goes out at its next retry, a few minutes later.
41
+ # Add ":refuse=" and a cause (e.g. "ses:refuse=configuration") to have SES refuse every
42
+ # email, tests, confirmations, and notifications included, as it would for that cause:
43
+ # credentials, opt_in, configuration, sender, mail_from, paused, set_paused, suspended, or
44
+ # unsupported. Email then reads as refusing and signups as paused. Beside a sandbox account
45
+ # (SIMULATE_ACCOUNT=sandbox), SES refuses any recipient not at SENDING_DOMAIN or SES's
46
+ # mailbox simulator.
41
47
  # For one run, set it in the shell instead: SIMULATE_SENDS=ses npm run dev
42
48
  # Only honored in a dev-shaped env (fake transport, no Access, APP_ORIGIN on localhost).
43
49
  SIMULATE_SENDS="ses"
50
+
51
+ # --- SES account simulation (local only) ---
52
+ # Stands in for the SES account the app reads, so Settings and the dashboard show a state a
53
+ # real account would: sandbox, pending (AWS reviewing a request for production access),
54
+ # denied, production, or permission (the AWS key isn't allowed to read the account). Add
55
+ # ":unrouted" (e.g. "sandbox:unrouted") for bounces that reach no configuration set, which
56
+ # shows the bounces warning, or one problem the account check finds, which shows its warning:
57
+ # ":set_missing" or ":set_paused" (the configuration set), ":key_rejected" (SES rejects the
58
+ # AWS key), ":sending_disabled" (sending turned off), ":shutdown" (AWS paused the account),
59
+ # ":sender_unverified" (the From address isn't verified), or ":probation" (AWS reviewing the
60
+ # account), e.g. "production:set_missing". A problem that stops every email also refuses every
61
+ # simulated email with its cause (set_missing as configuration, key_rejected as credentials, and
62
+ # so on), as the real account would, unless SIMULATE_SENDS names a refusal of its own. In the sandbox, a recipient outside SENDING_DOMAIN
63
+ # and SES's mailbox simulator is refused. Needs SIMULATE_SENDS=ses; "off" leaves the account unread.
64
+ # For one run, set it in the shell instead: SIMULATE_ACCOUNT=sandbox npm run dev
65
+ SIMULATE_ACCOUNT="off"
package/CHANGELOG.md CHANGED
@@ -10,6 +10,76 @@ To move a running instance from one version to another, follow [Upgrade to a new
10
10
 
11
11
  <!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
12
12
 
13
+ ## [1.19.0] - 2026-11-02
14
+
15
+ **Upgrading from 1.18.0.** If your instance sends through Amazon SES, let the app's AWS key send templated email now, whether or not you upgrade: in the IAM console, open **Policies**, then `kestrel-send-email`, choose **Edit**, add `"ses:SendTemplatedEmail"` and `"ses:SendBulkTemplatedEmail"` to its `Action` list in the JSON editor, and save. Keep the actions already there. A key with the guide's earlier policy can't send at all: until you add them, SES refuses every email the app sends as not authorized, test emails included. While you're there, add `"ses:GetConfigurationSet"` too, so the app can tell whether the configuration set exists and can send. Without it the app says nothing about the set, and is never wrong about it. In your instance's repository, run `npm run sync-migrations`, which brings in five database migrations: `0016_held_receipts`, a new table for receipts SES reports before the app has recorded the message they name, which rewrites nothing; `0017_provider_account_rejected`, which rebuilds the one-row table where the app keeps what it reads of your SES account, so it can record a key SES rejects, carrying its row over; `0018_notify_addresses`, which records, for each notification, the addresses it has reached, and rewrites nothing; `0019_sending`, a one-row table recording whether email is getting out, which starts as working when your instance's records show email went out (a post SES took a recipient of, or a subscriber who confirmed through a link it mailed), so a live instance keeps its signups open; and `0020_notifications_not_sending`, which rebuilds the notifications table for the new notice that email isn't going out, carrying every row over. Your saved notifications address carries over as a list of that one address. Run `npm run check-context`: this release changes the operator's Claude context, and a copy you've edited is merged by hand. Apply the migrations before you deploy, with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). Then deploy with `npx wrangler deploy --env production`. Then open **Settings → Email sender**. If it shows **Not proven yet**, as it does when your records show no email went out beyond tests, signups stay closed until SES takes an email: send one test email from **Template → Send test email** to open them. Within about ten minutes the app also checks your SES account, and a red warning there now pauses signups until the check after the fix. A script of yours that read a subscriber's `confirm_token` or `unsub_token` from the API, or read or wrote `notifications.to` in the settings, needs changing (see Breaking); nothing else does.
16
+
17
+ ### Added
18
+
19
+ - On Amazon SES, the account check now finds, without sending anything, the setup mistakes that used to show only when a send, a test, or a reader's signup was refused (SPEC §13): a configuration set that doesn't exist in the region or has sending turned off, an AWS key SES rejects (an unknown key, a secret that doesn't match, or an account not signed up for SES, which used to read as "can't tell"), sending turned off for the account or paused by AWS, and a From address with no verified identity. It also shows when AWS is reviewing the account. Each is a warning in Settings → Email sender and the dashboard, red when no email can go out and amber otherwise, and `GET /api/provider` carries them with a new `error` severity and `unknown_reason` `rejected`. The check reads the sending identity on every check, not only when `SES_CONFIGURATION_SET` is unset. Connect Amazon SES's key policy adds `ses:GetConfigurationSet`, and local development can show each finding with `SIMULATE_ACCOUNT`, such as `production:set_missing`.
20
+ - Notifications can go to up to five addresses, added one at a time under Settings → Notifications as the default test recipients are, so whoever runs an instance for someone else is told of problems alongside them. Each address gets every notification, an address that fails is tried again without resending to the others, and **Send a test notification** goes to each (SPEC §8). While SES is in the sandbox, each address must be verified in SES, or with Cloudflare's email when notifications go through it.
21
+ - Kestrel now records whether email is getting out: not proven yet, working, or refusing when SES refuses the account, found by any email or by the account check (SPEC §13). `GET /api/provider` carries it as `sending` and `signups`, and Claude's `get_publication` carries both, with a new `send_template_test` tool to prove email works.
22
+ - While SES refuses the account, the dashboard shows a red line with SES's words and the fix, Subscribers a red line saying signups are paused, and Settings → Email sender a **Refusing** pill, each ending in sending a test email; before SES has taken any email, Settings shows **Not proven yet** instead. Add subscriber and a test email are refused with SES's cause and the fix. When a reader's signup or the account check finds the refusal, with no one there to see it, the publisher is emailed at most once a day (SPEC §8).
23
+ - A send scheduled while SES is in the sandbox warns that it reaches only verified addresses until AWS grants production access (SPEC §6).
24
+ - Local development can have SES refuse every email with `SIMULATE_SENDS=ses:refuse=<cause>`, and a simulated account problem or sandbox refuses as the real one would (SPEC §10).
25
+
26
+ ### Changed
27
+
28
+ - Settings → Reader pages shows the landing and archive pages as readers get them once signups are open, whatever the instance's state, so a publisher previewing mid-setup sees the Subscribe link and the empty landing page's form. Each page now has its own line, beside the preview and as its row's tooltip, saying how a reader comes to it, in place of one note per journey.
29
+ - The subscribe form takes signups only while email is working, in the SES sandbox as well as with production access, and otherwise says "Signups aren't open yet" or "Signups are paused" to every address alike, with a new `signups_closed` (503) for JSON (SPEC §7). The home page and the archive leave out their form and Subscribe link while signups are closed. A reader is no longer told a link is coming when none can go.
30
+ - The operator's Claude context (`template/claude/CLAUDE.md`) and the MCP server's instructions read an instance's setup as the editor does. They know the ten setup steps and the fact that ticks each, so Claude answers "what's left?" by them. They raise the account check's `error` warnings before its `warn` ones, read an unknown state with `unknown_reason` `rejected` as a key SES rejects rather than a question about production access, and read `review: "denied"` as AWS having replied, so Claude never tells a publisher they were turned down; the sandbox rule also lists `ses:GetConfigurationSet`, and `get_publication` carries each warning's severity and the unknown reason. They offer the subscribe link only while signups are open and SES has production access, and say to send a template test once a refusal is fixed, so Claude never shares a closed form.
31
+ - The dashboard's setup checklist is ten steps in one list, each checked off only from what Claude can read too: Connect Amazon SES, Send a test email (once SES has taken an email), Name your publication, Get notified, Request production access, Bring your existing list, Write your first post, Schedule your first post, Send a test email for your first post (a test of the scheduled email, not of the draft), and Share your subscribe link. Finished steps hide behind one "N done" toggle that shows them in place, a step that can't be taken yet says which step it waits for, and problems stay in the health block, which first run now shows too. A notification that didn't arrive is now a health line too. Check your setup and the Publishing docs' setup list follow the same steps (SPEC §13).
32
+ - An SES bad request that names a configuration set, as a deleted default set causes, now refuses the account's configuration instead of failing one recipient for good.
33
+ - Lock the dashboard with Access says how to let another person into the editor: add them to the Access policy, and store `ACCESS_ALLOWED_EMAILS` again with their address if you set it, since storing it replaces the whole list. It and the Configuration reference show the list's format, and say spaces and capital letters don't matter.
34
+ - While AWS hasn't granted production access and has replied in the support case, Settings and the dashboard say so, with the case number, and to answer there, instead of "AWS denied production access". AWS's account reads the same for the request for more information it sends within seconds of every request as for a refusal. The wire value `review: "denied"` is unchanged, and its reference description reads it the same way.
35
+ - Request production access says AWS's automated request for more information comes every time, and adds a sample email to what to send AWS.
36
+ - SES refusing a request as expired, which a clock running more than 15 minutes off causes, now waits for the retry as SES being unavailable, instead of holding the send as a refusal of the account's credentials (SPEC §12).
37
+ - Connect Amazon SES follows the SES, SNS, and IAM consoles as they are now: it creates the key's policy before its user, sets SNS's longer retry policy on the subscription form, and leaves alone the SES statement a topic created from SES already has. It says why the other six event types stay off, and ends its replies section with a check in Email Routing's Activity log.
38
+ - While Amazon SES is in the sandbox, an empty Subscribers list offers only the import, says to share the subscribe page once AWS grants production access, and links Request production access. A reader who signs up before then gets no confirmation email, since SES mails only addresses verified in it.
39
+ - The confirm page's line under its heading reads "Confirm reader@example.com to start receiving the newsletter.", in the reader's words like the confirmation email, instead of asking whether to start sending to them.
40
+ - The comment `kestrel init` writes above `PROVIDER` in `wrangler.jsonc` stays true after Connect Amazon SES sets it to `ses`. Only new instances get it; an existing instance can edit its comment by hand.
41
+ - Allow Claude to connect's check after turning on Managed OAuth states only the expected answer, and says Access can take a few minutes more to accept sign-ins, showing `invalid_target` until then. Troubleshooting adds the `invalid_target` case, and Connect Claude includes the **Connect** and **Allow** steps after **Add**.
42
+ - `kestrel api` run without `--url` or `KESTREL_URL` names the instance it calls on stderr ("calling …, the only instance you're signed in to"), so a call from another instance's folder no longer goes to that sign-in unseen.
43
+
44
+ ### Fixed
45
+
46
+ - A bounce, complaint, or delivery SES reports in the seconds between taking a batch and the app recording SES's answer now lands on the post's record within a minute, instead of being left off it, so the record no longer reads as delivered for that reader. The address was suppressed either way. One for a recipient left awaiting Resolve lands if you resolve them as sent. Each wait is logged as `held` on `receipt.applied`, and its landing as `receipt.released` (SPEC §10).
47
+ - Connect Amazon SES's IAM policy, `kestrel-send-email`, now includes `ses:SendTemplatedEmail` and `ses:SendBulkTemplatedEmail`. Every SES email has been sent as an inline template since 1.8.0, so a key with only the guide's earlier actions had every email refused, test emails included. The advice for an SES credentials refusal names all four send actions.
48
+ - When renewing a sign-in gets a 404 from its Access team, `kestrel api` says so, naming the instance and the team, and to run `npx kestrel login` again, instead of "Access refused the token request (404)". A real refusal still reads "Access refused the token request", now naming the instance.
49
+
50
+ ### Breaking
51
+
52
+ - A subscriber no longer carries `confirm_token` or `unsub_token` in any API answer, so no API client, Claude included, can confirm a pending address or unsubscribe someone as if the reader had (SPEC I1). Both stay in the database, and the links in mail work as before. Read `status` and `confirm_sent_at` for where a confirmation stands, and add the address again (`POST /api/subscribers`) to send another.
53
+ - `notifications.to` in the settings is a list of addresses, no longer one address: `GET /api/settings` answers `["you@example.com"]` where it answered `"you@example.com"`, and `[]` where it answered `""`, and `PUT /api/settings` takes a list and refuses a string. `POST /api/settings/notifications/test` answers `to` as that list, with `failed` naming any address the channel refused, and is `notify_failed` only when every address was refused.
54
+
55
+ ## [1.18.0] - 2026-10-26
56
+
57
+ **Upgrading from 1.17.0.** In your instance's repository, run `npm run sync-migrations`, which brings in one database migration, `0015_provider_account`: a new table where the app keeps what it reads of your SES account, which rewrites nothing. Apply it before you deploy, with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). If your instance already sends through Amazon SES, let the app's AWS key read the account before you deploy: in the IAM console, open **Policies**, then `kestrel-send-email`, choose **Edit**, add `"ses:GetAccount"` and `"ses:GetEmailIdentity"` to its `Action` list in the JSON editor, and save. Otherwise the app's first check finds the key not allowed, Settings says so with **Add the permission**, and the app asks again only hourly. An instance not yet connected to SES skips this, since Connect Amazon SES's policy now includes both. Then deploy with `npx wrangler deploy --env production`. `SES_MAX_SEND_RATE` is now a cap: a send keeps to the lower of it and your account's own rate, so the old guide's `"1"` would hold every send at one a second even after AWS grants production access. Once Settings → Email sender shows a **Sandbox** or **Production** badge beside Amazon SES, the app has read your account: remove `SES_MAX_SEND_RATE` from the `production` block's `vars` in `wrangler.jsonc`, or raise it to the most you want, and deploy again. Run `npm run check-context`: this release changes the operator's Claude context, and a copy you've edited is merged by hand. An existing instance keeps its Worker, database, and bucket names: don't rename them, and don't copy a new instance's names from the template into your `wrangler.jsonc`. If you're partway through the setup guide, continue from its renumbered pages: 1.17.0's Connect Amazon SES section 4 is now its own page, Request production access, after Connect Amazon SES.
58
+
59
+ ### Added
60
+
61
+ - On Amazon SES, the app reads the account itself, about every ten minutes while nothing is sending (SPEC §9). Settings → Email sender shows whether SES is still in the sandbox, where AWS's review of a production-access request stands, and the send rate and where it comes from, and says when the app's AWS key isn't allowed to read the account; the dashboard's setup steps add Request production access, checked off once AWS grants it; and a warning says when no configuration set applies, so bounces and complaints would never reach the app. `GET /api/provider` carries all of it for Claude, and an AWS key that can't read the account reads as unknown, never as an error.
62
+ - `kestrel init` takes `--name` to name a new instance, which otherwise takes its folder's name: lowercase letters, digits, and hyphens, at most 46 characters. It names the instance's Worker, D1 database, and R2 bucket after it instead of `kestrel`: `NAME-production` for the Worker and the database, `NAME-media-production` for the bucket, and `NAME-dev` for the development Worker a deploy without `--env production` would create. So a second instance in the same Cloudflare account no longer deploys over the first. Deploy the app now recommends naming the repository after the publication. An existing instance keeps its names, and nothing changes for it.
63
+ - Local development can stand in for the SES account with `SIMULATE_ACCOUNT` (`sandbox`, `pending`, `denied`, `production`, or `permission`, optionally `:unrouted`, beside `SIMULATE_SENDS=ses`), so Settings and the dashboard can be seen in each state without an AWS account. `kestrel dev` takes it from the shell for one run.
64
+ - Get started has a new step, Create an AWS account, before Connect Amazon SES. It covers the sign-up path, plan, and support choices, securing the root user, a budget alert, and what running several publications through one account shares. Deploy the app's Before you begin says to start it early.
65
+
66
+ ### Changed
67
+
68
+ - Until a post is listed, the home page is the subscribe form itself, with its line saying each signup is confirmed by email. A first visitor, or AWS reviewing the site for SES production access, sees how joining works without a click.
69
+ - Before any post is listed and while no email provider is connected, the home page reads "No posts yet. Subscribe to get the first one.", as two sentences without a dash.
70
+ - Name your publication now suggests a tagline that says what the newsletter covers and how often it goes out.
71
+ - A send keeps to your SES account's own maximum send rate once the app has read it, or to `SES_MAX_SEND_RATE` where that is lower, so a raise from AWS needs no deploy (SPEC §12). The setup guide no longer sets the rate to 1 in the sandbox and changes it after the grant, and its IAM policy adds `ses:GetAccount` and `ses:GetEmailIdentity`.
72
+ - The setup guide asks AWS for SES production access on a page of its own, Request production access, after Connect Amazon SES, so AWS's reviewer finds a live site with SES connected and bounces handled. It ends by checking the grant, in Settings or from `GET /api/provider`, and Go live opens by pointing there. Verify it works now runs wholly in the sandbox, while AWS reviews the request.
73
+ - Connect Amazon SES sets the custom MAIL FROM domain on the Create identity form, so all five DNS records go to Cloudflare in one trip, with a zone file to import. It verifies your two test addresses in its own section, and checks each piece in the section that sets it up.
74
+ - The setup guide's pages from Connect Amazon SES on, and the sections of Connect Amazon SES and Verify it works, are renumbered. A bookmark to a section now lands at its page's top, and a GitHub link to a renamed page, such as `docs/get-started/04-ses.md` or `05-verify.md`, finds no page. The editor's Docs links keep working.
75
+ - The operator's Claude context reads whether SES is in the sandbox from `GET /api/provider` before helping share the subscribe link, instead of asking the publisher, and names the two permissions to add when the app's AWS key can't read the account. For production access it points to Request production access, instead of a Go live section that doesn't exist. The connector's instructions read the same state. Merge it into an edited copy by hand.
76
+ - The operator's Claude context names the Worker a deploy without `--env production` creates by the top-level `name` in `wrangler.jsonc`, not as `kestrel`, since a new instance's is its own name. Merge it into an edited copy by hand.
77
+ - The operator's Claude context, following a setup guide page, runs the check that ends each section, and the page's **Check it** list where it has one, since Connect Amazon SES and Request production access have none. Merge it into an edited copy by hand.
78
+
79
+ ### Fixed
80
+
81
+ - A locked editor (before Access is set up, or after a sign-in Kestrel refuses) no longer flashes the editor's sidebar before its explanation: the dashboard shows the publication's chrome only once it knows who is signing in.
82
+
13
83
  ## [1.17.0] - 2026-10-26
14
84
 
15
85
  ### Changed
@@ -661,7 +731,9 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
661
731
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
662
732
  - This changelog and a semantic-versioning release process.
663
733
 
664
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.17.0...HEAD
734
+ [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.19.0...HEAD
735
+ [1.19.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.19.0
736
+ [1.18.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.18.0
665
737
  [1.17.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.17.0
666
738
  [1.16.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.16.0
667
739
  [1.15.1]: https://github.com/kurtbruns/kestrel/releases/tag/v1.15.1
package/bin/kestrel.mjs CHANGED
@@ -26,7 +26,7 @@ const HELP = `kestrel ${PACKAGE.version}
26
26
 
27
27
  Usage: kestrel <command> [options]
28
28
 
29
- init [dir] write a new instance repository here (or in dir)
29
+ init [dir] [--name <name>] write a new instance repository here (or in dir)
30
30
  dev [wrangler dev args] run locally, with the send sweep once a minute (PORT picks the port)
31
31
  sync-migrations copy this release's migrations into migrations/
32
32
  check-context [--update] compare the Claude files in .claude/ with this release's
package/cli/dev.mjs CHANGED
@@ -22,8 +22,8 @@
22
22
  * D1 route would 500 until the migrations ran. The local database is probed and, when its
23
23
  * tables are missing, migrated before the server starts. Never for `--remote`.
24
24
  *
25
- * Per-run settings. `SIMULATE_SENDS`, `MIN_LEAD_SECONDS`, and `SUBREQUEST_BUDGET` set in the
26
- * shell are forwarded over `.dev.vars`, so `SIMULATE_SENDS=ses kestrel dev` picks a
25
+ * Per-run settings. `SIMULATE_SENDS`, `SIMULATE_ACCOUNT`, `MIN_LEAD_SECONDS`, and
26
+ * `SUBREQUEST_BUDGET` set in the shell are forwarded over `.dev.vars`, so `SIMULATE_SENDS=ses kestrel dev` picks a
27
27
  * simulation profile for one run without editing a file. A `.dev.vars` missing a setting that
28
28
  * `.dev.vars.example` gives a value is named, rather than left to differ quietly. What
29
29
  * `SIMULATE_SENDS` makes of this run (a simulated provider, or not connected) is said in one
@@ -49,7 +49,7 @@ import { captureWrangler, runWrangler, spawnWrangler } from "./wrangler.mjs";
49
49
  const PREFERRED_PORT = 8787;
50
50
 
51
51
  // The dev settings a shell may set for one run (see the header).
52
- const OVERRIDABLE = ["SIMULATE_SENDS", "MIN_LEAD_SECONDS", "SUBREQUEST_BUDGET"];
52
+ const OVERRIDABLE = ["SIMULATE_SENDS", "SIMULATE_ACCOUNT", "MIN_LEAD_SECONDS", "SUBREQUEST_BUDGET"];
53
53
 
54
54
  // Whether a TCP port can be bound on loopback right now. A momentary check, so what it
55
55
  // reports can go stale before wrangler binds: the same check-then-bind race autoPort lives with.
package/cli/init.mjs CHANGED
@@ -8,6 +8,11 @@
8
8
  * wrangler config and one-line Worker entry, `.dev.vars.example`, the Claude context with its
9
9
  * headers (cli/context.mjs), and this release's migrations.
10
10
  *
11
+ * The scaffold's wrangler config names the Worker, the D1 database, and the R2 bucket
12
+ * `kestrel…`; init renames each after the instance (the folder's name, or `--name`), so a
13
+ * second instance in the same Cloudflare account never deploys over the first or collides
14
+ * with its database and bucket. An instance already written keeps the names it has.
15
+ *
11
16
  * A few files ship under other names and are renamed here: npm leaves `.gitignore` and
12
17
  * `.npmrc` out of a published package, and the Claude files sit outside `.claude/` so they
13
18
  * don't load into sessions in Kestrel's own repository. Init never overwrites: if any file it
@@ -62,9 +67,92 @@ function packageName(dir) {
62
67
  return name || "newsletter";
63
68
  }
64
69
 
65
- /** `kestrel init [dir]`. */
70
+ // The longest name init derives is `<name>-media-production`, and an R2 bucket's name, like a
71
+ // Worker's on workers.dev, is at most 63 characters. Each resource takes lowercase letters,
72
+ // digits, and hyphens, never a hyphen at either end: the rules R2 and workers.dev share, and
73
+ // D1 accepts. Every name init makes ends in a suffix (`-dev`, `-production`), so no instance's
74
+ // name can make another's: the development Worker's `NAME-dev` never equals a production
75
+ // `OTHER-production`. `kestrel` alone is refused, being what instances made by earlier
76
+ // releases are named.
77
+ const NAME_MAX = 63 - "-media-production".length;
78
+ const NAME_RULE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
79
+
80
+ /** Why `name` can't name an instance's resources, or null when it can. */
81
+ export function nameRefusal(name) {
82
+ if (name.length > NAME_MAX) {
83
+ return `"${name}" is ${name.length} characters; an instance's name is at most ${NAME_MAX}`;
84
+ }
85
+ if (!NAME_RULE.test(name)) {
86
+ return `"${name}" isn't a valid instance name: use lowercase letters, digits, and hyphens, starting and ending with a letter or digit`;
87
+ }
88
+ if (name === "kestrel") {
89
+ return `"kestrel" names the instances made by earlier releases, so a new one takes a name of its own`;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * The instance name a folder's name makes: accents dropped (`Café` is `cafe`), each run of
96
+ * other characters a hyphen, or `newsletter` when nothing is left.
97
+ */
98
+ export function nameFromFolder(dir) {
99
+ const name = basename(dir)
100
+ .normalize("NFKD")
101
+ .replace(/\p{M}/gu, "")
102
+ .toLowerCase()
103
+ .replace(/[^a-z0-9]+/g, "-")
104
+ .replace(/^-+/, "")
105
+ .slice(0, NAME_MAX)
106
+ .replace(/-+$/, "");
107
+ return name || "newsletter";
108
+ }
109
+
110
+ // The scaffold's resource names: `kestrel`, then a suffix such as `-media-production`.
111
+ const RESOURCE_NAME = /("(?:name|database_name|bucket_name)":\s*")kestrel((?:-[a-z]+)*")/g;
112
+ const RESOURCE_COUNT = 6;
113
+
114
+ /** The scaffold's wrangler config with its Worker, database, and bucket named after `name`. */
115
+ export function nameResources(config, name) {
116
+ let count = 0;
117
+ const named = config.replace(RESOURCE_NAME, (_, key, suffix) => {
118
+ count++;
119
+ return `${key}${name}${suffix}`;
120
+ });
121
+ // A scaffold whose names this no longer finds would write an instance named `kestrel`.
122
+ if (count !== RESOURCE_COUNT) {
123
+ throw new Error(`template/wrangler.jsonc names ${count} resources, not ${RESOURCE_COUNT}`);
124
+ }
125
+ return named;
126
+ }
127
+
128
+ /** The instance directory and the `--name` flag's value, if given, from init's arguments. */
129
+ export function parseInitArgs(argv) {
130
+ let dir;
131
+ let name;
132
+ for (let i = 0; i < argv.length; i++) {
133
+ const arg = argv[i];
134
+ if (arg === "--name") {
135
+ name = argv[++i] ?? "";
136
+ } else if (arg.startsWith("--name=")) {
137
+ name = arg.slice("--name=".length);
138
+ } else if (!arg.startsWith("-") && dir === undefined) {
139
+ dir = arg;
140
+ }
141
+ }
142
+ return { dir: dir ?? ".", name };
143
+ }
144
+
145
+ /** `kestrel init [dir] [--name <name>]`. */
66
146
  export function initCommand(argv) {
67
- const root = resolve(argv.find((a) => !a.startsWith("-")) ?? ".");
147
+ const args = parseInitArgs(argv);
148
+ const root = resolve(args.dir);
149
+ const name = args.name ?? nameFromFolder(root);
150
+ const refusal = nameRefusal(name);
151
+ if (refusal) {
152
+ const hint = args.name === undefined ? " Name it with --name, or rename the folder." : "";
153
+ console.error(`[init] refused: ${refusal}.${hint}`);
154
+ process.exit(1);
155
+ }
68
156
  const files = [
69
157
  ...scaffoldFiles(),
70
158
  [join(PACKAGE_ROOT, ".dev.vars.example"), ".dev.vars.example"],
@@ -93,12 +181,14 @@ export function initCommand(argv) {
93
181
  // nodejs_compat) and stops ending the typecheck with "Action required"; the scaffold's
94
182
  // tsconfig lists no `types`, so they never enter the program and the Worker's types win.
95
183
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
96
- pkg.name = packageName(root);
184
+ pkg.name = args.name ?? packageName(root);
97
185
  pkg.dependencies[PACKAGE.name] = PACKAGE.version;
98
186
  pkg.devDependencies["@types/node"] = PACKAGE.devDependencies["@types/node"];
99
187
  pkg.devDependencies.typescript = PACKAGE.devDependencies.typescript;
100
188
  pkg.devDependencies.wrangler = PACKAGE.peerDependencies.wrangler;
101
189
  writeFileSync(join(root, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
190
+ const config = join(root, "wrangler.jsonc");
191
+ writeFileSync(config, nameResources(readFileSync(config, "utf8"), name));
102
192
  for (const file of CONTEXT_FILES) {
103
193
  writeShipped(root, file);
104
194
  }
@@ -107,7 +197,11 @@ export function initCommand(argv) {
107
197
  // A path from here when the instance is below this directory, else the absolute one.
108
198
  const rel = relative(process.cwd(), root);
109
199
  const where = rel.startsWith("..") ? root : rel;
110
- console.log(`[init] a Kestrel ${PACKAGE.version} instance${where ? ` in ${where}` : ""}. Next:`);
200
+ console.log(`[init] a Kestrel ${PACKAGE.version} instance${where ? ` in ${where}` : ""}.`);
201
+ console.log(
202
+ `[init] named ${name}${args.name === undefined ? " after its folder (--name picks another)" : ""}: ` +
203
+ `Worker and database ${name}-production, image bucket ${name}-media-production. Next:`,
204
+ );
111
205
  console.log(
112
206
  [
113
207
  ...(where ? [` cd ${where}`] : []),