@ingram-tech/nk-dev 0.2.5 → 0.4.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/README.md CHANGED
@@ -3,7 +3,7 @@
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` / `check` / `type-check` / `build`);
6
+ - the **`nk` CLI** (`nk dev` / `format` / `lint` / `knip` / `check` / `type-check` / `test` / `build`, plus `nk doctor`);
7
7
  - the shared **oxlint + oxfmt**, **TypeScript**, and **Vitest** config;
8
8
  - **knip** (unused dependency / export / file detection), bundled and run by `nk check`;
9
9
  - the **oxfmt format-on-commit** git hook (`nextkit-format-staged`);
@@ -46,10 +46,6 @@ Everything is `extends`-based, so the house config is enforced by default but
46
46
  overridable — layer your own rules on top, or replace a stub entirely (e.g. drop
47
47
  in a `biome.json` instead of the oxlint/oxfmt stubs).
48
48
 
49
- Already on the old split packages or `@ingram-tech/biome-config`? Run the
50
- codemod — see
51
- [`oxlint-migration.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/oxlint-migration.md).
52
-
53
49
  ## The `nk` command
54
50
 
55
51
  Point your package.json scripts at it:
@@ -62,6 +58,7 @@ Point your package.json scripts at it:
62
58
  "lint": "nk lint",
63
59
  "check": "nk check",
64
60
  "type-check": "nk type-check",
61
+ "test": "nk test",
65
62
  "build": "nk build"
66
63
  }
67
64
  }
@@ -81,6 +78,9 @@ tsc), so versions stay under each site's control — nk just orchestrates.
81
78
 
82
79
  - **`nk init`** — scaffold this project to use nextkit (see above). Idempotent:
83
80
  skips files that already exist.
81
+ - **`nk doctor [--fix]`** — report drift from the canonical nk-dev toolchain
82
+ (superseded deps, config `extends`, package.json scripts, the agent-guide
83
+ import, a stale `.prettierignore`); `--fix` applies the auto-fixable findings.
84
84
  - **`nk dev`** — start the Next dev server on the golden-path local database
