@kurtbruns/kestrel 1.5.0 → 1.6.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 +53 -1
- package/cli/context.mjs +20 -11
- package/cli/dev-api.mjs +73 -9
- package/cli/dev-server.mjs +72 -0
- package/cli/dev.mjs +39 -11
- package/cli/init.mjs +8 -2
- package/cli/reset.mjs +2 -3
- package/cli/seed.mjs +7 -5
- package/cli/sync-migrations.mjs +5 -1
- package/dist/public/dashboard/app.js +362 -324
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +600 -126
- package/dist/worker/index.js +1111 -802
- package/docs/get-started/02-deploy.md +1 -1
- package/docs/get-started/05-verify.md +1 -1
- package/docs/reference/01-configuration.md +1 -1
- package/migrations/0003_post_slug_auto.sql +14 -0
- package/package.json +4 -5
- package/template/claude/CLAUDE.md +2 -2
- package/template/package.json +5 -0
- package/cli/dev-port.mjs +0 -57
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,57 @@ 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.6.0] - 2026-10-11
|
|
14
|
+
|
|
15
|
+
**Upgrading from 1.5.0.** One database migration: run `npm run sync-migrations`, then `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development) before deploying. Existing posts keep their slugs and read as set by hand; tick *Auto-generate from subject* on a draft to have its slug follow the subject again. On npm 12, to end `npm install` without the blocked-install-scripts warning, add this to your `package.json` (a new instance has it already): `"allowScripts": { "esbuild": true, "fsevents": true, "workerd": true }`.
|
|
16
|
+
|
|
17
|
+
### Breaking
|
|
18
|
+
|
|
19
|
+
- Cancel, reschedule, and Resolve no longer take a precondition: an `If-Match` header on them, and Resolve's `expected_count`, are now ignored, and the `precondition_failed` and `count_changed` refusals are gone. Each act applies to the send as it stands and answers with the result, so a retry after a lost answer is answered `changed: false` instead of a 412, and two acts made moments apart both land, each shown at once and reversible before the fire time (SPEC §1, §8).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Settings has a **Reader pages** section that shows every page a reader can land on, including the expired and invalid links a reader rarely meets and the landing page and archive as visitors find them before your first post: an outline lists each journey (subscribe, confirm, unsubscribe, reading) with its pages, and the one you pick shows beside it at desktop or phone width. Each is the real page rendered with your publication's identity and sample values, so you can see what readers see without walking the flows; nothing on a preview acts (SPEC §5).
|
|
24
|
+
- The editor shows a post's **web version**: the post as its public page will look, with the template's email-only parts left out and the masthead and display font added, so you can proof the page before it's permanent. **Web version ↗** in the editor opens it in a new tab, under a strip above the page that says where the post stands and where its public page will be, and replaces **Open in browser**, which showed the same email as the Preview tab. The email's own "View in browser" link in the Preview tab opens the web version too, rather than a public page that doesn't exist yet. Once a post's send starts, the web version opens its public page (SPEC §5).
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- A logo you upload in Settings is now cropped to its center square and saved as a 256 × 256 image, so it looks the same everywhere and is never stretched in an email. The tile shows the result, the confirmation says when the edges were cropped, and Replace picks another. A logo you uploaded before that isn't square is flagged in Settings, since emails show it stretched; upload it again to have it cropped.
|
|
29
|
+
- Settings orders its sections by whom they serve, editable first: your publication's identity; then, in the order a reader meets them, the ways to subscribe, the confirmation email, and the reader pages; then the email template and your test recipients; then notifications; and last, read-only, what only a redeploy changes (the email sender and the instance). The embeddable form's Styled | Plain choice is now the same toggle as the page's other two-way choices.
|
|
30
|
+
- The subscribe page and the Styled embed tell a reader what happens next in plain words, "We'll email you a link to confirm. Unsubscribe anytime.", instead of "Double opt-in".
|
|
31
|
+
- Settings' **Ways to subscribe** shows its two ways as matching parts of one card, the subscribe page first and the embeddable form second, each with a line on what it is for. The page's address reads as a value with Copy and Open beside it, and the embeddable form shows beside its snippet, both in view at once, with the Styled | Plain choice named only on its toggle and a **Copy HTML** button at the end, where the form's one act belongs. The **Styled** snippet, now offered first, looks like your hosted subscribe page: a Georgia serif headline over a sans body, in the reader pages' ink colors. A form already pasted into a site keeps the look it was copied with; copy it again to take the new one.
|
|
32
|
+
- Copying in the editor says it worked on the Copy itself, its glyph turning to a check for a moment, instead of a "Copied" toast: the subscribe page's address and the embeddable form in Settings, the addresses and setup step on the dashboard, and the whole template on the Template page.
|
|
33
|
+
- A post now records whether its slug follows the subject or was set by hand (`slug_auto` on the post and on a save), which the editor's *Auto-generate from subject* box now shows rather than guesses. A slug that follows is derived and numbered afresh on every draft save, through the API as well as the editor, so a script that creates posts should take the slug from each save's answer rather than keep the one it was created with. A draft numbered because another post held its slug (`spring-update-2`) drops the number once that post is deleted, and a slug set by hand that looks numbered, like `spring-update-2027`, is no longer mistaken for one that follows and replaced by a subject edit (SPEC §4).
|
|
34
|
+
- A post's page, the archive index, and the landing page show a post as soon as its send starts, dated by when it went out, so a reader who opens "View in browser" while a large send is still going out finds the post instead of "Not found". A send's view links the published post from the start too (SPEC §5).
|
|
35
|
+
- The confirm, unsubscribe, and post-not-found pages now wear your publication's reader look (its name, logo, and serif headline), like the subscribe page, instead of a plain grey card, so a reader arriving from an email sees your newsletter, and the pages that ask a reader to confirm or to unsubscribe name it in their headline when it has a name ("Confirm your subscription to …", "Unsubscribe from …?"). They carry no subscribe call to action in the masthead, and they are never cached or indexed, since some show the reader's address (SPEC §5).
|
|
36
|
+
- A test email's links now land on pages that say what they are. Its "View in browser" link, before the send starts, opens "Nothing here yet" (the same page any archive address without a published post shows, so a scheduled post is never revealed) instead of "Not found", and its unsubscribe link, like the one in the editor's preview, opens the page a subscriber sees instead of "This link is invalid": marked as a test, with a sample address, and walking through it changes no one's subscription (SPEC §5, §7).
|
|
37
|
+
- The operator's Claude context no longer tells Claude to send `If-Match` when it acts on a send, or to handle `precondition_failed`, since neither applies any more. It now says an act is safe to repeat and that every answer carries the send as it now stands. `npm run check-context` reports the change; if you edited your `.claude/CLAUDE.md`, replace that line by hand.
|
|
38
|
+
- `kestrel seed` and `kestrel reset` (`npm run seed`, `npm run reset`) with no port given now act only on the dev server `kestrel dev` started in the same folder, and refuse otherwise: when none is running there, or when the server on its port isn't that one (started some other way, or another folder's). Before, they fell back to port 8787 and could wipe another local server's database. `PORT` no longer aims them, since a shell can carry it unawares; to act on another server on purpose, name its port as an argument: `npm run seed -- 8787`.
|
|
39
|
+
- The operator's Claude context now says that seed and reset act only on the dev server started in the instance's folder, and tells Claude to start that server when they refuse rather than name another server's port, which the person must mean. It bears on safety: both wipe the database they reach. `npm run check-context` reports the change; if you edited your `.claude/CLAUDE.md`, add the sentence by hand.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- An email you've sent, and its archive page, now keep the logo it went out with. Before, uploading a new logo changed it in every past email and archive page beside their original name and tagline, and removing the logo left a broken image in all of them. Each upload is now kept, and a new one or a removal changes only what you send from then on. A logo uploaded before this release stays where its past emails point, and from now on is never replaced (SPEC §9).
|
|
44
|
+
- A publication with no name, and no display name on its sending address, is no longer called "Newsletter" on its reader pages while its email carries no name. Every surface now reads the name the same way and, with none, shows none: the pages say "Subscribe", "Confirm your subscription", and "Unsubscribe?", their tab titles drop the name, the masthead shows only what you've set (or a link home reading "Archive"), and the subscribe embed in Settings reads "Subscribe" (SPEC §9).
|
|
45
|
+
- Toasts no longer pile up in a column up the middle of the screen. At most three show at once, a repeated message (clicking Copy five times, or a save failing again while you type) restarts the one already showing instead of adding another, and one leaving lets the rest slide into place. A longer toast on a phone now uses the screen's width instead of wrapping into a narrow column.
|
|
46
|
+
- A new instance's `npm install` on npm 12 no longer ends with a warning that install scripts were blocked for esbuild and workerd (and fsevents on a Mac). The instance's `package.json` now allows them by name, so their scripts run as wrangler expects and the entry doesn't go stale when wrangler moves to a new version.
|
|
47
|
+
- Two send-now requests for the same post that arrive at the same moment (from Claude and the editor, say) now both answer with the one send they made. Before, the one that lost the race was refused with "post already has an active send", though the send was scheduled and no one was mailed twice; a later repeat already answered this way (SPEC §6).
|
|
48
|
+
- A draft deleted elsewhere (in another tab, or by Claude) now says so once and goes to Drafts, instead of failing each save with "Couldn't save your changes". Opening a post that is no longer there goes to Drafts and says so, instead of an error page whose Retry can only fail again. Canceling a scheduled post that was deleted elsewhere says the same, and a Cancel that lands after the send time now says plainly that it's too late and the email is going out, instead of showing the server's refusal (SPEC §4).
|
|
49
|
+
- A link in a notification email now opens the send it is about even when you have to sign in first. Before, the sign-in dropped the page from the link and you landed on the dashboard home (SPEC §8).
|
|
50
|
+
- Leaving a draft while a save was still going (just after leaving the subject or slug field, say) no longer warns "Changed elsewhere — your edits weren't saved" about your own save, and an edit made while that save was going is no longer lost. The editor now waits for the save to land and saves what is left on top of it.
|
|
51
|
+
- A link in a post's Preview tab, or in the sample email on the Template page and in Settings, now opens in a new tab. Before, it loaded in the preview's frame, which went blank when the page refused to be framed, as most do, and the preview was lost until you switched tabs.
|
|
52
|
+
- The sample email on the Template page and in Settings no longer runs script a template carries. A template with an inline handler (an image's `onerror`, say) could act in the editor with your session; the sample now shows the template the way the Preview tab shows a post, with nothing in it able to run (SPEC §5).
|
|
53
|
+
- On a draft's Preview tab, "Paste, drop, or click to add images" now dims and ignores clicks, like the formatting toolbar. Before, a click there added the image to the hidden Edit text and left the preview unchanged. Dropping an image still works and switches to Edit.
|
|
54
|
+
- A send wedged on recipients whose delivery is unknown now keeps its count and its Resolve control until you resolve it. A delivery receipt that named the address but not the message could settle those recipients in its place, so the count you saw shrank, and once the last one was reached the Resolve control vanished and the send sat stuck with nothing in the editor or the API to finish it (SPEC §12).
|
|
55
|
+
- The editor's slug field shows only the slug the post actually has. It used to preview a slug from the subject while you typed, and tabbing through the slug field into the body could leave that preview showing a slug another post already held until the next save. The field now updates when the subject is left, with the slug the server gave, numbered if it was taken. Enter in the subject or slug field saves, as leaving it does, and so does ticking the box that has the slug follow the subject. A slug you're typing by hand is never rewritten under the cursor: what it was saved as shows once you leave the field.
|
|
56
|
+
- On a checkout with Windows line endings, `npm run sync-migrations` no longer refuses every migration as edited, and `npm run check-context` no longer reports the Claude Code files as having no header.
|
|
57
|
+
- `kestrel init` on Windows no longer leaves an extra `claude` folder beside `.claude`, and `npm run check-context -- --merged` accepts a Windows path.
|
|
58
|
+
- `npm run check-context` now prints commands that run as written (`npm run check-context -- --update`, `-- --merged`), where it printed a bare `kestrel` that isn't on the path.
|
|
59
|
+
- `kestrel init` refuses before writing anything when a migration of the release's is already there, where it wrote the other files first.
|
|
60
|
+
- When the web version, a post's Preview tab, or a send's CSV export can't be loaded, the editor now says why, such as "post has no content yet". Before, a deployed instance, served over HTTP/2, showed an empty error box or toast, and a local one only "Not Found" or "Bad Request". A web version address with no post says "There's no post here. It may have been deleted.", and a preview whose post was deleted elsewhere says so. An error page that isn't the API's own, such as a proxy's, now names its HTTP status anywhere in the editor, where it could show a JSON parse error.
|
|
61
|
+
- The Worker no longer writes "using deprecated parameters for the initialization function" to its logs the first time it renders an email after starting, a plain-text line among the structured ones.
|
|
62
|
+
- The Worker no longer writes "Called .text() on an HTTP body which does not appear to be text" to its logs when a request to the API sends a body that isn't JSON (a form post, say); the request is still refused the same way.
|
|
63
|
+
|
|
13
64
|
## [1.5.0] - 2026-10-09
|
|
14
65
|
|
|
15
66
|
**Upgrading from 1.4.0.** No migration. Any script or API client that saves posts must send the revision it read, as `base_revision` or `If-Match`; the editor already does.
|
|
@@ -236,7 +287,8 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
|
|
|
236
287
|
- A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
|
|
237
288
|
- This changelog and a semantic-versioning release process.
|
|
238
289
|
|
|
239
|
-
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.
|
|
290
|
+
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.6.0...HEAD
|
|
291
|
+
[1.6.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.6.0
|
|
240
292
|
[1.5.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.5.0
|
|
241
293
|
[1.4.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.4.0
|
|
242
294
|
[1.3.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.3.0
|
package/cli/context.mjs
CHANGED
|
@@ -41,8 +41,15 @@ export function shippedPath(file) {
|
|
|
41
41
|
// The header line, as a Markdown comment or, inside a skill's front matter, a YAML one.
|
|
42
42
|
const HEADER = /^.*@kurtbruns\/kestrel (\S+), sha256:([0-9a-f]{64}).*\n/m;
|
|
43
43
|
|
|
44
|
+
// Files are compared and hashed with LF line endings, so a checkout that turns them into CRLF
|
|
45
|
+
// (git's autocrlf on Windows) reads as the same file, not as an edit.
|
|
46
|
+
const lf = (text) => text.replace(/\r\n/g, "\n");
|
|
47
|
+
const read = (path) => lf(readFileSync(path, "utf8"));
|
|
44
48
|
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
45
49
|
|
|
50
|
+
// How an operator runs this command: `kestrel` is on the PATH only inside an npm script.
|
|
51
|
+
const RUN = "npm run check-context --";
|
|
52
|
+
|
|
46
53
|
/** A context file's header (`version`, `hash`, or null when it has none) and its body. */
|
|
47
54
|
function parse(text) {
|
|
48
55
|
const m = HEADER.exec(text);
|
|
@@ -72,17 +79,17 @@ export function stamp(body, hash = sha256(body)) {
|
|
|
72
79
|
export function writeShipped(root, file) {
|
|
73
80
|
const to = join(root, file);
|
|
74
81
|
mkdirSync(dirname(to), { recursive: true });
|
|
75
|
-
writeFileSync(to, stamp(
|
|
82
|
+
writeFileSync(to, stamp(read(shippedPath(file))));
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
/** Where a context file stands against the installed release. */
|
|
79
86
|
function status(root, file) {
|
|
80
87
|
const path = join(root, file);
|
|
81
|
-
const release =
|
|
88
|
+
const release = read(shippedPath(file));
|
|
82
89
|
if (!existsSync(path)) {
|
|
83
90
|
return { state: "missing" };
|
|
84
91
|
}
|
|
85
|
-
const { header, body } = parse(
|
|
92
|
+
const { header, body } = parse(read(path));
|
|
86
93
|
const releaseHash = sha256(release);
|
|
87
94
|
if (sha256(body) === releaseHash) {
|
|
88
95
|
return { state: "current", header, restamp: header?.hash !== releaseHash };
|
|
@@ -104,8 +111,8 @@ function diff(root, file) {
|
|
|
104
111
|
const dir = mkdtempSync(join(tmpdir(), "kestrel-context-"));
|
|
105
112
|
const name = basename(file);
|
|
106
113
|
for (const [side, text] of [
|
|
107
|
-
["yours", parse(
|
|
108
|
-
["release",
|
|
114
|
+
["yours", parse(read(join(root, file))).body],
|
|
115
|
+
["release", read(shippedPath(file))],
|
|
109
116
|
]) {
|
|
110
117
|
mkdirSync(join(dir, side));
|
|
111
118
|
writeFileSync(join(dir, side, name), text);
|
|
@@ -129,7 +136,7 @@ function diff(root, file) {
|
|
|
129
136
|
export function checkContextCommand(argv, root = process.cwd()) {
|
|
130
137
|
const mergedAt = argv.indexOf("--merged");
|
|
131
138
|
if (mergedAt !== -1) {
|
|
132
|
-
const file = relative(root, resolve(root, argv[mergedAt + 1] ?? ""));
|
|
139
|
+
const file = relative(root, resolve(root, argv[mergedAt + 1] ?? "")).replaceAll("\\", "/");
|
|
133
140
|
if (!CONTEXT_FILES.includes(file) || !existsSync(join(root, file))) {
|
|
134
141
|
console.error(
|
|
135
142
|
`[check-context] --merged takes one of: ${CONTEXT_FILES.join(", ")} (and it must exist).`,
|
|
@@ -137,8 +144,8 @@ export function checkContextCommand(argv, root = process.cwd()) {
|
|
|
137
144
|
process.exit(1);
|
|
138
145
|
}
|
|
139
146
|
// The body stays the operator's; the header now says it has this release's changes.
|
|
140
|
-
const { body } = parse(
|
|
141
|
-
const release =
|
|
147
|
+
const { body } = parse(read(join(root, file)));
|
|
148
|
+
const release = read(shippedPath(file));
|
|
142
149
|
writeFileSync(join(root, file), stamp(body, sha256(release)));
|
|
143
150
|
console.log(`[check-context] ${file}: recorded as merged with ${PACKAGE.version}`);
|
|
144
151
|
return;
|
|
@@ -162,7 +169,9 @@ export function checkContextCommand(argv, root = process.cwd()) {
|
|
|
162
169
|
console.log(`[check-context] ${file}: restored from ${PACKAGE.version}`);
|
|
163
170
|
} else {
|
|
164
171
|
pending++;
|
|
165
|
-
console.log(
|
|
172
|
+
console.log(
|
|
173
|
+
`[check-context] ${file}: missing; \`${RUN} --update\` writes ${PACKAGE.version}'s`,
|
|
174
|
+
);
|
|
166
175
|
}
|
|
167
176
|
break;
|
|
168
177
|
case "outdated":
|
|
@@ -172,7 +181,7 @@ export function checkContextCommand(argv, root = process.cwd()) {
|
|
|
172
181
|
} else {
|
|
173
182
|
pending++;
|
|
174
183
|
console.log(
|
|
175
|
-
`[check-context] ${file}: Kestrel ${PACKAGE.version} changed it${had}, and you haven't edited it; --update takes the new one`,
|
|
184
|
+
`[check-context] ${file}: Kestrel ${PACKAGE.version} changed it${had}, and you haven't edited it; \`${RUN} --update\` takes the new one`,
|
|
176
185
|
);
|
|
177
186
|
}
|
|
178
187
|
break;
|
|
@@ -191,7 +200,7 @@ export function checkContextCommand(argv, root = process.cwd()) {
|
|
|
191
200
|
: ` From yours to ${PACKAGE.version}'s, header aside (lines starting + are the release's):\n${d}`,
|
|
192
201
|
);
|
|
193
202
|
console.log(
|
|
194
|
-
` Merge what you want of it into yours, then record that:
|
|
203
|
+
` Merge what you want of it into yours, then record that: ${RUN} --merged ${file}`,
|
|
195
204
|
);
|
|
196
205
|
break;
|
|
197
206
|
}
|
package/cli/dev-api.mjs
CHANGED
|
@@ -7,8 +7,15 @@
|
|
|
7
7
|
* they do is what the editor or Claude could do, and it lands in the database the server
|
|
8
8
|
* is actually serving. The token comes from the dev-only `/api/dev/token` route, which
|
|
9
9
|
* exists only on a local dev server (fake transport, no Access, a loopback origin).
|
|
10
|
+
*
|
|
11
|
+
* Seed and reset wipe the database of the server they reach, so a target they find for
|
|
12
|
+
* themselves must prove it is this directory's: the port `kestrel dev` recorded here, and a
|
|
13
|
+
* server there that reports the run ID recorded with it (cli/dev-server.mjs). A target the
|
|
14
|
+
* caller names, a port or URL argument, is their choice and is used as given. `PORT` names
|
|
15
|
+
* nothing here: a shell can carry it without the caller meaning anything by it, and a
|
|
16
|
+
* target that wipes a database has to be named on purpose.
|
|
10
17
|
*/
|
|
11
|
-
import {
|
|
18
|
+
import { readDevServer } from "./dev-server.mjs";
|
|
12
19
|
|
|
13
20
|
/** Split argv into `--flag value` / `--flag=value` pairs, bare `--flag`s (true), and
|
|
14
21
|
* positionals, so a script's flags can be given in any order. */
|
|
@@ -35,13 +42,68 @@ export function parseArgs(argv, booleans = []) {
|
|
|
35
42
|
return { flags, positional };
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Where a dev command aims, decided from what it was given and this directory's record:
|
|
47
|
+
* `{ base, explicit: true }` for a port or URL argument, used as given;
|
|
48
|
+
* `{ base, expectId }` for the recorded server, which must then report that ID
|
|
49
|
+
* (`ownsServer`); or `{ refuse: "unrecorded" }` when nothing names a server and none is
|
|
50
|
+
* recorded here. There is no fallback port: a guess could be another directory's server.
|
|
51
|
+
*/
|
|
52
|
+
export function chooseTarget({ arg, record }) {
|
|
41
53
|
if (arg) {
|
|
42
|
-
return /^https?:\/\//.test(arg) ? arg : `http://localhost:${arg}
|
|
54
|
+
return { base: /^https?:\/\//.test(arg) ? arg : `http://localhost:${arg}`, explicit: true };
|
|
55
|
+
}
|
|
56
|
+
if (!record) {
|
|
57
|
+
return { refuse: "unrecorded" };
|
|
58
|
+
}
|
|
59
|
+
return { base: `http://localhost:${record.port}`, expectId: record.id };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether the server at `target` may be acted on, given the run ID it reported (undefined
|
|
63
|
+
* when it reported none): always for a target the caller named, else only on a match. */
|
|
64
|
+
export function ownsServer(target, reportedId) {
|
|
65
|
+
return target.explicit === true || (reportedId !== undefined && reportedId === target.expectId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The dev server to act on and a local admin token for it, `{ base, token }`, or exit saying
|
|
70
|
+
* why there is none to act on (see `chooseTarget`). `arg` is the command's port or URL
|
|
71
|
+
* argument, if any; `tag` names the command, as in `npm run <tag>`.
|
|
72
|
+
*/
|
|
73
|
+
export async function devTarget(arg, tag) {
|
|
74
|
+
const target = chooseTarget({ arg, record: readDevServer() });
|
|
75
|
+
const deliberately = [
|
|
76
|
+
`To act on a server deliberately, name its port: \`npm run ${tag} -- <port>\`.`,
|
|
77
|
+
...(process.env.PORT
|
|
78
|
+
? [
|
|
79
|
+
`(PORT=${process.env.PORT} is set, but PORT doesn't name a target here: a shell can carry it unawares.)`,
|
|
80
|
+
]
|
|
81
|
+
: []),
|
|
82
|
+
];
|
|
83
|
+
if (target.refuse) {
|
|
84
|
+
fail(
|
|
85
|
+
tag,
|
|
86
|
+
"no dev server is recorded in this directory, so there is no server here to act on.",
|
|
87
|
+
"Start one here with `npm run dev` (it runs `kestrel dev`, which records it), then run this again.",
|
|
88
|
+
...deliberately,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const { token, runId } = await devToken(target.base, tag);
|
|
92
|
+
if (!ownsServer(target, runId)) {
|
|
93
|
+
fail(
|
|
94
|
+
tag,
|
|
95
|
+
`the server at ${target.base} isn't this directory's dev server, so it was left alone.`,
|
|
96
|
+
runId === undefined
|
|
97
|
+
? "It reports no run ID, so it wasn't started by `npm run dev` here."
|
|
98
|
+
: "It reports another run's ID: the server recorded here has stopped, and another holds its port.",
|
|
99
|
+
"Start this directory's with `npm run dev`, then run this again.",
|
|
100
|
+
...deliberately,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (target.explicit) {
|
|
104
|
+
console.log(`[${tag}] acting on ${target.base}, as named`);
|
|
43
105
|
}
|
|
44
|
-
return
|
|
106
|
+
return { base: target.base, token };
|
|
45
107
|
}
|
|
46
108
|
|
|
47
109
|
/** Print a failure under the script's tag and exit. */
|
|
@@ -54,9 +116,10 @@ export function fail(tag, ...lines) {
|
|
|
54
116
|
process.exit(1);
|
|
55
117
|
}
|
|
56
118
|
|
|
57
|
-
/** Mint a local admin token, a `service` principal as Claude is in production,
|
|
119
|
+
/** Mint a local admin token, a `service` principal as Claude is in production, with the run
|
|
120
|
+
* ID the server reports (undefined when it reports none), as `{ token, runId }`, or exit
|
|
58
121
|
* saying why the server can't give one. */
|
|
59
|
-
|
|
122
|
+
async function devToken(base, tag) {
|
|
60
123
|
let res;
|
|
61
124
|
try {
|
|
62
125
|
res = await fetch(`${base}/api/dev/token?kind=service`);
|
|
@@ -76,7 +139,8 @@ export async function devToken(base, tag) {
|
|
|
76
139
|
if (!res.ok) {
|
|
77
140
|
fail(tag, `could not mint a dev token: ${res.status} ${res.statusText}`);
|
|
78
141
|
}
|
|
79
|
-
|
|
142
|
+
const { token, runId } = await res.json();
|
|
143
|
+
return { token, runId: typeof runId === "string" && runId ? runId : undefined };
|
|
80
144
|
}
|
|
81
145
|
|
|
82
146
|
/** Call the API as `token`: JSON in, JSON out. Resolves `{ status, ok, body }`, or exits
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The record of this directory's dev server, shared between `kestrel dev` (cli/dev.mjs) and
|
|
3
|
+
* the seed and reset commands: the port it bound and the ID of that run.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: seed and reset wipe the database of whichever server they reach, and more
|
|
6
|
+
* than one dev server can be running on this machine (a second worktree, an instance repository
|
|
7
|
+
* beside this one). `npm run dev` can bind a port other than 8787 (Claude Code's preview
|
|
8
|
+
* autoPort picks a free one and hands it over in PORT; `kestrel dev` falls back to a free one
|
|
9
|
+
* when 8787 is busy), and that choice reaches only the dev child, so a `npm run seed` in another
|
|
10
|
+
* terminal never sees it. The port alone isn't proof either: a server started some other way
|
|
11
|
+
* (a raw `wrangler dev --port N`) leaves no record, and a record left by a server that has since
|
|
12
|
+
* stopped can name a port another directory's server now holds. So `kestrel dev` makes up an ID
|
|
13
|
+
* for each run, records it here with the port, and hands it to the Worker (the `DEV_RUN_ID`
|
|
14
|
+
* var), which reports it beside the dev token. Seed and reset with no target given go only to
|
|
15
|
+
* the recorded port, and only when the server there reports this record's ID (cli/dev-api.mjs).
|
|
16
|
+
*
|
|
17
|
+
* Scope: the file lives under `.wrangler/`, which is gitignored and per-directory (each worktree
|
|
18
|
+
* has its own `.wrangler/state`), so the record is scoped to this directory and never committed.
|
|
19
|
+
* It replaces the bare-port `.wrangler/dev-port` of earlier releases, which is removed when a dev
|
|
20
|
+
* server starts and is never read: a port with no ID proves nothing.
|
|
21
|
+
*/
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
// Under the directory the command runs in: this repository, or an instance repository. The
|
|
27
|
+
// directory is a parameter only so the suite can point these at a scratch one.
|
|
28
|
+
const recordFile = (dir) => join(dir, ".wrangler", "dev-server.json");
|
|
29
|
+
const legacyPortFile = (dir) => join(dir, ".wrangler", "dev-port");
|
|
30
|
+
|
|
31
|
+
/** A fresh ID for one run of the dev server. */
|
|
32
|
+
export function newDevRunId() {
|
|
33
|
+
return randomUUID();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Record the port and run ID of the dev server starting here. Best-effort: on failure there
|
|
37
|
+
* is no record, and seed and reset refuse an implicit target rather than guess one. */
|
|
38
|
+
export function writeDevServer(port, id, dir = process.cwd()) {
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(join(dir, ".wrangler"), { recursive: true });
|
|
41
|
+
writeFileSync(recordFile(dir), `${JSON.stringify({ port: String(port), id })}\n`, "utf8");
|
|
42
|
+
rmSync(legacyPortFile(dir), { force: true });
|
|
43
|
+
} catch {
|
|
44
|
+
/* best effort */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove this run's record when its dev server exits, so a later seed/reset says no server is
|
|
49
|
+
* recorded rather than chase a port nothing is listening on. Only when the record is still
|
|
50
|
+
* this run's: a second `kestrel dev` here overwrites it, and the first one stopping must not
|
|
51
|
+
* erase the record of the one still serving. Best-effort. */
|
|
52
|
+
export function clearDevServer(id, dir = process.cwd()) {
|
|
53
|
+
try {
|
|
54
|
+
if (readDevServer(dir)?.id === id) {
|
|
55
|
+
rmSync(recordFile(dir), { force: true });
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
/* best effort */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The dev server last recorded in this directory, `{ port, id }`, or undefined when none is. */
|
|
63
|
+
export function readDevServer(dir = process.cwd()) {
|
|
64
|
+
try {
|
|
65
|
+
const { port, id } = JSON.parse(readFileSync(recordFile(dir), "utf8"));
|
|
66
|
+
return /^\d+$/.test(String(port)) && typeof id === "string" && id
|
|
67
|
+
? { port: String(port), id }
|
|
68
|
+
: undefined;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/cli/dev.mjs
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* `--port`. This reads PORT and forwards it as `--port`. With no PORT it prefers 8787 and
|
|
16
16
|
* falls back to a free port, so a second server still starts. The resolved port drives the
|
|
17
17
|
* origin overrides, so the absolute URLs an email carries point at the server actually bound,
|
|
18
|
-
* and is recorded (cli/dev-
|
|
18
|
+
* and is recorded with an ID made up for this run (cli/dev-server.mjs), which the Worker is
|
|
19
|
+
* handed too, so seed and reset find this server and can tell it from another directory's.
|
|
19
20
|
*
|
|
20
21
|
* A fresh local database. A new checkout or worktree has an empty `.wrangler/state`, so every
|
|
21
22
|
* D1 route would 500 until the migrations ran. The local database is probed and, when its
|
|
@@ -34,7 +35,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
34
35
|
import { createServer } from "node:net";
|
|
35
36
|
import { join } from "node:path";
|
|
36
37
|
import { parseEnv } from "node:util";
|
|
37
|
-
import {
|
|
38
|
+
import { clearDevServer, newDevRunId, writeDevServer } from "./dev-server.mjs";
|
|
38
39
|
import { startSweepTicker } from "./sweep-ticker.mjs";
|
|
39
40
|
import { captureWrangler, runWrangler, spawnWrangler } from "./wrangler.mjs";
|
|
40
41
|
|
|
@@ -69,7 +70,8 @@ function findFreePort() {
|
|
|
69
70
|
|
|
70
71
|
// Migrate the local database when its `posts` table is genuinely missing. Wrangler's "no such
|
|
71
72
|
// table" text is matched rather than any failure, so a transient probe failure (the file still
|
|
72
|
-
// locked by a server just stopped) doesn't trigger a needless migrate.
|
|
73
|
+
// locked by a server just stopped) doesn't trigger a needless migrate. In a terminal, wrangler
|
|
74
|
+
// asks to confirm the migrations first, as it does for any apply. Best-effort: a failed
|
|
73
75
|
// bootstrap leaves things no worse than an unmigrated database would be on its own.
|
|
74
76
|
function bootstrapDatabase() {
|
|
75
77
|
const probe = captureWrangler([
|
|
@@ -175,28 +177,54 @@ export async function devCommand(argv, { onStop = () => {} } = {}) {
|
|
|
175
177
|
checkDevVars(fromShell);
|
|
176
178
|
}
|
|
177
179
|
|
|
178
|
-
|
|
180
|
+
// The Worker reports this ID beside the dev token, and seed and reset with no target given
|
|
181
|
+
// check it against the record, so they never wipe a server this directory didn't start.
|
|
182
|
+
// Not for --remote: its database is a deployed one, which an unnamed seed must never reach.
|
|
183
|
+
const runId = newDevRunId();
|
|
184
|
+
const runIdArgs = isRemote ? [] : ["--var", `DEV_RUN_ID:${runId}`];
|
|
185
|
+
|
|
186
|
+
if (!isRemote) {
|
|
187
|
+
writeDevServer(port, runId);
|
|
188
|
+
}
|
|
179
189
|
// A remote session runs against deployed resources, where the real cron and the real
|
|
180
190
|
// webhooks are the ones that count.
|
|
181
191
|
const stopTicker = isRemote ? () => {} : startSweepTicker(origin);
|
|
182
192
|
const stop = () => {
|
|
183
|
-
|
|
193
|
+
clearDevServer(runId);
|
|
184
194
|
stopTicker();
|
|
185
195
|
onStop();
|
|
186
196
|
};
|
|
187
197
|
|
|
188
|
-
const child = spawnWrangler([
|
|
198
|
+
const child = spawnWrangler([
|
|
199
|
+
"dev",
|
|
200
|
+
"--port",
|
|
201
|
+
port,
|
|
202
|
+
...originArgs,
|
|
203
|
+
...overrideArgs,
|
|
204
|
+
...runIdArgs,
|
|
205
|
+
...argv,
|
|
206
|
+
]);
|
|
189
207
|
|
|
190
208
|
// A signal aimed at this process alone (a harness stop, `kill <pid>`) must not leave
|
|
191
209
|
// wrangler serving, the ticker calling a server that is gone, or the caller's companions
|
|
192
|
-
// running.
|
|
193
|
-
//
|
|
194
|
-
//
|
|
210
|
+
// running. Stop the rest at once and pass the signal to wrangler, then end once wrangler has
|
|
211
|
+
// (the exit handler below re-raises it), or after a few seconds if it doesn't. A terminal's
|
|
212
|
+
// Ctrl-C already reaches wrangler with the whole group, so SIGINT is passed on only if
|
|
213
|
+
// wrangler is still running a moment later: a second SIGINT would cut its shutdown short.
|
|
195
214
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
196
215
|
process.once(signal, () => {
|
|
197
|
-
child.kill(signal);
|
|
198
216
|
stop();
|
|
199
|
-
|
|
217
|
+
const forward = () => {
|
|
218
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
219
|
+
child.kill(signal);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
if (signal === "SIGINT") {
|
|
223
|
+
setTimeout(forward, 500).unref();
|
|
224
|
+
} else {
|
|
225
|
+
forward();
|
|
226
|
+
}
|
|
227
|
+
setTimeout(() => process.kill(process.pid, signal), 5000).unref();
|
|
200
228
|
});
|
|
201
229
|
}
|
|
202
230
|
|
package/cli/init.mjs
CHANGED
|
@@ -40,8 +40,9 @@ function scaffoldFiles(dir = TEMPLATE, out = []) {
|
|
|
40
40
|
scaffoldFiles(from, out);
|
|
41
41
|
continue;
|
|
42
42
|
}
|
|
43
|
+
// Split on either separator, so the names match on Windows too.
|
|
43
44
|
const to = relative(TEMPLATE, from)
|
|
44
|
-
.split(
|
|
45
|
+
.split(/[\\/]/)
|
|
45
46
|
.map((part) => RENAMED[part] ?? part)
|
|
46
47
|
.join("/");
|
|
47
48
|
if (!CONTEXT_FILES.includes(to)) {
|
|
@@ -67,7 +68,12 @@ export function initCommand(argv) {
|
|
|
67
68
|
...scaffoldFiles(),
|
|
68
69
|
[join(PACKAGE_ROOT, ".dev.vars.example"), ".dev.vars.example"],
|
|
69
70
|
];
|
|
70
|
-
|
|
71
|
+
// A migration of this release's already there counts too, since sync-migrations refuses
|
|
72
|
+
// to overwrite one, and by then everything else would have been written.
|
|
73
|
+
const migrations = readdirSync(join(PACKAGE_ROOT, "migrations"))
|
|
74
|
+
.filter((f) => f.endsWith(".sql"))
|
|
75
|
+
.map((f) => `migrations/${f}`);
|
|
76
|
+
const writes = [...files.map(([, to]) => to), ...CONTEXT_FILES, ...migrations];
|
|
71
77
|
const taken = writes.filter((to) => existsSync(join(root, to)));
|
|
72
78
|
if (taken.length > 0) {
|
|
73
79
|
console.error(
|
package/cli/reset.mjs
CHANGED
|
@@ -6,12 +6,11 @@
|
|
|
6
6
|
* settings), so this needs the dev server up. It shows the first-run dashboard and setup
|
|
7
7
|
* checklist an operator meets when they deploy. The target is found as for seed.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import { callApi, devTarget, fail } from "./dev-api.mjs";
|
|
10
10
|
|
|
11
11
|
/** `kestrel reset [port|url]`. */
|
|
12
12
|
export async function resetCommand(argv) {
|
|
13
|
-
const base =
|
|
14
|
-
const token = await devToken(base, "reset");
|
|
13
|
+
const { base, token } = await devTarget(argv[0], "reset");
|
|
15
14
|
const res = await callApi(base, token, "/api/dev/reset", { method: "POST", tag: "reset" });
|
|
16
15
|
if (!res.ok) {
|
|
17
16
|
fail("reset", `request failed: ${res.status}`, JSON.stringify(res.body));
|
package/cli/seed.mjs
CHANGED
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
* never talks to D1 or R2 directly. The images travel through the running Worker so they land
|
|
10
10
|
* in the same R2 the dev server serves.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* With no target given, it goes only to the dev server `kestrel dev` recorded in this
|
|
13
|
+
* directory, and only when that server reports the run ID recorded with it, so it never wipes
|
|
14
|
+
* another directory's database (cli/dev-api.mjs, cli/dev-server.mjs); with none recorded it
|
|
15
|
+
* refuses rather than guess a port. A URL or port argument names a server deliberately, and
|
|
16
|
+
* is used as given; `PORT` names nothing here (cli/dev-api.mjs says why). Scale the demo list
|
|
14
17
|
* with `--size` (100 / 1k / 10k / 100k, an approximate target) and pin the seeded PRNG with
|
|
15
18
|
* `--seed`. Without `--size`, the curated story-shaped list (~155 subscribers) loads.
|
|
16
19
|
*/
|
|
@@ -18,7 +21,7 @@ import { existsSync } from "node:fs";
|
|
|
18
21
|
import { readdir, readFile } from "node:fs/promises";
|
|
19
22
|
import { dirname, join } from "node:path";
|
|
20
23
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import {
|
|
24
|
+
import { devTarget, fail, parseArgs } from "./dev-api.mjs";
|
|
22
25
|
|
|
23
26
|
// The demo ships beside the CLI, in this repository and in the package.
|
|
24
27
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -117,8 +120,7 @@ export async function seedDemo(base, token, { size, seed } = {}, tag = "seed") {
|
|
|
117
120
|
/** `kestrel seed [--size n] [--seed s] [port|url]`. */
|
|
118
121
|
export async function seedCommand(argv) {
|
|
119
122
|
const { flags, positional } = parseArgs(argv);
|
|
120
|
-
const base =
|
|
121
|
-
const token = await devToken(base, "seed");
|
|
123
|
+
const { base, token } = await devTarget(positional[0], "seed");
|
|
122
124
|
const summary = await seedDemo(base, token, { size: flags.size, seed: flags.seed });
|
|
123
125
|
console.log("[seed] done:");
|
|
124
126
|
if (flags.size) {
|
package/cli/sync-migrations.mjs
CHANGED
|
@@ -23,6 +23,10 @@ import { fileURLToPath } from "node:url";
|
|
|
23
23
|
|
|
24
24
|
const SHIPPED = join(dirname(fileURLToPath(import.meta.url)), "..", "migrations");
|
|
25
25
|
|
|
26
|
+
// A file's text with LF line endings, so a checkout that turned them into CRLF (git's autocrlf
|
|
27
|
+
// on Windows) isn't taken for an edited migration.
|
|
28
|
+
const sameText = (path) => readFileSync(path, "utf8").replace(/\r\n/g, "\n");
|
|
29
|
+
|
|
26
30
|
/** `kestrel sync-migrations`, into the `migrations/` of `root` (the current directory). */
|
|
27
31
|
export function syncMigrationsCommand(root = process.cwd()) {
|
|
28
32
|
const target = join(root, "migrations");
|
|
@@ -41,7 +45,7 @@ export function syncMigrationsCommand(root = process.cwd()) {
|
|
|
41
45
|
if (!existsSync(to)) {
|
|
42
46
|
copyFileSync(from, to);
|
|
43
47
|
copied.push(name);
|
|
44
|
-
} else if (
|
|
48
|
+
} else if (sameText(to) !== sameText(from)) {
|
|
45
49
|
differ.push(name);
|
|
46
50
|
}
|
|
47
51
|
}
|