@ingram-tech/nk-dev 0.10.0 → 0.11.1

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/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  The nextkit **dev toolchain in one package**. Everything a site needs at
4
4
  development time — and nothing that ships to production — lives here:
5
5
 
6
- - the **`nk` CLI** (`nk dev` / `format` / `lint` / `knip` / `ast-grep` / `check` / `type-check` / `test` / `build`, plus `nk doctor`);
6
+ - the **`nk` CLI** (`nk dev` / `format` / `lint` / `knip` / `ast-grep` / `migrations` / `check` / `type-check` / `test` / `build`, plus
7
+ `nk doctor`);
7
8
  - the shared **oxlint + oxfmt**, **TypeScript**, and **Vitest** config;
8
9
  - **knip** (unused dependency / export / file detection), bundled and run by `nk check`;
9
10
  - the **oxfmt format-on-commit** git hook (`nextkit-format-staged`);
@@ -80,7 +81,10 @@ tsc), so versions stay under each site's control — nk just orchestrates.
80
81
  skips files that already exist.
81
82
  - **`nk doctor [--fix]`** — report drift from the canonical nk-dev toolchain
82
83
  (superseded deps, config `extends`, package.json scripts, the agent-guide
83
- import, a stale `.prettierignore`); `--fix` applies the auto-fixable findings.
84
+ import, a stale `.prettierignore`, an unsealed migration chain and the DDL in
85
+ it drizzle can't model, a page under `app/auth/` shadowing a Better Auth
86
+ endpoint — static segments beat the `[...all]` catch-all, so such a page
87
+ silently 405s the endpoint); `--fix` applies the auto-fixable findings.
84
88
  - **`nk dev`** — start the Next dev server on the golden-path local database
85
89
  (see [`db-package.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/db-package.md)):
86
90
  - **PGlite** — if `@ingram-tech/nk-db`'s `nk-pglite-dev` bin resolves, hand off
@@ -92,6 +96,14 @@ tsc), so versions stay under each site's control — nk just orchestrates.
92
96
  generated (drizzle migrations, `pg_dump` baselines, pglite fixtures).
93
97
  - **`nk lint`** — `oxlint`.
94
98
  - **`nk knip`** — `knip` (unused dependencies / exports / files).
99
+ - **`nk migrations [--check|--reseal|--ddl]`** — guard the `drizzle/` migration
100
+ chain, with no database involved. Verifies each file against the hashes in
101
+ `drizzle/_seal.json` and seals newly generated ones; `--check` verifies
102
+ without writing (what `nk check` runs); `--reseal` rewrites every hash for a
103
+ deliberate squash; `--ddl` lists the migrations carrying DDL drizzle's
104
+ snapshot can't model. Details in
105
+ [`db-package.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/db-package.md#the-seal-applied-migrations-are-immutable).
106
+ A no-op on sites without a migration journal.
95
107
  - **`nk ast-grep [...]`** — structural search & rewrite of TS/TSX by AST pattern,
96
108
  via the vendored [ast-grep](https://ast-grep.github.io) (args passed through to
97
109
  it). For large mechanical refactors — import rewrites, API renames, call-shape
@@ -99,8 +111,8 @@ tsc), so versions stay under each site's control — nk just orchestrates.
99
111
  apply → `nk format` + `nk type-check`) and its syntactic-not-semantic limits
100
112
  live in the codemod skill, `skills/ts-codemod.md`.
101
113
  - **`nk check`** — `oxlint` + `oxfmt --check` + `knip` (only when the repo has a
102
- knip config) + the agent-guide import gate. The CI gate; runs every checker and
103
- reports them all before failing.
114
+ knip config) + the agent-guide import gate + the migration seal. The CI gate;
115
+ runs every checker and reports them all before failing.
104
116
  - **`nk type-check`** — `next typegen && tsc --noEmit`.
105
117
  - **`nk test [...]`** — `vitest run`, extra args passed through.
106
118
  - **`nk build [...]`** — `next build`, extra args passed through.
package/bin/nk.js CHANGED
@@ -5,6 +5,7 @@ import { doctor } from "../lib/doctor.js";
5
5
  import { format } from "../lib/format.js";
6
6
  import { init } from "../lib/init.js";
7
7
  import { knip } from "../lib/knip.js";
8
+ import { migrations } from "../lib/migrations.js";
8
9
  import { build, check, clean, lint, test, typeCheck } from "../lib/passthrough.js";
9
10
 
10
11
  const USAGE = `nk — the nextkit CLI
@@ -16,17 +17,23 @@ Commands:
16
17
  oxfmt / TypeScript / Vitest config, the format-on-commit
17
18
  hook, and the agent-guide import. Skips files that exist.
18
19
  doctor [--fix] Report drift from the canonical nk-dev toolchain (scripts,
19
- superseded deps, config extends, guide import); --fix applies.
20
+ superseded deps, config extends, guide import, auth pages
21
+ shadowing Better Auth endpoints); --fix applies.
20
22
  dev Start the Next dev server (Turbopack). Boots local PGlite
21
23
  first when @ingram-tech/nk-db is installed (no Docker).
22
24
  format [--check] Format code with oxfmt. --check verifies without writing.
23
25
  lint [...] Lint with oxlint (extra args passed through, e.g. --fix).
24
26
  knip Find unused dependencies / exports / files with knip.
27
+ migrations [...] Guard the drizzle migration chain: verify that no applied
28
+ migration's bytes changed, and seal newly generated ones.
29
+ --check verifies without writing (CI); --reseal rewrites
30
+ every hash (a deliberate squash); --ddl lists the DDL
31
+ drizzle's snapshot cannot model.
25
32
  ast-grep [...] Structural search & rewrite of TS/TSX by AST pattern
26
33
  (vendored ast-grep; args passed through). For large
27
34
  mechanical refactors — see the codemod skill.
28
35
  check The CI gate: lint + format verify + knip (when configured)
29
- + the agent-guide import gate.
36
+ + the agent-guide import gate + the migration seal.
30
37
  type-check next typegen && tsc --noEmit. Recovers automatically when
31
38
  generated types are damaged (e.g. a killed dev server).
32
39
  clean Remove regenerable build artifacts: Next's generated
@@ -58,6 +65,9 @@ switch (cmd) {
58
65
  case "knip":
59
66
  knip(rest);
60
67
  break;
68
+ case "migrations":
69
+ migrations(rest);
70
+ break;
61
71
  case "ast-grep":
62
72
  astGrep(rest);
63
73
  break;
package/guide.md CHANGED
@@ -66,10 +66,10 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
66
66
  (`uuid("id").primaryKey().default(sql\`uuidv7()\`)`) and Better Auth gets
67
67
  `advanced.database.generateId: false` so the DB mints ids; below 18 — and in
68
68
  the nk-auth README's canonical example — pass