85
85
  (see [`db-package.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/db-package.md)):
86
86
  - **PGlite** — if `@ingram-tech/nk-db`'s `nk-pglite-dev` bin resolves, hand off
@@ -88,13 +88,15 @@ tsc), so versions stay under each site's control — nk just orchestrates.
88
88
  `DATABASE_URL`, then `next dev --turbopack`. No Docker, no daemon.
89
89
  - **Plain** — otherwise just `next dev` (static/marketing sites with no DB).
90
90
  - **`nk format` / `nk format --check`** — formats code (JS/TS/JSON/CSS) with
91
- oxfmt and SQL with Prettier. `--check` verifies without writing (CI).
91
+ oxfmt. `--check` verifies without writing (CI). SQL isn't formatted — it's
92
+ generated (drizzle migrations, `pg_dump` baselines, pglite fixtures).
92
93
  - **`nk lint`** — `oxlint`.
93
94
  - **`nk knip`** — `knip` (unused dependencies / exports / files).
94
- - **`nk check`** — `oxlint` + `oxfmt --check` + SQL format verification + `knip`
95
- (only when the repo has a knip config) + the agent-guide import gate. The CI
96
- gate; runs every checker and reports them all before failing.
95
+ - **`nk check`** — `oxlint` + `oxfmt --check` + `knip` (only when the repo has a
96
+ knip config) + the agent-guide import gate. The CI gate; runs every checker and
97
+ reports them all before failing.
97
98
  - **`nk type-check`** — `next typegen && tsc --noEmit`.
99
+ - **`nk test [...]`** — `vitest run`, extra args passed through.
98
100
  - **`nk build [...]`** — `next build`, extra args passed through.
99
101
 
100
102
  ## Exports
@@ -110,11 +112,11 @@ tsc), so versions stay under each site's control — nk just orchestrates.
110
112
  "@ingram-tech/nk-dev/guide.md" // the AI agent guide
111
113
  ```
112
114
 
113
- ## Why Prettier for SQL?
115
+ ## Why no SQL formatting?
114
116
 
115
- oxfmt is the formatter for code and stays that wayPrettier is never used for
116
- JS/TS. But oxfmt can't format SQL, so nk-dev bundles `prettier` +
117
- `prettier-plugin-sql` **as its own dependencies** and uses them only for `.sql`
118
- files. Prettier therefore never lands in any app's `package.json`. A site's own
119
- `.prettierrc` / package.json `"prettier"` settings are honored if present;
120
- otherwise nk defaults to tabs + the Postgres dialect.
117
+ oxfmt is the formatter for code, and it can't format SQL but nk-dev doesn't
118
+ format SQL at all. The SQL in our repos is ~entirely generated (drizzle
119
+ migrations, `pg_dump` baselines, pglite fixtures), so formatting it only churned
120
+ generated files and crashed on psql directives (`\restrict`) for no gain. nk-dev
121
+ therefore carries no `prettier` dependency; if a site has a leftover
122
+ `.prettierignore`, `nk doctor --fix` removes it.
package/bin/nk.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { dev } from "../lib/dev.js";
3
+ import { doctor } from "../lib/doctor.js";
3
4
  import { format } from "../lib/format.js";
4
5
  import { init } from "../lib/init.js";
5
6
  import { knip } from "../lib/knip.js";
6
- import { build, check, lint, typeCheck } from "../lib/passthrough.js";
7
+ import { build, check, lint, test, typeCheck } from "../lib/passthrough.js";
7
8
 
8
9
  const USAGE = `nk — the nextkit CLI
9
10
 
@@ -13,18 +14,21 @@ Commands:
13
14
  init Scaffold this project to use nextkit: writes the oxlint /
14
15
  oxfmt / TypeScript / Vitest config, the format-on-commit
15
16
  hook, and the agent-guide import. Skips files that exist.
17
+ doctor [--fix] Report drift from the canonical nk-dev toolchain (scripts,
18
+ superseded deps, config extends, guide import); --fix applies.
16
19
  dev Start the Next dev server (Turbopack). Boots local PGlite
17
20
  first when @ingram-tech/nk-db is installed (no Docker).
18
- format [--check] Format code with oxfmt and SQL with Prettier. --check
19
- verifies without writing (for CI).
21
+ format [--check] Format code with oxfmt. --check verifies without writing.
20
22
  lint Lint with oxlint.
21
23
  knip Find unused dependencies / exports / files with knip.
22
- check The CI gate: lint + format verify + SQL + knip (when
23
- configured) + the agent-guide import gate.
24
+ check The CI gate: lint + format verify + knip (when configured)
25
+ + the agent-guide import gate.
24
26
  type-check next typegen && tsc --noEmit.
27
+ test [...] vitest run (extra args passed through).
25
28
  build [...] next build (extra args passed through).
26
29
 
27
- Code formats with oxfmt and lints with oxlint; SQL formats with Prettier.`;
30
+ Code formats with oxfmt and lints with oxlint. SQL is not formatted (it's
31
+ generated); nk-dev carries no Prettier.`;
28
32
 
29
33
  const [cmd, ...rest] = process.argv.slice(2);
30
34
 
@@ -32,11 +36,14 @@ switch (cmd) {
32
36
  case "init":
33
37
  init();
34
38
  break;
39
+ case "doctor":
40
+ doctor(rest);
41
+ break;
35
42
  case "dev":
36
43
  dev(rest);
37
44
  break;
38
45
  case "format":
39
- await format({ check: rest.includes("--check") });
46
+ format({ check: rest.includes("--check") });
40
47
  break;
41
48
  case "lint":
42
49
  lint();
@@ -45,11 +52,14 @@ switch (cmd) {
45
52
  knip(rest);
46
53
  break;
47
54
  case "check":
48
- await check();
55
+ check();
49
56
  break;
50
57
  case "type-check":
51
58
  typeCheck();
52
59
  break;
60
+ case "test":
61
+ test(rest);
62
+ break;
53
63
  case "build":
54
64
  build(rest);
55
65
  break;
package/guide.md CHANGED
@@ -14,9 +14,11 @@ package. Stay a thin, standard Next.js app (bun · oxlint + oxfmt · strict TS).
14
14
  `as`-cast it.** Every `/api` route and webhook handler takes untrusted input;
15
15
  an `as` cast is a lie the type-checker can't catch at runtime.
16
16
  - Format/lint with **oxlint + oxfmt** via `nk` (`@ingram-tech/nk-dev`); don't
17
- reintroduce ESLint, nor Prettier for code (`nk` uses Prettier only for SQL,
18
- which oxfmt can't format). `nk` is optional convenience that only orchestrates
19
- the standard tools the site must stay buildable with plain `next build` / `next dev`.
17
+ reintroduce ESLint or Prettier (SQL isn't formatted it's generated). nk-dev
18
+ owns the toolchain: don't re-declare `oxfmt`/`oxlint`/`typescript` or the
19
+ retired `@ingram-tech/{oxlint,typescript}-config` — `nk doctor` flags the drift.
20
+ `nk` is optional convenience that only orchestrates the standard tools — the
21
+ site must stay buildable with plain `next build` / `next dev`.
20
22
 
21
23
  ## Route & URL conventions
22
24
 
@@ -81,6 +83,6 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
81
83
  - `@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
82
84
  - `@ingram-tech/bot-protection` — invisible form protection (honeypot + timing + Vercel BotID)
83
85
  - `@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
84
- - `@ingram-tech/nk-dev` — the whole dev toolchain in one devDependency: the `nk` command (`nk dev` boots local PGlite via `@ingram-tech/nk-db` if installed, then Next; plus `nk format` / `lint` / `knip` / `check` / `type-check` / `build`), the shared oxlint + oxfmt / TypeScript / Vitest config, knip, the oxfmt format-on-commit hook, and this guide. `nk check` runs every fast checker (oxlint, oxfmt, SQL, knip) in one gate. `nk init` scaffolds a site to use it all.
86
+ - `@ingram-tech/nk-dev` — the whole dev toolchain in one devDependency: the `nk` command (`nk dev` boots local PGlite via `@ingram-tech/nk-db` if installed, then Next; plus `nk format` / `lint` / `knip` / `check` / `type-check` / `test` / `build`), the shared oxlint + oxfmt / TypeScript / Vitest config, knip, the oxfmt format-on-commit hook, and this guide. `nk check` runs every fast checker (oxlint, oxfmt, knip) in one gate; `nk doctor --fix` reconciles a site back to the canonical toolchain. `nk init` scaffolds a site to use it all.
85
87
 
86
88
  For detail on any package, read its README in `node_modules/@ingram-tech/<pkg>/`.
package/lib/doctor.js ADDED
@@ -0,0 +1,269 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { SUPERSEDED_DEPS } from "./drift.js";
4
+
5
+ // The canonical script → command mapping for a site on the nk-dev toolchain.
6
+ // The key is matched loosely (`type-check` and `typecheck` are both accepted);
7
+ // the value is what the script should run.
8
+ const CANONICAL_SCRIPTS = {
9
+ lint: "nk lint",
10
+ format: "nk format",
11
+ check: "nk check",
12
+ "type-check": "nk type-check",
13
+ };
14
+
15
+ const GUIDE_IMPORT = "@./node_modules/@ingram-tech/nk-dev/guide.md";
16
+ const OXLINTRC_EXTENDS = "./node_modules/@ingram-tech/nk-dev/oxlintrc.json";
17
+ const TSCONFIG_EXTENDS = "@ingram-tech/nk-dev/tsconfig/nextjs.json";
18
+
19
+ /** Read + parse a JSON file, or null if absent/unparseable. */
20
+ function readJson(file) {
21
+ try {
22
+ return JSON.parse(readFileSync(file, "utf8"));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** Write JSON in the house style (tabs, trailing newline). */
29
+ function writeJson(file, value) {
30
+ writeFileSync(file, `${JSON.stringify(value, null, "\t")}\n`);
31
+ }
32
+
33
+ /**
34
+ * Collect drift findings for the repo at `cwd`. Each finding is
35
+ * `{ id, level, message, fix }`, where `fix(cwd)` applies the correction and
36
+ * returns a short past-tense note. `level` is "error" (breaks the model) or
37
+ * "warn" (cosmetic / cleanup).
38
+ */
39
+ function findings(cwd) {
40
+ const out = [];
41
+ const pkgPath = resolve(cwd, "package.json");
42
+ const pkg = readJson(pkgPath);
43
+ if (!pkg) return out; // not a package — nothing to reconcile
44
+
45
+ // 1. Scripts point at `nk`. Accept either `type-check` or `typecheck` key.
46
+ const scripts = pkg.scripts ?? {};
47
+ for (const [name, wanted] of Object.entries(CANONICAL_SCRIPTS)) {
48
+ const key =
49
+ name === "type-check" &&
50
+ scripts["typecheck"] !== undefined &&
51
+ scripts["type-check"] === undefined
52
+ ? "typecheck"
53
+ : name;
54
+ const current = scripts[key];
55
+ if (current === wanted) continue;
56
+ // Only flag `check` as missing if the repo has scripts at all; a repo
57
+ // truly without a check script still wants one on the nk model.
58
+ out.push({
59
+ id: `script:${key}`,
60
+ level: "error",
61
+ message:
62
+ current === undefined
63
+ ? `missing \`${key}\` script (want "${wanted}")`
64
+ : `\`${key}\` script is "${current}" (want "${wanted}")`,
65
+ fix: (dir) => {
66
+ const p = resolve(dir, "package.json");
67
+ const j = readJson(p);
68
+ j.scripts ??= {};
69
+ j.scripts[key] = wanted;
70
+ writeJson(p, j);
71
+ return `set \`${key}\` → "${wanted}"`;
72
+ },
73
+ });
74
+ }
75
+
76
+ // 2. Superseded deps still declared.
77
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
78
+ if (deps["@ingram-tech/nk-dev"]) {
79
+ const superseded = SUPERSEDED_DEPS.filter((d) => d in deps);
80
+ for (const dep of superseded) {
81
+ out.push({
82
+ id: `dep:${dep}`,
83
+ level: "error",
84
+ message: `depends on \`${dep}\` — superseded by @ingram-tech/nk-dev`,
85
+ fix: (dir) => {
86
+ const p = resolve(dir, "package.json");
87
+ const j = readJson(p);
88
+ delete j.dependencies?.[dep];
89
+ delete j.devDependencies?.[dep];
90
+ writeJson(p, j);
91
+ return `removed \`${dep}\``;
92
+ },
93
+ });
94
+ }
95
+ } else {
96
+ out.push({
97
+ id: "dep:nk-dev-missing",
98
+ level: "warn",
99
+ message:
100
+ "does not depend on @ingram-tech/nk-dev (run `nk init` to adopt the toolchain)",
101
+ });
102
+ }
103
+
104
+ // 3. .oxlintrc.json extends nk-dev (not the retired oxlint-config).
105
+ const oxPath = resolve(cwd, ".oxlintrc.json");
106
+ const ox = readJson(oxPath);
107
+ if (ox) {
108
+ const ext = [].concat(ox.extends ?? []);
109
+ if (!ext.includes(OXLINTRC_EXTENDS)) {
110
+ out.push({
111
+ id: "oxlintrc:extends",
112
+ level: "error",
113
+ message: `.oxlintrc.json does not extend nk-dev's config (extends: ${JSON.stringify(ox.extends ?? null)})`,
114
+ fix: (dir) => {
115
+ const p = resolve(dir, ".oxlintrc.json");
116
+ const j = readJson(p);
117
+ // Keep any non-superseded extends (e.g. a local tier-b tweak
118
+ // living outside oxlint-config), drop oxlint-config, ensure ours.
119
+ const kept = []
120
+ .concat(j.extends ?? [])
121
+ .filter(
122
+ (e) =>
123
+ !String(e).includes("oxlint-config") &&
124
+ e !== OXLINTRC_EXTENDS,
125
+ );
126
+ j.extends = [OXLINTRC_EXTENDS, ...kept];
127
+ j.$schema ??= "./node_modules/oxlint/configuration_schema.json";
128
+ writeJson(p, j);
129
+ return "repointed .oxlintrc.json extends → nk-dev";
130
+ },
131
+ });
132
+ }
133
+ }
134
+
135
+ // 4. tsconfig.json extends nk-dev's tsconfig (not the retired typescript-config).
136
+ const tsPath = resolve(cwd, "tsconfig.json");
137
+ const ts = readJson(tsPath);
138
+ if (ts && String(ts.extends ?? "").includes("typescript-config")) {
139
+ out.push({
140
+ id: "tsconfig:extends",
141
+ level: "error",
142
+ message: `tsconfig.json extends "${ts.extends}" — use nk-dev's tsconfig`,
143
+ fix: (dir) => {
144
+ const p = resolve(dir, "tsconfig.json");
145
+ const j = readJson(p);
146
+ j.extends = TSCONFIG_EXTENDS;
147
+ writeJson(p, j);
148
+ return `repointed tsconfig.json extends → ${TSCONFIG_EXTENDS}`;
149
+ },
150
+ });
151
+ }
152
+
153
+ // 5. CLAUDE.md imports the shared guide.
154
+ const claudePath = resolve(cwd, "CLAUDE.md");
155
+ if (deps["@ingram-tech/nk-dev"]) {
156
+ const claude = existsSync(claudePath) ? readFileSync(claudePath, "utf8") : null;
157
+ if (claude === null || !/@\S*nk-dev\/guide\.md/.test(claude)) {
158
+ out.push({
159
+ id: "claude:guide-import",
160
+ level: "error",
161
+ message:
162
+ claude === null
163
+ ? "no CLAUDE.md importing the nk-dev guide"
164
+ : "CLAUDE.md does not @import the nk-dev guide",
165
+ fix: (dir) => {
166
+ const p = resolve(dir, "CLAUDE.md");
167
+ const body = existsSync(p)
168
+ ? readFileSync(p, "utf8")
169
+ : "# Project\n";
170
+ writeFileSync(
171
+ p,
172
+ `${body.replace(/\n*$/, "")}\n\n${GUIDE_IMPORT}\n`,
173
+ );
174
+ return "added the guide import to CLAUDE.md";
175
+ },
176
+ });
177
+ }
178
+ }
179
+
180
+ // 6. knip.json ignoreDependencies referencing superseded packages (stale).
181
+ const knipPath = resolve(cwd, "knip.json");
182
+ const knip = readJson(knipPath);
183
+ if (knip && Array.isArray(knip.ignoreDependencies)) {
184
+ const stale = knip.ignoreDependencies.filter((d) =>
185
+ SUPERSEDED_DEPS.includes(d),
186
+ );
187
+ if (stale.length) {
188
+ out.push({
189
+ id: "knip:stale-ignores",
190
+ level: "warn",
191
+ message: `knip.json ignoreDependencies references removed packages: ${stale.join(", ")}`,
192
+ fix: (dir) => {
193
+ const p = resolve(dir, "knip.json");
194
+ const j = readJson(p);
195
+ j.ignoreDependencies = j.ignoreDependencies.filter(
196
+ (d) => !SUPERSEDED_DEPS.includes(d),
197
+ );
198
+ writeJson(p, j);
199
+ return `pruned stale knip ignoreDependencies: ${stale.join(", ")}`;
200
+ },
201
+ });
202
+ }
203
+ }
204
+
205
+ // 7. .prettierignore is now dead weight (nk no longer formats SQL).
206
+ const prettierIgnore = resolve(cwd, ".prettierignore");
207
+ if (existsSync(prettierIgnore)) {
208
+ out.push({
209
+ id: "prettierignore",
210
+ level: "warn",
211
+ message:
212
+ ".prettierignore is unused (nk no longer runs Prettier) — remove it",
213
+ fix: (dir) => {
214
+ rmSync(resolve(dir, ".prettierignore"));
215
+ return "removed .prettierignore";
216
+ },
217
+ });
218
+ }
219
+
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * `nk doctor [--fix]` — report drift from the canonical nk-dev model (scripts,
225
+ * dependencies, oxlint/tsconfig extends, the CLAUDE.md guide import, stale knip
226
+ * ignores, a dead .prettierignore). With `--fix`, apply every auto-fixable
227
+ * finding, then remind to reinstall.
228
+ */
229
+ export function doctor(args = []) {
230
+ const fix = args.includes("--fix");
231
+ const cwd = process.cwd();
232
+ const results = findings(cwd);
233
+
234
+ if (results.length === 0) {
235
+ console.log("nk doctor: ✓ on the canonical nk-dev toolchain — no drift.");
236
+ process.exit(0);
237
+ }
238
+
239
+ if (!fix) {
240
+ const errors = results.filter((r) => r.level === "error").length;
241
+ console.log(`nk doctor: ${results.length} finding(s):\n`);
242
+ for (const r of results) {
243
+ const mark = r.level === "error" ? "✗" : "•";
244
+ console.log(` ${mark} ${r.message}`);
245
+ }
246
+ console.log(
247
+ `\n Run \`nk doctor --fix\` to apply the ${results.filter((r) => r.fix).length} auto-fixable one(s).`,
248
+ );
249
+ // Exit non-zero only when there are model-breaking findings, so `nk
250
+ // doctor` can gate CI while cosmetic warnings don't.
251
+ process.exit(errors > 0 ? 1 : 0);
252
+ }
253
+
254
+ let applied = 0;
255
+ let touchedDeps = false;
256
+ for (const r of results) {
257
+ if (!r.fix) {
258
+ console.log(` – ${r.message} (manual)`);
259
+ continue;
260
+ }
261
+ const note = r.fix(cwd);
262
+ console.log(` ✓ ${note}`);
263
+ applied++;
264
+ if (r.id.startsWith("dep:")) touchedDeps = true;
265
+ }
266
+ console.log(`\nnk doctor: applied ${applied} fix(es).`);
267
+ if (touchedDeps) console.log(" → run `bun install` to sync the lockfile.");
268
+ process.exit(0);
269
+ }
package/lib/drift.js ADDED
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /**
5
+ * Packages whose job `@ingram-tech/nk-dev` now owns — either because it bundles
6
+ * the tool (oxfmt, oxlint) or because it superseded a standalone shared-config /
7
+ * CLI / hooks package. A site on the nk-dev toolchain shouldn't re-declare any
8
+ * of these: doing so either splits a tool's version across two pins (the exact
9
+ * drift nk-dev centralizes away) or keeps a dead dependency on a retired package.
10
+ */
11
+ export const SUPERSEDED_DEPS = [
12
+ "oxfmt",
13
+ "oxlint",
14
+ "prettier",
15
+ "prettier-plugin-sql",
16
+ "@ingram-tech/oxlint-config",
17
+ "@ingram-tech/typescript-config",
18
+ "@ingram-tech/nk-cli",
19
+ "@ingram-tech/git-hooks",
20
+ ];
21
+
22
+ /**
23
+ * The superseded packages this repo still declares. Empty when the repo is
24
+ * clean, or when it doesn't depend on nk-dev at all (nothing to enforce).
25
+ */
26
+ export function toolDrift(cwd = process.cwd()) {
27
+ let pkg;
28
+ try {
29
+ pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf8"));
30
+ } catch {
31
+ return [];
32
+ }
33
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
34
+ if (!deps["@ingram-tech/nk-dev"]) return [];
35
+ return SUPERSEDED_DEPS.filter((d) => d in deps);
36
+ }
package/lib/format.js CHANGED
@@ -1,123 +1,17 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { createRequire } from "node:module";
4
- import { join, relative } from "node:path";
5
1
  import { FORMATTER } from "./formatter.js";
6
2
  import { run } from "./run.js";
7
3
 
8
- const require = createRequire(import.meta.url);
9
-
10
- // House SQL defaults, used only when the site has no Prettier config of its own
11
- // (matches the house tabs/4/88 style + the PostgreSQL dialect; without the
12
- // explicit widths Prettier falls back to 80/2).
13
- const SQL_DEFAULTS = {
14
- useTabs: true,
15
- tabWidth: 4,
16
- printWidth: 88,
17
- language: "postgresql",
18
- };
19
-
20
4
  /**
21
5
  * `nk format` / `nk format --check`.
22
6
  *
23
- * Code (JS/TS/JSON/CSS) goes through oxfmt; SQL goes through Prettier, which
24
- * oxfmt can't format. Prettier + prettier-plugin-sql are bundled with nk-dev,
25
- * so they never appear in any app's dependencies the "no Prettier for code"
26
- * rule still holds, it's just the one file type oxfmt lacks.
7
+ * Code (JS/TS/JSON/CSS) goes through oxfmt the one formatter nk owns. SQL is
8
+ * intentionally NOT formatted: it's almost entirely generated (drizzle
9
+ * migrations, `pg_dump` baselines, pglite fixtures), so a SQL formatter only
10
+ * churns generated files and chokes on psql directives (`\restrict`, …) for no
11
+ * real gain. Hand-written SQL is rare and fine unformatted. This is why nk-dev
12
+ * carries no `prettier` dependency.
27
13
  */