69
- `advanced.database.generateId: uuidGenerateId` (JS-minted UUIDv7 from
70
- `@ingram-tech/nk-auth`) instead. Either way, never Better Auth's default JS
69
+ `advanced.database.generateId: uuidv7` (JS-minted UUIDv7 from `id758`;
70
+ `@ingram-tech/nk-auth` re-exports it as `uuidGenerateId`) instead. Either way, never Better Auth's default JS
71
71
  nanoid. Ids that cross a **public contract** are skinned to `prefix_base58`
72
- via `@ingram-tech/nk-db/id` (`createIdRegistry`) — never expose a raw UUID.
72
+ via `id758` / `@ingram-tech/nk-db/id` (`createIdRegistry`) — never expose a raw UUID.
73
73
  External ids you don't mint (Stripe `cus_`, OAuth) stay `text`.
74
74
  - **Migrations don't auto-apply on deploy.** Code ships ahead of the prod schema
75
75
  unless someone runs the migration against the target DB — a page that reads a
@@ -83,6 +83,33 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
83
83
  transaction, before commit. A blind move that touches nothing (an RLS mask, a
84
84
  wrong `where`) otherwise reports success, and the drop of the source columns
85
85
  in the same migration makes it unrecoverable.
86
+ - **Never edit a migration that has been applied.** The runner records
87
+ `sha256(file)`, so the bytes are history: editing one drifts every database
88
+ that already ran it, and drizzle never looks at the file again to notice.
89
+ Express the change as a **new** migration. `drizzle/_seal.json` pins the
90
+ hashes and `nk check` fails on a mismatch — if it does, `git checkout` the
91
+ file rather than resealing. After generating a migration, run **`nk
92
+ migrations`** and commit `_seal.json` in the same commit as the `.sql`.
93
+ `nk migrations --reseal` exists only for a deliberate squash, which also
94
+ requires reconciling every database with `nk-pg-migrate --baseline`.
95
+ - **A clean `db:generate` does not mean the chain matches the database.**
96
+ drizzle diffs `schema.ts` against `meta/*_snapshot.json`, never against the
97
+ `.sql` files, and the snapshot can't model functions, triggers, `DEFERRABLE`
98
+ constraints, grants or roles. Anything regenerated from `schema.ts` drops
99
+ those clauses silently. `nk migrations --ddl` lists which migrations carry
100
+ them; verify against a real database before trusting a regenerated chain.
101
+ - **Never hand-append unmodelled DDL to a generated migration.** A generated
102
+ file must stay exactly what `drizzle-kit generate` produced, or the snapshot
103
+ becomes an active lie about a file drizzle believes it owns — and the next
104
+ regenerate re-emits those objects without your clauses. Put functions,
105
+ triggers, `DEFERRABLE`, grants and roles in `drizzle-kit generate --custom`
106
+ migrations instead.
107
+ - **Merging two branches that both added migrations? Check the journal.**
108
+ drizzle applies files by `when > max(created_at)`, so a migration whose `when`
109
+ lands below one already applied is skipped silently and forever.
110
+ `nk-pg-migrate` refuses to run in that state (`MigrationOrderError`); fix it
111
+ by raising the stranded entry's `when` in `meta/_journal.json` — never by
112
+ editing the `.sql`, which would break the hash every database recorded.
86
113
  - **`drizzle-kit` is GENERATE-ONLY — it must never apply schema.** Use it for
87
114
  `drizzle-kit generate` (and `generate --custom` for a package-owned/raw SQL
88
115
  migration). Applying is always **`nk-pg-migrate`** (the bin from
@@ -112,12 +139,12 @@ tool instead). One-off single-file edits: just edit the file.
112
139
 
113
140
  - `@ingram-tech/nk-email` — Cloudflare email: `sendEmail`, `fromAddress`
114
141
  - `@ingram-tech/nk-auth` — Better Auth foundation: presets you spread into your own `betterAuth()` (mounts at `/auth` via `authBasePath`; org / JWT / passkey / pool / client helpers). Don't hand-roll session reads or auth middleware — bind `createAuthHelpers` (`getUser` / `requireUser` / `redirectIfAuthenticated`, from `@ingram-tech/nk-auth/server`) and gate routes with the loop-safe `createAuthMiddleware`
115
- - `@ingram-tech/nk-db` — Postgres data layer: `createPool` (one TLS-aware pool) + `createQueries` (raw SQL) + `createDb` (Drizzle), the PGlite dev/test harness at `@ingram-tech/nk-db/pglite`, the prefixed-id codec at `@ingram-tech/nk-db/id`, and the drift-aware migration runner at `@ingram-tech/nk-db/migrate`
142
+ - `@ingram-tech/nk-db` — Postgres data layer: `createPool` (one TLS-aware pool) + `createQueries` (raw SQL) + `createDb` (Drizzle), the PGlite dev/test harness at `@ingram-tech/nk-db/pglite`, the prefixed-id codec (the standalone `id758` package) at `@ingram-tech/nk-db/id`, and the drift-aware migration runner at `@ingram-tech/nk-db/migrate`
116
143
  - `@ingram-tech/nk-api` — the standard HTTP API seam (Hono + `@hono/zod-openapi`): one `{ error, details? }` envelope, `createApiApp` / `createRouter`, auth + multi-tenant resource-scope middleware, pagination helpers, and an emitted OpenAPI/Swagger doc. Reach for it instead of hand-rolling route handlers
117
144
  - `@ingram-tech/nk-billing` — Stripe primitives: subscriptions, a Stripe-side wallet, and an optional Postgres credit ledger behind the `/credits` subpath. Prices resolve at runtime by Stripe `lookup_key` — **never hardcode a price id**, so test and live share one code path
118
145
  - `@ingram-tech/bot-protection` — invisible form protection (honeypot + timing + Vercel BotID); the primitive nk-forms builds on, used directly only for non-form endpoints
119
146
  - `@ingram-tech/nk-forms` — the public contact/signup submission pipeline over bot-protection + nk-email: `handleFormSubmission` (rate-limit → bot gate → validate → escaped-email deliver → uniform 200), `renderNotificationEmail`, `mintFormToken`, and `useFormSubmit` / `HoneypotInput` (`/react`). Reach for it instead of wiring bot-protection by hand
120
- - `@ingram-tech/nk-i18n` — type-safe, English-as-key i18n: the English source text *is* the key (no `en.json`), ICU MessageFormat, colocated JSON catalogs; routing is left to the site
147
+ - `@ingram-tech/nk-i18n` — type-safe, English-as-key i18n: the English source text *is* the key (no `en.json`), ICU MessageFormat, colocated JSON catalogs, plus **locale URL routing** (`defineLocaleRouting` + a fixed URL→account→cookie→`Accept-Language`→country precedence, wired to Next at `/next`). A URL that names a locale must serve it with a 200 — never redirect `?hl=fr` away, or every hreflang annotation on the site points at a URL that doesn't serve the language it claims. See `docs/i18n-routing.md`
121
148
  - `@ingram-tech/nk-marketing` — Postgres-backed marketing & lifecycle email: contacts + consent, newsletter broadcast audiences, and idempotent triggered campaigns, with RFC 8058 one-click unsubscribe