28
- export async function format({ check }) {
14
+ export function format({ check }) {
29
15
  const op = check ? FORMATTER.checkFormat : FORMATTER.write;
30
- const code = run(op[0], op[1]);
31
- if (code !== 0) process.exitCode = code;
32
-
33
- if (await formatSql({ check })) process.exitCode = 1;
34
- }
35
-
36
- /**
37
- * Format (or, with `check`, verify) every tracked `.sql` file via Prettier.
38
- * Returns true when a check found unformatted files — the caller owns the exit
39
- * code (inferring failure from the `process.exitCode` global misattributes any
40
- * earlier failure to SQL).
41
- */
42
- export async function formatSql({ check }) {
43
- const files = sqlFiles();
44
- if (files.length === 0) return false;
45
-
46
- const prettier = require("prettier");
47
- const pluginPath = require.resolve("prettier-plugin-sql");
48
-
49
- let unformatted = 0;
50
- let written = 0;
51
- for (const file of files) {
52
- // git ls-files lists tracked files deleted from the worktree without
53
- // `git rm`; reading one would throw an unhandled ENOENT.
54
- if (!existsSync(file)) continue;
55
- const source = readFileSync(file, "utf8");
56
- // The site's own .prettierrc / package.json "prettier" wins over our
57
- // defaults; we always inject the bundled SQL plugin + parser.
58
- const siteConfig = (await prettier.resolveConfig(file)) ?? {};
59
- const options = {
60
- ...SQL_DEFAULTS,
61
- ...siteConfig,
62
- parser: "sql",
63
- plugins: [
64
- pluginPath,
65
- ...(siteConfig.plugins ?? []).filter(
66
- (p) => !String(p).includes("prettier-plugin-sql"),
67
- ),
68
- ],
69
- };
70
-
71
- if (check) {
72
- if (!(await prettier.check(source, options))) {
73
- unformatted++;
74
- console.error(` ${relative(process.cwd(), file)}`);
75
- }
76
- } else {
77
- const out = await prettier.format(source, options);
78
- if (out !== source) {
79
- writeFileSync(file, out);
80
- written++;
81
- }
82
- }
83
- }
84
-
85
- if (check && unformatted > 0) {
86
- console.error(
87
- `nk: ${unformatted} SQL file(s) need formatting — run \`nk format\`.`,
88
- );
89
- return true;
90
- }
91
- if (!check && written > 0) {
92
- console.log(`nk: formatted ${written} SQL file(s).`);
93
- }
94
- return false;
95
- }
96
-
97
- /** Tracked + untracked-not-ignored `.sql` files; falls back to an fs walk. */
98
- function sqlFiles() {
99
- // -z: NUL-separated, unquoted — the default output octal-escapes non-ASCII
100
- // filenames, which then match no real path.
101
- const res = spawnSync(
102
- "git",
103
- ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "*.sql"],
104
- { encoding: "utf8" },
105
- );
106
- if (res.status === 0) {
107
- return res.stdout.split("\0").filter(Boolean);
108
- }
109
- return walkSql(process.cwd());
110
- }
111
-
112
- const SKIP_DIRS = new Set(["node_modules", ".next", ".git", "dist", "build"]);
113
-
114
- function walkSql(dir, out = []) {
115
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
116
- if (entry.isDirectory()) {
117
- if (!SKIP_DIRS.has(entry.name)) walkSql(join(dir, entry.name), out);
118
- } else if (entry.name.endsWith(".sql")) {
119
- out.push(join(dir, entry.name));
120
- }
121
- }
122
- return out;
16
+ process.exit(run(op[0], op[1]));
123
17
  }
@@ -0,0 +1,121 @@
1
+ // nextkit oxlint JS plugin: catch Radix-era props left on Base UI
2
+ // (@base-ui/react) shadcn wrappers that silently do nothing post-migration.
3
+ //
4
+ // The trap: a Radix prop whose name is also a valid native DOM attribute is
5
+ // accepted by Base UI's wrapper prop types (they extend the rendered element's
6
+ // DOM props) and then dropped at runtime. `onSelect` on a menu item is the
7
+ // canonical case: it type-checks as the DOM text-selection handler on the
8
+ // rendered <div>, so tsc stays silent, and it never fires on click. The menu
9
+ // action just does nothing. Base UI's Menu.Item activates via `onClick`.
10
+ //
11
+ // tsc CANNOT catch this class (the prop is a legitimate DOM attribute), which
12
+ // is exactly why it needs a lint rule. Note the mirror image: non-DOM Radix
13
+ // renames (asChild -> render, forceMount -> keepMounted, onValueCommit ->
14
+ // onValueCommitted, delayDuration -> delay, Accordion type -> openMultiple) are
15
+ // NOT listed here because they are not valid DOM attributes, so tsc already
16
+ // errors on them. Only add an entry when the old prop is a valid DOM attribute
17
+ // on the element the wrapper renders (i.e. genuinely silent). Otherwise you add
18
+ // noise that duplicates the type-checker.
19
+ //
20
+ // Matching is by the shadcn wrapper's JSX element name, which is stable across
21
+ // nextkit sites. Aliased imports (rare) are not matched; that is an accepted
22
+ // limitation, not a correctness hole (the type-checker still guards renames).
23
+ //
24
+ // The same component names exist in Radix-based shadcn, where `onSelect` is the
25
+ // CORRECT API. So the rule only activates for projects that actually depend on
26
+ // @base-ui/react; on Radix (or UI-less) sites it is inert. This lets the shared
27
+ // nextkit ruleset enable it as an error fleet-wide without false positives.
28
+
29
+ import { existsSync, readFileSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+
32
+ const baseUiCache = new Map();
33
+
34
+ const pkgDeclaresBaseUi = (pkgPath) => {
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
37
+ return Boolean(
38
+ pkg.dependencies?.["@base-ui/react"] ||
39
+ pkg.devDependencies?.["@base-ui/react"] ||
40
+ pkg.peerDependencies?.["@base-ui/react"],
41
+ );
42
+ } catch {
43
+ return false;
44
+ }
45
+ };
46
+
47
+ // Walk up to the nearest package.json and report whether it declares
48
+ // @base-ui/react, memoizing every directory visited so a lint run pays the fs
49
+ // cost once per project subtree.
50
+ const projectUsesBaseUi = (fromDir) => {
51
+ const seen = [];
52
+ let dir = fromDir;
53
+ let result = null;
54
+ while (result === null) {
55
+ if (baseUiCache.has(dir)) {
56
+ result = baseUiCache.get(dir);
57
+ } else {
58
+ seen.push(dir);
59
+ const pkgPath = join(dir, "package.json");
60
+ const parent = dirname(dir);
61
+ if (existsSync(pkgPath)) result = pkgDeclaresBaseUi(pkgPath);
62
+ else if (parent === dir) result = false;
63
+ else dir = parent;
64
+ }
65
+ }
66
+ for (const d of seen) baseUiCache.set(d, result);
67
+ return result;
68
+ };
69
+
70
+ // component name -> { bannedProp: replacementProp }
71
+ const BANNED = {
72
+ // Base UI Menu.Item (and its checkbox/radio variants) activate via onClick.
73
+ DropdownMenuItem: { onSelect: "onClick" },
74
+ DropdownMenuCheckboxItem: { onSelect: "onClick" },
75
+ DropdownMenuRadioItem: { onSelect: "onClick" },
76
+ ContextMenuItem: { onSelect: "onClick" },
77
+ ContextMenuCheckboxItem: { onSelect: "onClick" },
78
+ ContextMenuRadioItem: { onSelect: "onClick" },
79
+ MenubarItem: { onSelect: "onClick" },
80
+ MenubarCheckboxItem: { onSelect: "onClick" },
81
+ MenubarRadioItem: { onSelect: "onClick" },
82
+ };
83
+
84
+ const elementNameOf = (attributeNode) => {
85
+ const opening = attributeNode.parent;
86
+ if (!opening || opening.type !== "JSXOpeningElement") return null;
87
+ const name = opening.name;
88
+ return name && name.type === "JSXIdentifier" ? name.name : null;
89
+ };
90
+
91
+ const noRadixPropsOnBaseUi = {
92
+ meta: {
93
+ type: "problem",
94
+ docs: {
95
+ description:
96
+ "Disallow Radix-era props that Base UI wrappers silently ignore",
97
+ },
98
+ },
99
+ create(context) {
100
+ const filename = context.physicalFilename || context.filename || "";
101
+ if (!filename || !projectUsesBaseUi(dirname(filename))) return {};
102
+ return {
103
+ JSXAttribute(node) {
104
+ if (!node.name || node.name.type !== "JSXIdentifier") return;
105
+ const banned = BANNED[elementNameOf(node)];
106
+ if (!banned) return;
107
+ const replacement = banned[node.name.name];
108
+ if (!replacement) return;
109
+ context.report({
110
+ node: node.name,
111
+ message: `\`${node.name.name}\` does nothing on this Base UI component and is silently dropped at runtime. Use \`${replacement}\` instead (a Radix -> Base UI leftover the type-checker cannot catch).`,
112
+ });
113
+ },
114
+ };
115
+ },
116
+ };
117
+
118
+ export default {
119
+ meta: { name: "nextkit" },
120
+ rules: { "no-radix-props-on-base-ui": noRadixPropsOnBaseUi },
121
+ };
@@ -1,5 +1,5 @@
1
1
  import { checkAgentGuideImport } from "./agent-guide.js";