122
149
  - `@ingram-tech/nk-seo` — SEO toolkit: metadata factory, JSON-LD builders, sitemap/robots routes, hreflang + canonical links, and an OG image template
123
150
  - `@ingram-tech/nk-blog` — file-indexed blog engine: frontmatter contract, limited-MDX rendering with a component vocabulary, RSS, blog SEO, GitHub publishing
package/lib/artifacts.js CHANGED
@@ -17,7 +17,7 @@ const GENERATED_DIRECTORIES = [
17
17
  ];
18
18
 
19
19
  /** Prefixes (posix-normalised) that `tsc` error locations may fall inside. */
20
- export const TYPE_CHECK_INPUT_PREFIXES = GENERATED_DIRECTORIES.filter(
20
+ const TYPE_CHECK_INPUT_PREFIXES = GENERATED_DIRECTORIES.filter(
21
21
  (entry) => entry.typeCheckInput,
22
22
  ).map((entry) => entry.path);
23
23
 
@@ -0,0 +1,150 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ // In the App Router a static segment always beats a catch-all, so a page (or
6
+ // route.ts) under `app/auth/` whose path matches a Better Auth endpoint
7
+ // silently shadows it: GETs render the page, POSTs to the endpoint return 405,
8
+ // and nothing at build time says so. The endpoint list is derived textually
9
+ // from better-auth's dist (grep for `createAuthEndpoint("...")`) — we never
10
+ // load or execute site or dependency code just to read a set of strings.
11
+
12
+ const PAGE_FILES = /^page\.(tsx|jsx|ts|js)$/;
13
+ const ROUTE_FILES = /^route\.(ts|js)$/;
14
+ const ENDPOINT_RE = /createAuthEndpoint\(\s*"([^"]+)"/g;
15
+
16
+ /** The `app/auth/[...all]` mount dir, or null when the site has no auth mount. */
17
+ function findMount(cwd) {
18
+ for (const appDir of ["src/app", "app"]) {
19
+ const catchAll = resolve(cwd, appDir, "auth", "[...all]");
20
+ for (const ext of ["ts", "js", "tsx", "jsx"]) {
21
+ if (existsSync(join(catchAll, `route.${ext}`))) {
22
+ return { appDir, authDir: resolve(cwd, appDir, "auth") };
23
+ }
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** better-auth's dist dir resolved from the site, or null when not installed. */
30
+ function betterAuthDist(cwd) {
31
+ try {
32
+ const require = createRequire(resolve(cwd, "package.json"));
33
+ const pkg = require.resolve("better-auth/package.json");
34
+ return join(dirname(pkg), "dist");
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /** All `createAuthEndpoint("...")` paths in the `.mjs` files under `dir`. */
41
+ function grepEndpoints(dir, recurse) {
42
+ if (!existsSync(dir)) return [];
43
+ const paths = new Set();
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const full = join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ if (recurse) for (const p of grepEndpoints(full, true)) paths.add(p);
48
+ continue;
49
+ }
50
+ if (!entry.name.endsWith(".mjs")) continue;
51
+ const src = readFileSync(full, "utf8");
52
+ for (const m of src.matchAll(ENDPOINT_RE)) paths.add(m[1]);
53
+ }
54
+ return [...paths];
55
+ }
56
+
57
+ /**
58
+ * Walk `app/auth/**` collecting the page/route files that claim a static URL,
59
+ * as `{ file, segments }` with `file` relative to `cwd`. Skips the `[...all]`
60
+ * catch-all itself, `_private` folders, and `@slot` parallel-route trees (a
61
+ * slot renders alongside the layout rather than owning the URL segment, so we
62
+ * conservatively leave those trees to the human); `(group)` segments don't
63
+ * appear in the URL and are dropped.
64
+ */
65
+ function collectRoutes(cwd, dir, segments, out) {
66
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
67
+ const full = join(dir, entry.name);
68
+ if (entry.isDirectory()) {
69
+ if (entry.name === "[...all]") continue;
70
+ if (entry.name.startsWith("_") || entry.name.startsWith("@")) continue;
71
+ const next = /^\(.*\)$/.test(entry.name)
72
+ ? segments
73
+ : [...segments, entry.name];
74
+ collectRoutes(cwd, full, next, out);
75
+ continue;
76
+ }
77
+ if (!PAGE_FILES.test(entry.name) && !ROUTE_FILES.test(entry.name)) continue;
78
+ if (segments.length === 0) continue; // `/auth` itself can't match an endpoint
79
+ out.push({ file: full.slice(cwd.length + 1), segments });
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Whether a page's segments match an endpoint path: same segment count, where
85
+ * an endpoint `:param` matches any page segment and a page `[param]` (or
86
+ * catch-all) matches any endpoint segment.
87
+ */
88
+ function shadows(segments, endpoint) {
89
+ const eps = endpoint.split("/").filter(Boolean);
90
+ if (eps.length !== segments.length) return false;
91
+ return eps.every((ep, i) => {
92
+ const seg = segments[i];
93
+ if (ep.startsWith(":")) return true;
94
+ if (/^\[.*\]$/.test(seg)) return true;
95
+ return seg === ep;
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Findings for Better Auth endpoint shadowing. Silent on sites without an
101
+ * `app/auth/[...all]` mount or without better-auth installed. Core endpoints
102
+ * (dist/api/routes) shadow as errors; plugin endpoints (dist/plugins) as
103
+ * warnings, since only enabled plugins are live and we can't tell which those
104
+ * are without executing the site's auth config.
105
+ */
106
+ export function authShadowFindings(cwd) {
107
+ const mount = findMount(cwd);
108
+ if (!mount) return [];
109
+ const dist = betterAuthDist(cwd);
110
+ if (!dist) return [];
111
+
112
+ const core = grepEndpoints(join(dist, "api", "routes"), false);
113
+ if (core.length === 0) {
114
+ return [
115
+ {
116
+ id: "auth:shadow-check-skipped",
117
+ level: "warn",
118
+ message:
119
+ "could not derive Better Auth's endpoint list from better-auth/dist/api/routes (layout changed?) — the endpoint-shadowing check was skipped",
120
+ },
121
+ ];
122
+ }
123
+ const plugin = grepEndpoints(join(dist, "plugins"), true);
124
+
125
+ const routes = [];
126
+ collectRoutes(cwd, mount.authDir, [], routes);
127
+
128
+ const out = [];
129
+ for (const { file, segments } of routes) {
130
+ const routePath = `/${segments.join("/")}`;
131
+ const hit = core.find((ep) => shadows(segments, ep));
132
+ if (hit) {
133
+ out.push({
134
+ id: `auth:endpoint-shadow:${routePath}`,
135
+ level: "error",
136
+ message: `\`${file}\` shadows Better Auth's \`/auth${hit}\` endpoint — a static segment beats the \`[...all]\` catch-all, so POSTs to it return 405 and the auth flow silently breaks. Rename the page (the precedent: the reset page is \`/auth/set-password\` because \`/auth/reset-password\` is taken).`,
137
+ });
138
+ continue;
139
+ }
140
+ const pluginHit = plugin.find((ep) => shadows(segments, ep));
141
+ if (pluginHit) {
142
+ out.push({
143
+ id: `auth:endpoint-shadow-plugin:${routePath}`,
144
+ level: "warn",
145
+ message: `\`${file}\` would shadow the Better Auth plugin endpoint \`/auth${pluginHit}\` — only a problem if the site enables that plugin, but a rename now avoids the 405 later.`,
146
+ });
147
+ }
148
+ }
149
+ return out;
150
+ }
package/lib/doctor.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ import { authShadowFindings } from "./auth-shadow.js";
3
4
  import { SUPERSEDED_DEPS } from "./drift.js";
5
+ import {
6
+ SEAL_FILE,
7
+ migrationsFolder,
8
+ readChain,
9
+ readSeal,
10
+ summarizeKinds,
11
+ unmodelledDdl,
12
+ writeSeal,
13
+ } from "./migrations.js";
4
14
 
5
15
  // The canonical script → command mapping for a site on the nk-dev toolchain.
6
16
  // The key is matched loosely (`type-check` and `typecheck` are both accepted);
@@ -257,6 +267,65 @@ export function findings(cwd) {
257
267
  });
258
268
  }
259
269
 
270
+ // 9. The migration chain is sealed, and its unmodelled DDL is declared.
271
+ out.push(...migrationFindings(cwd));
272
+
273
+ // 10. No page/route under app/auth/ shadows a Better Auth endpoint.
274
+ out.push(...authShadowFindings(cwd));
275
+
276
+ return out;
277
+ }
278
+
279
+ /**
280
+ * Findings over a `drizzle/` chain. Silent on repos without one.
281
+ *
282
+ * The seal finding is the cheap half of migration safety: applied migrations
283
+ * are immutable, and nothing in drizzle notices when one is edited.
284
+ *
285
+ * The unmodelled-DDL finding is the honest half. `drizzle-kit generate` diffs
286
+ * `schema.ts` against `meta/*_snapshot.json`, so any DDL the snapshot can't
287
+ * model — functions, triggers, `DEFERRABLE` constraints, grants, roles — is
288
+ * outside the diff basis entirely. A chain carrying it can drift arbitrarily
289
+ * far from the database while `db:generate` still reports no changes, and
290
+ * anything regenerated from `schema.ts` drops it. That is a real property of
291
+ * the repo, so `nk doctor` states it rather than leaving it to be rediscovered.
292
+ */
293
+ function migrationFindings(cwd) {
294
+ const out = [];
295
+ const folder = migrationsFolder(cwd);
296
+ let chain;
297
+ try {
298
+ chain = readChain(cwd, folder);
299
+ } catch (err) {
300
+ return [
301
+ { id: "migrations:broken-chain", level: "error", message: err.message },
302
+ ];
303
+ }
304
+ if (chain === null || chain.length === 0) return out;
305
+
306
+ if (readSeal(cwd, folder) === null) {
307
+ out.push({
308
+ id: "migrations:unsealed",
309
+ level: "warn",
310
+ message: `${chain.length} migration(s) with no ${folder}/${SEAL_FILE} — an edit to an already-applied migration would go unnoticed`,
311
+ fix: (dir) => {
312
+ const f = migrationsFolder(dir);
313
+ writeSeal(dir, f, readChain(dir, f));
314
+ return `sealed ${chain.length} migration(s) in ${f}/${SEAL_FILE}`;
315
+ },
316
+ });
317
+ }
318
+
319
+ const inventory = unmodelledDdl(cwd, folder);
320
+ if (inventory.length > 0) {
321
+ const kinds = summarizeKinds(inventory);
322
+ out.push({
323
+ id: "migrations:unmodelled-ddl",
324
+ level: "warn",
325
+ message: `${inventory.length} of ${chain.length} migration(s) carry DDL drizzle's snapshot cannot model (${kinds.join(", ")}) — \`db:generate\` reporting "no changes" does not mean the chain reproduces the database, and regenerating from schema.ts drops it. Run \`nk migrations --ddl\` for the per-file list.`,
326
+ });
327
+ }
328
+
260
329
  return out;
261
330
  }
262
331
 
@@ -264,7 +333,9 @@ export function findings(cwd) {
264
333
  * `nk doctor [--fix]` — report drift from the canonical nk-dev model (scripts,
265
334
  * dependencies, oxlint/tsconfig extends, the CLAUDE.md guide import, stale knip
266
335
  * ignores, forbidden schema-applying drizzle-kit scripts, a dead
267
- * .prettierignore). With `--fix`, apply every auto-fixable finding, then remind
336
+ * .prettierignore, an unsealed or unmodelled-DDL-carrying migration chain, a
337
+ * page under app/auth/ shadowing a Better Auth endpoint).
338
+ * With `--fix`, apply every auto-fixable finding, then remind
268
339
  * to reinstall.
269
340
  */
270
341
  export function doctor(args = []) {
@@ -0,0 +1,354 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ // Two guards over a `drizzle/` migration chain, both of which exist because
6
+ // `drizzle-kit generate` diffs `schema.ts` against `meta/*_snapshot.json` and
7
+ // NEVER against the `.sql` files:
8
+ //
9
+ // 1. THE SEAL. Once a migration has been applied anywhere, its bytes are
10
+ // history — the runner records `sha256(file)` in the journal table, so
11
+ // editing the file (a formatter sweep, a "quick fix" to a generated
12
+ // migration) permanently drifts every database that already ran it. There
13
+ // is nothing in drizzle that notices. {@link verifySeal} pins each file's
14
+ // hash in a committed `_seal.json`, so the edit shows up as a failed check
15
+ // in the PR that made it instead of as a confusing `already exists` on the
16
+ // next deploy.
17
+ //
18
+ // 2. THE UNMODELLED-DDL INVENTORY. Functions, triggers, `DEFERRABLE`
19
+ // constraints, grants and roles are not in drizzle's snapshot model. Once
20
+ // a migration carries them, the snapshot is a permanently partial view of
21
+ // the schema: `db:generate` reports "nothing to migrate" no matter how far
22
+ // the chain has drifted from the database, and anything regenerated from
23
+ // `schema.ts` (notably a squash) silently drops them.
24
+ // {@link unmodelledDdl} turns that from tribal knowledge into a list.
25
+ //
26
+ // Both are deliberately database-free: they run in CI, in a pre-commit hook and
27
+ // on a laptop with no `DATABASE_URL`. Proving the chain actually reproduces the
28
+ // live schema needs a catalog diff against a real database, which is a
29
+ // different (and much larger) tool.
30
+
31
+ /** Name of the seal file, written inside the migrations folder. */
32
+ export const SEAL_FILE = "_seal.json";
33
+
34
+ const SEAL_COMMENT =
35
+ "sha256 of each migration file at the time it was sealed. Applied migrations are immutable: if a hash here stops matching, the file was edited after it ran and every database that already applied it has drifted. Regenerate with `nk migrations --reseal` ONLY as part of a deliberate squash.";
36
+
37
+ /**
38
+ * The migrations folder for a repo. Honours `out:` in a drizzle config when one
39
+ * is present (matched textually — we are not loading the site's TS config just
40
+ * to read one string), else drizzle's `drizzle` default.
41
+ */
42
+ export function migrationsFolder(cwd = process.cwd()) {
43
+ for (const name of [
44
+ "drizzle.config.ts",
45
+ "drizzle.config.js",
46
+ "drizzle.config.mjs",
47
+ ]) {
48
+ const path = resolve(cwd, name);
49
+ if (!existsSync(path)) continue;
50
+ const match = /\bout\s*:\s*["'`]([^"'`]+)["'`]/.exec(
51
+ readFileSync(path, "utf8"),
52
+ );
53
+ if (match?.[1]) return match[1];
54
+ }
55
+ return "drizzle";
56
+ }
57
+
58
+ const journalPathFor = (cwd, folder) => resolve(cwd, folder, "meta", "_journal.json");
59
+
60
+ /**
61
+ * The migration chain as `{ tag, hash }`, in journal order. `hash` is
62
+ * `sha256(rawFile)` — the exact value drizzle records in `__drizzle_migrations`,
63
+ * so a mismatch here is a mismatch there.
64
+ *
65
+ * Returns null when the repo has no journal (not a drizzle site — nothing to
66
+ * guard). Throws when the journal names a file that doesn't exist, which is
67
+ * itself a broken chain.
68
+ */
69
+ export function readChain(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
70
+ const journalPath = journalPathFor(cwd, folder);
71
+ if (!existsSync(journalPath)) return null;
72
+ const journal = JSON.parse(readFileSync(journalPath, "utf8"));
73
+ const entries = Array.isArray(journal?.entries) ? journal.entries : [];
74
+ return entries.map((entry) => {
75
+ const sqlPath = resolve(cwd, folder, `${entry.tag}.sql`);
76
+ if (!existsSync(sqlPath)) {
77
+ throw new Error(
78
+ `nk migrations: journal entry "${entry.tag}" has no ${folder}/${entry.tag}.sql`,
79
+ );
80
+ }
81
+ const sql = readFileSync(sqlPath, "utf8");
82
+ return {
83
+ tag: entry.tag,
84
+ hash: createHash("sha256").update(sql).digest("hex"),
85
+ sql,
86
+ };
87
+ });
88
+ }
89
+
90
+ /** The committed seal, or an empty one when the repo hasn't sealed yet. */
91
+ export function readSeal(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
92
+ const path = resolve(cwd, folder, SEAL_FILE);
93
+ if (!existsSync(path)) return null;
94
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
95
+ return parsed?.migrations && typeof parsed.migrations === "object"
96
+ ? parsed.migrations
97
+ : {};
98
+ }
99
+
100
+ /** Write the seal for `chain`, in journal order (stable diffs). */
101
+ export function writeSeal(cwd, folder, chain) {
102
+ const migrations = {};
103
+ for (const m of chain) migrations[m.tag] = m.hash;
104
+ writeFileSync(
105
+ resolve(cwd, folder, SEAL_FILE),
106
+ `${JSON.stringify({ $comment: SEAL_COMMENT, migrations }, null, "\t")}\n`,
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Compare the chain on disk to the committed seal.
112
+ *
113
+ * - `changed` — sealed migrations whose bytes moved. Always a defect: those
114
+ * files have already run somewhere.
115
+ * - `dropped` — sealed migrations no longer in the journal. Normal during a
116
+ * squash, a defect at any other time.
117
+ * - `unsealed` — migrations with no seal entry yet (newly generated).
118
+ */
119
+ export function verifySeal(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
120
+ const chain = readChain(cwd, folder);
121
+ if (chain === null) return null;
122
+ const sealed = readSeal(cwd, folder);
123
+ if (sealed === null) {
124
+ return { chain, sealedYet: false, changed: [], dropped: [], unsealed: chain };
125
+ }
126
+ const changed = [];
127
+ const unsealed = [];
128
+ for (const m of chain) {
129
+ const expected = sealed[m.tag];
130
+ if (expected === undefined) unsealed.push(m);
131
+ else if (expected !== m.hash) changed.push({ ...m, sealed: expected });
132
+ }
133
+ const tags = new Set(chain.map((m) => m.tag));
134
+ const dropped = Object.keys(sealed).filter((tag) => !tags.has(tag));
135
+ return { chain, sealedYet: true, changed, dropped, unsealed };
136
+ }
137
+
138
+ // DDL that drizzle's snapshot model does not represent. Each entry is
139
+ // `[kind, pattern]`, matched against SQL with comments, string literals and
140
+ // dollar-quoted bodies stripped, so a mention inside a function body or a
141
+ // `-- create trigger` comment doesn't count.
142
+ const UNMODELLED = [
143
+ ["function", /\bcreate\s+(?:or\s+replace\s+)?(?:function|procedure)\b/i],
144
+ ["trigger", /\bcreate\s+(?:constraint\s+|event\s+)?trigger\b/i],
145
+ ["deferrable", /\bdeferrable\b/i],
146
+ ["grant", /\b(?:grant|revoke)\b/i],
147
+ ["role", /\b(?:create|alter|drop)\s+role\b/i],
148
+ ["extension", /\bcreate\s+extension\b/i],
149
+ ["materialized-view", /\bcreate\s+materialized\s+view\b/i],
150
+ ["rule", /\bcreate\s+(?:or\s+replace\s+)?rule\b/i],
151
+ ["do-block", /(?:^|;|\n)\s*do\s+\$/i],
152
+ ];
153
+
154
+ /**
155
+ * Blank out anything that isn't executable DDL text: dollar-quoted bodies (a
156
+ * function body full of SQL keywords), block and line comments, single-quoted
157
+ * literals and double-quoted identifiers. Replaced with spaces rather than
158
+ * removed so nothing accidentally joins into a new keyword.
159
+ */
160
+ function stripNonDdl(sql) {
161
+ const blank = (m) => " ".repeat(m.length);
162
+ return (
163
+ sql
164
+ // A dollar-quoted body collapses to a bare `$$` rather than to spaces:
165
+ // the body's contents are not statements, but the opener still has to be
166
+ // visible so `do $$ ... $$` is recognisable as an anonymous block.
167
+ .replace(/\$([A-Za-z_]\w*)?\$[\s\S]*?\$\1?\$/g, " $$$$ ")
168
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
169
+ .replace(/--[^\n]*/g, blank)
170
+ .replace(/'(?:[^']|'')*'/g, blank)
171
+ .replace(/"(?:[^"]|"")*"/g, blank)
172
+ );
173
+ }
174
+
175
+ /** The unmodelled-DDL kinds present in one migration's SQL. */
176
+ export function unmodelledKinds(sql) {
177
+ const stripped = stripNonDdl(sql);
178
+ return UNMODELLED.filter(([, pattern]) => pattern.test(stripped)).map(
179
+ ([kind]) => kind,
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Per-file inventory of DDL drizzle can't model: `[{ tag, kinds }]`, only for
185
+ * files that carry some. Empty when the chain is purely generated output (the
186
+ * only case in which `db:generate` reporting "no changes" actually means the
187
+ * chain reproduces `schema.ts`).
188
+ */
189
+ export function unmodelledDdl(cwd = process.cwd(), folder = migrationsFolder(cwd)) {
190
+ const chain = readChain(cwd, folder);
191
+ if (chain === null) return [];
192
+ return chain
193
+ .map((m) => ({ tag: m.tag, kinds: unmodelledKinds(m.sql) }))
194
+ .filter((m) => m.kinds.length > 0);
195
+ }
196
+
197
+ /** Distinct kinds across a whole inventory, for a one-line summary. */
198
+ export function summarizeKinds(inventory) {
199
+ return [...new Set(inventory.flatMap((m) => m.kinds))].sort();
200
+ }
201
+
202
+ const short = (hash) => hash.slice(0, 12);
203
+
204
+ /**
205
+ * The `nk check` gate: `{ ok, reason }`, non-exiting. `ok` on a repo with no
206
+ * journal (not every site has a database) and on a chain that matches its seal.
207
+ */
208
+ export function checkSeal(cwd = process.cwd()) {
209
+ const folder = migrationsFolder(cwd);
210
+ let state;
211
+ try {
212
+ state = verifySeal(cwd, folder);
213
+ } catch (err) {
214
+ return { ok: false, reason: err.message };
215
+ }
216
+ if (state === null) return { ok: true };
217
+ const problems = [
218
+ ...state.changed.map(
219
+ (m) =>
220
+ `${m.tag} changed after it was sealed (${short(m.sealed)} → ${short(m.hash)})`,
221
+ ),
222
+ ...state.dropped.map(
223
+ (tag) => `${tag} was sealed but is no longer in the journal`,
224
+ ),
225
+ ...state.unsealed.map((m) => `${m.tag} is unsealed`),
226
+ ];
227
+ if (problems.length === 0) return { ok: true };
228
+ return {
229
+ ok: false,
230
+ reason: `migration chain does not match ${folder}/${SEAL_FILE} — ${problems.join("; ")}`,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * `nk migrations [--check|--reseal|--ddl]` — guard the migration chain.
236
+ *
237
+ * Default: verify the seal, then seal anything newly generated and write the
238
+ * file. `--check` verifies without writing (the CI shape: an unsealed migration
239
+ * is a failure, because the seal must land in the same commit as the
240
+ * migration). `--reseal` rewrites every hash — the deliberate squash escape
241
+ * hatch, whose effect is visible in the diff. `--ddl` prints the
242
+ * unmodelled-DDL inventory.
243
+ */
244
+ export function migrations(args = []) {
245
+ const cwd = process.cwd();
246
+ const folder = migrationsFolder(cwd);
247
+ const checkOnly = args.includes("--check");
248
+
249
+ let state;
250
+ try {
251
+ state = verifySeal(cwd, folder);
252
+ } catch (err) {
253
+ console.error(`nk migrations: ${err.message}`);
254
+ process.exit(1);
255
+ }
256
+ if (state === null) {
257
+ if (!checkOnly) console.log(`nk migrations: no ${folder}/meta/_journal.json.`);
258
+ process.exit(0);
259
+ }
260
+
261
+ if (args.includes("--ddl")) {
262
+ printDdl(cwd, folder);
263
+ process.exit(0);
264
+ }
265
+
266
+ if (args.includes("--reseal")) {
267
+ writeSeal(cwd, folder, state.chain);
268
+ console.log(
269
+ `nk migrations: resealed ${state.chain.length} migration(s) in ${folder}/${SEAL_FILE}.`,
270
+ );
271
+ for (const m of state.changed) {
272
+ console.log(` ! ${m.tag}: ${short(m.sealed)} → ${short(m.hash)}`);
273
+ }
274
+ for (const tag of state.dropped) console.log(` – ${tag} (dropped)`);
275
+ console.log(
276
+ "\n Every database that already ran a changed or dropped migration must be reconciled (`nk-pg-migrate --baseline`) and verified against the new chain before this ships.",
277
+ );
278
+ process.exit(0);
279
+ }
280
+
281
+ const broken = state.changed.length > 0 || state.dropped.length > 0;
282
+ for (const m of state.changed) {
283
+ console.error(
284
+ `nk migrations: ✗ ${m.tag} changed after it was sealed (${short(m.sealed)} → ${short(m.hash)})`,
285
+ );
286
+ }
287
+ for (const tag of state.dropped) {
288
+ console.error(
289
+ `nk migrations: ✗ ${tag} was sealed but is no longer in the journal`,
290
+ );
291
+ }
292
+ if (broken) {
293
+ console.error(
294
+ "\n An applied migration's bytes are history: every database that ran it recorded that hash. Restore the file (`git checkout`) and express the change as a NEW migration.",
295
+ );
296
+ console.error(
297
+ " If this is a deliberate squash, run `nk migrations --reseal` and reconcile each database with `nk-pg-migrate --baseline`.",
298
+ );
299
+ process.exit(1);
300
+ }
301
+
302
+ if (checkOnly) {
303
+ if (state.unsealed.length > 0) {
304
+ console.error(
305
+ `nk migrations: ✗ ${state.unsealed.length} unsealed migration(s): ${state.unsealed.map((m) => m.tag).join(", ")}`,
306
+ );
307
+ console.error(
308
+ ` → run \`nk migrations\` and commit ${folder}/${SEAL_FILE} alongside the migration.`,
309
+ );
310
+ process.exit(1);
311
+ }
312
+ console.log(
313
+ `nk migrations: ✓ ${state.chain.length} migration(s) match the seal.`,
314
+ );
315
+ process.exit(0);
316
+ }
317
+
318
+ if (state.unsealed.length === 0 && state.sealedYet) {
319
+ console.log(
320
+ `nk migrations: ✓ ${state.chain.length} migration(s) match the seal.`,
321
+ );
322
+ process.exit(0);
323
+ }
324
+ writeSeal(cwd, folder, state.chain);
325
+ console.log(
326
+ `nk migrations: sealed ${state.unsealed.length} new migration(s) — commit ${folder}/${SEAL_FILE}.`,
327
+ );
328
+ for (const m of state.unsealed) console.log(` + ${m.tag}`);
329
+ process.exit(0);
330
+ }
331
+
332
+ function printDdl(cwd, folder) {
333
+ const inventory = unmodelledDdl(cwd, folder);
334
+ if (inventory.length === 0) {
335
+ console.log(
336
+ "nk migrations: no DDL outside drizzle's snapshot model — `db:generate` sees the whole schema.",
337
+ );
338
+ return;
339
+ }
340
+ console.log(
341
+ `nk migrations: ${inventory.length} migration(s) carry DDL drizzle's snapshot cannot model:\n`,
342
+ );
343
+ for (const m of inventory) console.log(` ${m.tag} ${m.kinds.join(", ")}`);
344
+ console.log(
345
+ "\n drizzle diffs schema.ts against meta/*_snapshot.json, so none of this is in the diff basis:",
346
+ );
347
+ console.log(
348
+ " `db:generate` reporting no changes does NOT mean the chain reproduces the database, and anything",
349
+ );
350
+ console.log(
351
+ " regenerated from schema.ts (a squash above all) drops these clauses silently. Verify against a real",
352
+ );
353
+ console.log(" database before trusting a regenerated chain.");
354
+ }
@@ -7,6 +7,7 @@ import deferredCurrentTarget from "./deferred-current-target.js";
7
7
  import lucideIconSuffix from "./lucide-icon-suffix.js";
8
8
  import noCryptoRandomUuid from "./no-crypto-random-uuid.js";
9
9
  import noRedirectOnlyPage from "./no-redirect-only-page.js";
10
+ import noRedundantNodeCrypto from "./no-redundant-node-crypto.js";
10
11
  import redundantUseStateType from "./redundant-usestate-type.js";
11
12
  import satoriCss from "./satori-css.js";
12
13
  import tNoPositionalArgs from "./t-no-positional-args.js";
@@ -20,6 +21,7 @@ export default {
20
21
  ...lucideIconSuffix.rules,
21
22
  ...noCryptoRandomUuid.rules,
22
23
  ...noRedirectOnlyPage.rules,
24
+ ...noRedundantNodeCrypto.rules,
23
25
  ...redundantUseStateType.rules,
24
26
  ...satoriCss.rules,
25
27
  ...tNoPositionalArgs.rules,
@@ -8,7 +8,7 @@
8
8
  // invisible until the table is large, which is exactly when it is expensive to
9
9
  // undo.
10
10
  //
11
- // The mint is `uuidGenerateId()` from `@ingram-tech/nk-db/id`, already typed
11
+ // The mint is `uuidv7()` from `id758` (re-exported by `@ingram-tech/nk-db/id`), already typed
12
12
  // `Uuid`. Most rows need no mint at all: `uuid("id").primaryKey().default(sql`
13
13
  // `uuidv7()`)` lets the database do it, and the app only mints when it needs the
14
14
  // id *before* the insert (a client-chosen document PK it must also use as the
@@ -17,7 +17,7 @@
17
17
  // Deliberately not autofixable. The right replacement depends on what the value
18
18
  // is, and one of the answers is "leave it alone":
19
19
  //
20
- // - a stored id -> uuidGenerateId(), or drop it for the column default
20
+ // - a stored id -> uuidv7(), or drop it for the column default
21
21
  // - a bearer token / nonce -> keep crypto.randomUUID()
22
22
  //
23
23
  // v7 is the *wrong* choice for a secret. It spends 48 bits on a millisecond
@@ -77,7 +77,7 @@ const noCryptoRandomUuid = {
77
77
  },
78
78
  messages: {
79
79
  cryptoRandomUuid:
80
- "`crypto.randomUUID()` is UUIDv4; stored ids are UUIDv7. Mint with `uuidGenerateId()` from `@ingram-tech/nk-db/id`, or omit the id and let the `uuidv7()` column default apply. If this is a bearer token or nonce, keep v4 and add `// oxlint-disable-next-line nextkit/no-crypto-random-uuid -- <reason>`.",
80
+ "`crypto.randomUUID()` is UUIDv4; stored ids are UUIDv7. Mint with `uuidv7()` from `id758` (re-exported by `@ingram-tech/nk-db/id`), or omit the id and let the `uuidv7()` column default apply. If this is a bearer token or nonce, keep v4 and add `// oxlint-disable-next-line nextkit/no-crypto-random-uuid -- <reason>`.",
81
81
  },
82
82
  },
83
83
  create(context) {
@@ -0,0 +1,137 @@
1
+ // nextkit oxlint JS plugin rule: don't import from `node:crypto` what is
2
+ // already a global.
3
+ //
4
+ // Web Crypto is on `globalThis` in every runtime we ship to — Node (since 19,
5
+ // and nk-dev's floor is 22), the browser, and every edge/worker runtime. So
6
+ // `randomUUID`, `getRandomValues`, `subtle` and `webcrypto` are reachable as
7
+ // `crypto.randomUUID()`, `crypto.getRandomValues()`, `crypto.subtle` and
8
+ // `crypto` with no import at all.
9
+ //
10
+ // Importing them anyway costs something real: it pins the module to a Node-only
11
+ // runtime for a function it would have had regardless. A component, a shared
12
+ // helper or a route that could have run anywhere now can't, and the reason is
13
+ // invisible at the call site — the code reads identically either way. Two of
14
+ // these are not even different objects: `node:crypto`'s `subtle` and `webcrypto`
15
+ // are the very same references as `globalThis.crypto.subtle` and
16
+ // `globalThis.crypto`.
17
+ //
18
+ // nk-db already pays for this the hard way: its id codec is imported by Drizzle
19
+ // schemas, client components and edge runtimes, so `id.ts` is held to an empty
20
+ // import list by a test (`id.test.ts`, "isomorphic invariant") whose comment
21
+ // names `node:crypto` for randomness as the tempting one. That invariant was
22
+ // prose in one package; this rule is the mechanical version of it, fleet-wide.
23
+ //
24
+ // This is about the module boundary, not the algorithm. The rest of `node:crypto`
25
+ // — `createHash`, `createHmac`, `createPrivateKey`, `randomBytes`,
26
+ // `timingSafeEqual` — has no drop-in global (the Web Crypto equivalents live
27
+ // under `crypto.subtle` and are async), so those imports are correct and this
28
+ // rule leaves them alone. Trimming a redundant name off an import list is the
29
+ // common fix; the import disappears entirely only when nothing else was on it.
30
+ //
31
+ // Deliberately not autofixable. Deleting the specifier is the easy half — the
32
+ // call sites still have to become member expressions on the global, and a
33
+ // default or namespace import named `crypto` (which shadows the global it is
34
+ // standing in for) needs the whole file reread, not a mechanical edit.
35
+ //
36
+ // One case keeps the import: `node:crypto`'s `randomUUID` takes an options bag
37
+ // (`randomUUID({ disableEntropyCache: true })`) that Web Crypto's does not. If
38
+ // you need it, keep the import and say so:
39
+ //
40
+ // // oxlint-disable-next-line nextkit/no-redundant-node-crypto -- needs disableEntropyCache
41
+ //
42
+ // Only static `import` is checked, matching every other rule in this plugin.
43
+ // `require("node:crypto")` in a CommonJS script is out of scope.
44
+ //
45
+ // Note this overlaps by design with `nextkit/no-crypto-random-uuid`, which asks
46
+ // a different question about the same call: that rule is about v4-versus-v7 for
47
+ // a *stored id*, this one is about the module. A call site that justifiably
48
+ // keeps v4 — a bearer token, a nonce — silences that rule and should still be
49
+ // reaching for the global.
50
+
51
+ const NODE_CRYPTO_MODULES = new Set(["crypto", "node:crypto"]);
52
+
53
+ /** node:crypto exports that are already global, and what to reach for instead. */
54
+ const REDUNDANT_EXPORTS = new Map([
55
+ ["randomUUID", "crypto.randomUUID()"],
56
+ ["getRandomValues", "crypto.getRandomValues()"],
57
+ ["subtle", "crypto.subtle"],
58
+ ["webcrypto", "crypto"],
59
+ ]);
60
+
61
+ const noRedundantNodeCrypto = {
62
+ meta: {
63
+ type: "suggestion",
64
+ docs: {
65
+ description:
66
+ "Disallow importing node:crypto members that are already on the Web Crypto global",
67
+ },
68
+ messages: {
69
+ redundantImport:
70
+ "`{{name}}` from `{{module}}` is already global — use `{{replacement}}` and drop the import. Web Crypto is on globalThis in Node (>=19), the browser and every edge runtime, so importing it pins this module to Node for nothing. Keep the import only if you need a Node-specific signature, with `// oxlint-disable-next-line nextkit/no-redundant-node-crypto -- <reason>`.",
71
+ redundantMember:
72
+ "`{{local}}.{{name}}` is already global — use `{{replacement}}`. Web Crypto is on globalThis in Node (>=19), the browser and every edge runtime; reaching for it through the `{{module}}` namespace pins this module to Node for nothing.",
73
+ },
74
+ },
75
+ create(context) {
76
+ // Local names bound to the whole module (`import * as c` / `import c`),
77
+ // whose members we then check.
78
+ const namespaceNames = new Set();
79
+
80
+ return {
81
+ ImportDeclaration(node) {
82
+ const module = node.source.value;
83
+ if (!NODE_CRYPTO_MODULES.has(module)) return;
84
+
85
+ for (const specifier of node.specifiers) {
86
+ if (
87
+ specifier.type === "ImportNamespaceSpecifier" ||
88
+ specifier.type === "ImportDefaultSpecifier"
89
+ ) {
90
+ namespaceNames.add(specifier.local.name);
91
+ continue;
92
+ }
93
+ if (specifier.type !== "ImportSpecifier") continue;
94
+ if (specifier.imported.type !== "Identifier") continue;
95
+
96
+ const name = specifier.imported.name;
97
+ const replacement = REDUNDANT_EXPORTS.get(name);
98
+ if (!replacement) continue;
99
+
100
+ context.report({
101
+ node: specifier,
102
+ messageId: "redundantImport",
103
+ data: { name, module, replacement },
104
+ });
105
+ }
106
+ },
107
+ // `nodeCrypto.subtle` where `nodeCrypto` is the imported module. The
108
+ // import itself can be legitimate (it may also carry `createHash`), so
109
+ // the redundant part is this access, not the declaration.
110
+ MemberExpression(node) {
111
+ if (node.computed) return;
112
+ if (node.object.type !== "Identifier") return;
113
+ if (!namespaceNames.has(node.object.name)) return;
114
+ if (node.property.type !== "Identifier") return;
115
+
116
+ const replacement = REDUNDANT_EXPORTS.get(node.property.name);
117
+ if (!replacement) return;
118
+
119
+ context.report({
120
+ node,
121
+ messageId: "redundantMember",
122
+ data: {
123
+ local: node.object.name,
124
+ name: node.property.name,
125
+ replacement,
126
+ module: "node:crypto",
127
+ },
128
+ });
129
+ },
130
+ };
131
+ },
132
+ };
133
+
134
+ export default {
135
+ meta: { name: "nextkit" },
136
+ rules: { "no-redundant-node-crypto": noRedundantNodeCrypto },
137
+ };
@@ -3,6 +3,7 @@ import { cleanGeneratedArtifacts, onlyGeneratedTypeErrors } from "./artifacts.js
3
3
  import { toolDrift } from "./drift.js";
4
4
  import { FORMATTER } from "./formatter.js";
5
5
  import { hasKnipConfig, runKnip } from "./knip.js";
6
+ import { checkSeal } from "./migrations.js";
6
7
  import { run, runCapture, writeThrough } from "./run.js";
7
8
 
8
9
  /** `nk lint [...]` — oxlint, with extra args passed through (e.g. `--fix`). */
@@ -33,8 +34,19 @@ export function check() {
33
34
  " → add `@./node_modules/@ingram-tech/nk-dev/guide.md` to your CLAUDE.md (or run `nk init`).",
34
35
  );
35
36
  }
37
+ // Applied migrations are immutable. A no-op on sites without a `drizzle/`
38
+ // journal, so it costs non-database sites nothing.
39
+ const seal = checkSeal();
40
+ if (!seal.ok) {
41
+ console.error(`nk check: ${seal.reason}`);
42
+ console.error(
43
+ " → restore the file and add a new migration, or run `nk migrations` to seal a newly generated one.",
44
+ );
45
+ }
36
46
  warnToolDrift();
37
- process.exit(lintFailed || fmtFailed || knipFailed || !guide.ok ? 1 : 0);
47
+ process.exit(
48
+ lintFailed || fmtFailed || knipFailed || !guide.ok || !seal.ok ? 1 : 0,
49
+ );
38
50
  }
39
51
 
40
52
  /** Non-fatal: surface superseded deps so drift doesn't silently re-accumulate. */
package/oxlintrc.json CHANGED
@@ -13,6 +13,7 @@
13
13
  "nextkit/t-requires-values": "error",
14
14
  "nextkit/t-no-positional-args": "error",
15
15
  "nextkit/no-crypto-random-uuid": "warn",
16
+ "nextkit/no-redundant-node-crypto": "warn",
16
17
  "nextkit/satori-css": "warn",
17
18
  "no-unused-vars": "warn",
18
19
  "typescript/no-non-null-assertion": "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-dev",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "The nextkit dev toolchain in one package: the `nk` CLI plus shared oxlint/oxfmt, TypeScript, and Vitest config, the format-on-commit hook, and the AI agent guide. `nk init` scaffolds a site to use it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,14 +45,14 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
- "@ast-grep/cli": "^0.45.0",
48
+ "@ast-grep/cli": "^0.45.1",
49
49
  "@testing-library/dom": "^10.4.1",
50
- "@testing-library/jest-dom": "^7.0.0",
50
+ "@testing-library/jest-dom": "^7.0.1",
51
51
  "@typescript/native": "npm:typescript@^7.0.2",
52
52
  "jsdom": "^30.0.1",
53
- "knip": "^6.31.0",
54
- "oxfmt": "^0.61.0",
55
- "oxlint": "^1.76.0",
53
+ "knip": "^6.32.2",
54
+ "oxfmt": "^0.63.0",
55
+ "oxlint": "^1.78.0",
56
56
  "typescript": "npm:@typescript/typescript6@^6.0.2",
57
57
  "vitest": "^4.1.10"
58
58
  },