2
- import { formatSql } from "./format.js";
2
+ import { toolDrift } from "./drift.js";
3
3
  import { FORMATTER } from "./formatter.js";
4
4
  import { hasKnipConfig, runKnip } from "./knip.js";
5
5
  import { run } from "./run.js";
@@ -11,15 +11,15 @@ export function lint() {
11
11
 
12
12
  /**
13
13
  * `nk check` — the CI gate. Runs every fast checker and reports them all before
14
- * failing: oxlint, oxfmt (format), SQL format, knip (when configured), and the
15
- * agent-guide import gate.
14
+ * failing: oxlint, oxfmt (format), knip (when configured), and the agent-guide
15
+ * import gate. Tooling drift (superseded deps) is reported as a non-fatal
16
+ * warning — `nk doctor --fix` resolves it.
16
17
  */
17
- export async function check() {
18
+ export function check() {
18
19
  // Run every gate before deciding (no short-circuit), so one failure doesn't
19
20
  // hide another. oxc splits lint (oxlint) and format (oxfmt), so we run both.
20
21
  const lintFailed = run(FORMATTER.lint[0], FORMATTER.lint[1]) !== 0;
21
22
  const fmtFailed = run(FORMATTER.checkFormat[0], FORMATTER.checkFormat[1]) !== 0;
22
- const sqlFailed = await formatSql({ check: true });
23
23
  // knip (unused deps/exports/files). Opt-in: only when the repo has a knip
24
24
  // config — knip has no shareable config, so absence means "not adopted".
25
25
  const knipFailed = hasKnipConfig() ? runKnip() !== 0 : false;
@@ -32,8 +32,19 @@ export async function check() {
32
32
  " → add `@./node_modules/@ingram-tech/nk-dev/guide.md` to your CLAUDE.md (or run `nk init`).",
33
33
  );
34
34
  }
35
- process.exit(
36
- lintFailed || fmtFailed || sqlFailed || knipFailed || !guide.ok ? 1 : 0,
35
+ warnToolDrift();
36
+ process.exit(lintFailed || fmtFailed || knipFailed || !guide.ok ? 1 : 0);
37
+ }
38
+
39
+ /** Non-fatal: surface superseded deps so drift doesn't silently re-accumulate. */
40
+ function warnToolDrift() {
41
+ const drift = toolDrift();
42
+ if (drift.length === 0) return;
43
+ console.error(
44
+ `nk check: ${drift.length} dependency(ies) superseded by @ingram-tech/nk-dev — ${drift.join(", ")}`,
45
+ );
46
+ console.error(
47
+ " → nk-dev already provides these; run `nk doctor --fix` to remove them.",
37
48
  );
38
49
  }
39
50
 
@@ -44,6 +55,11 @@ export function typeCheck() {
44
55
  process.exit(run("tsc", ["--noEmit"]));
45
56
  }
46
57
 
58
+ /** `nk test [...]` — vitest run, with extra args passed through. */
59
+ export function test(extraArgs = []) {
60
+ process.exit(run("vitest", ["run", ...extraArgs]));
61
+ }
62
+
47
63
  /** `nk build [...]` — next build, with extra args passed through. */
48
64
  export function build(extraArgs = []) {
49
65
  process.exit(run("next", ["build", ...extraArgs]));
package/oxlintrc.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "import"],
3
+ "jsPlugins": ["@ingram-tech/nk-dev/oxlint-plugin"],
3
4
  "categories": {
4
5
  "correctness": "error"
5
6
  },
6
7
  "rules": {
8
+ "nextkit/no-radix-props-on-base-ui": "error",
7
9
  "no-unused-vars": "warn",
8
10
  "typescript/no-non-null-assertion": "error",
9
11
  "typescript/no-explicit-any": "error",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingram-tech/nk-dev",
3
- "version": "0.2.5",
3
+ "version": "0.4.0",
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",
@@ -30,6 +30,7 @@
30
30
  "./oxlintrc.json": "./oxlintrc.json",
31
31
  "./oxfmtrc.json": "./oxfmtrc.json",
32
32
  "./tier-b.json": "./tier-b.json",
33
+ "./oxlint-plugin": "./lib/oxlint-plugins/base-ui.js",
33
34
  "./tsconfig": "./tsconfig/nextjs.json",
34
35
  "./tsconfig/base.json": "./tsconfig/base.json",
35
36
  "./tsconfig/nextjs.json": "./tsconfig/nextjs.json",
@@ -45,13 +46,11 @@
45
46
  "dependencies": {
46
47
  "@testing-library/jest-dom": "^6.9.1",
47
48
  "jsdom": "^29.1.1",
48
- "knip": "^6.23.0",
49
- "oxfmt": "^0.56.0",
50
- "oxlint": "^1.71.0",
51
- "prettier": "^3.9.3",
52
- "prettier-plugin-sql": "^0.20.0",
53
- "typescript": "^6.0.3",
54
- "vitest": "^4.1.9"
49
+ "knip": "^6.25.0",
50
+ "oxfmt": "^0.58.0",
51
+ "oxlint": "^1.73.0",
52
+ "typescript": "^7.0.2",
53
+ "vitest": "^4.1.10"
55
54
  },
56
55
  "engines": {
57
56
  "node": ">=20"