@ingram-tech/nk-dev 0.1.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 ADDED
@@ -0,0 +1,113 @@
1
+ # @ingram-tech/nk-dev
2
+
3
+ The nextkit **dev toolchain in one package**. Everything a site needs at
4
+ development time — and nothing that ships to production — lives here:
5
+
6
+ - the **`nk` CLI** (`nk dev` / `format` / `lint` / `check` / `type-check` / `build`);
7
+ - the shared **oxlint + oxfmt**, **TypeScript**, and **Vitest** config;
8
+ - the **oxfmt format-on-commit** git hook (`nextkit-format-staged`);
9
+ - the **AI agent guide** (`guide.md`) imported into a site's `CLAUDE.md`;
10
+ - **`nk init`**, which scaffolds a site to use all of the above.
11
+
12
+ It's a single `devDependency` and pulls the toolchain (oxlint, oxfmt, tsc,
13
+ vitest, jsdom, jest-dom) as hard dependencies — so one install gives you the
14
+ whole stack instead of re-listing each tool per site.
15
+
16
+ > **Runtime vs dev-time.** nk-dev is the *dev-time* bundle. Runtime features
17
+ > (`@ingram-tech/email`, `nk-db`, `nk-auth`, …) stay separate packages that
18
+ > peer-depend on `next`/`react`. See the dev-toolchain carve-out in
19
+ > [`philosophy.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/philosophy.md).
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ bun add -d @ingram-tech/nk-dev
25
+ bunx nk init
26
+ bun install # the prepare script wires the git hook
27
+ ```
28
+
29
+ `nk init` writes the config files (and skips any that already exist):
30
+
31
+ | File | What |
32
+ | --- | --- |
33
+ | `.oxlintrc.json` | `extends` the shared oxlint rules (relative path — oxlint doesn't resolve package specifiers) |
34
+ | `.oxfmtrc.json` | a copy of the house format config (oxfmt has no `extends`) |
35
+ | `tsconfig.json` | `extends` `@ingram-tech/nk-dev/tsconfig/nextjs.json` + the site's own `include`/`paths` |
36
+ | `vitest.config.ts` | `mergeConfig(nextkitTestConfig, {})` |
37
+ | `.githooks/pre-commit` + `prepare` script | oxfmt format-on-commit |
38
+ | `CLAUDE.md` | the agent-guide `@import` |
39
+
40
+ Everything is `extends`-based, so the house config is enforced by default but
41
+ overridable — layer your own rules on top, or replace a stub entirely (e.g. drop
42
+ in a `biome.json` instead of the oxlint/oxfmt stubs).
43
+
44
+ Already on the old split packages or `@ingram-tech/biome-config`? Run the
45
+ codemod — see
46
+ [`oxlint-migration.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/oxlint-migration.md).
47
+
48
+ ## The `nk` command
49
+
50
+ Point your package.json scripts at it:
51
+
52
+ ```jsonc
53
+ {
54
+ "scripts": {
55
+ "dev": "nk dev",
56
+ "format": "nk format",
57
+ "lint": "nk lint",
58
+ "check": "nk check",
59
+ "type-check": "nk type-check",
60
+ "build": "nk build"
61
+ }
62
+ }
63
+ ```
64
+
65
+ `nk` shells out to the site's own `bunx`-resolved tools (oxlint, oxfmt, Next,
66
+ tsc), so versions stay under each site's control — nk just orchestrates.
67
+
68
+ > **`nk` is optional.** It only orchestrates the standard commands; it never
69
+ > wraps or intercepts the Next.js build. Every site must stay fully buildable and
70
+ > runnable with plain `next build` / `next dev` if `nk` is removed — see the
71
+ > [`nk` carve-out](https://github.com/ingram-technologies/nextkit/blob/main/docs/philosophy.md).
72
+ > The orchestration tests in this package check that the formatter resolves to
73
+ > standard oxlint/oxfmt invocations and nothing more.
74
+
75
+ ### Commands
76
+
77
+ - **`nk init`** — scaffold this project to use nextkit (see above). Idempotent:
78
+ skips files that already exist.
79
+ - **`nk dev`** — start the Next dev server on the golden-path local database
80
+ (see [`db-package.md`](https://github.com/ingram-technologies/nextkit/blob/main/docs/db-package.md)):
81
+ - **PGlite** — if `@ingram-tech/nk-db`'s `nk-pglite-dev` bin resolves, hand off
82
+ to it: boot Postgres-in-WASM, apply the `drizzle/` migrations, set
83
+ `DATABASE_URL`, then `next dev --turbopack`. No Docker, no daemon.
84
+ - **Plain** — otherwise just `next dev` (static/marketing sites with no DB).
85
+ - **`nk format` / `nk format --check`** — formats code (JS/TS/JSON/CSS) with
86
+ oxfmt and SQL with Prettier. `--check` verifies without writing (CI).
87
+ - **`nk lint`** — `oxlint`.
88
+ - **`nk check`** — `oxlint` + `oxfmt --check` plus SQL format verification, plus
89
+ the agent-guide import gate. The CI gate.
90
+ - **`nk type-check`** — `next typegen && tsc --noEmit`.
91
+ - **`nk build [...]`** — `next build`, extra args passed through.
92
+
93
+ ## Exports
94
+
95
+ ```jsonc
96
+ "@ingram-tech/nk-dev/oxlintrc.json" // oxlint rules (extend via relative path)
97
+ "@ingram-tech/nk-dev/oxfmtrc.json" // oxfmt config (copy; no extends)
98
+ "@ingram-tech/nk-dev/tier-b.json" // stricter opt-in lint tier
99
+ "@ingram-tech/nk-dev/tsconfig/base.json" // base TS config
100
+ "@ingram-tech/nk-dev/tsconfig/nextjs.json" // Next.js TS config (also "/tsconfig")
101
+ "@ingram-tech/nk-dev/vitest" // nextkitTestConfig preset
102
+ "@ingram-tech/nk-dev/vitest/setup" // Vitest setup (jest-dom + Next mocks)
103
+ "@ingram-tech/nk-dev/guide.md" // the AI agent guide
104
+ ```
105
+
106
+ ## Why Prettier for SQL?
107
+
108
+ oxfmt is the formatter for code and stays that way — Prettier is never used for
109
+ JS/TS. But oxfmt can't format SQL, so nk-dev bundles `prettier` +
110
+ `prettier-plugin-sql` **as its own dependencies** and uses them only for `.sql`
111
+ files. Prettier therefore never lands in any app's `package.json`. A site's own
112
+ `.prettierrc` / package.json `"prettier"` settings are honored if present;
113
+ otherwise nk defaults to tabs + the Postgres dialect.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * nextkit pre-commit: format staged files with oxfmt, then re-stage them.
4
+ *
5
+ * Centralizes the logic that lived inline in each repo's `.githooks/pre-commit`.
6
+ * Sites get it by pointing their committed `.githooks/pre-commit` at this bin —
7
+ * so the behavior updates in one place when this package is bumped.
8
+ *
9
+ * Behavior:
10
+ * - Only staged files are touched (never the whole tree).
11
+ * - Only extensions oxfmt understands are passed to it.
12
+ * - oxfmt auto-discovers the repo's `.oxfmtrc.json` for house style.
13
+ * - Files are re-staged after formatting so the commit includes the result.
14
+ * - Formatting only — no lint gate on commit (lint runs in CI).
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+
18
+ const FORMATTABLE = /\.(jsx?|mjs|cjs|tsx?|mts|cts|json|jsonc|css|graphql|gql)$/;
19
+
20
+ const git = (args) => execFileSync("git", args, { encoding: "utf8" }).trim();
21
+
22
+ const stagedFiles = git(["diff", "--cached", "--name-only", "--diff-filter=ACMRTUXB"])
23
+ .split("\n")
24
+ .filter(Boolean);
25
+
26
+ if (stagedFiles.length === 0) process.exit(0);
27
+
28
+ const toFormat = stagedFiles.filter((f) => FORMATTABLE.test(f));
29
+ if (toFormat.length === 0) process.exit(0);
30
+
31
+ try {
32
+ execFileSync(
33
+ "bunx",
34
+ ["oxfmt", "--write", "--no-error-on-unmatched-pattern", "--", ...toFormat],
35
+ { stdio: "inherit" },
36
+ );
37
+ } catch (err) {
38
+ console.error("[nextkit] oxfmt failed:", err.message);
39
+ process.exit(1);
40
+ }
41
+
42
+ // Re-stage only the files that were already staged and still exist.
43
+ const existing = toFormat.filter((f) => {
44
+ try {
45
+ execFileSync("test", ["-e", f]);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ });
51
+ if (existing.length > 0) git(["add", "--", ...existing]);
package/bin/nk.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { dev } from "../lib/dev.js";
3
+ import { format } from "../lib/format.js";
4
+ import { init } from "../lib/init.js";
5
+ import { build, check, lint, typeCheck } from "../lib/passthrough.js";
6
+
7
+ const USAGE = `nk — the nextkit CLI
8
+
9
+ Usage: nk <command> [options]
10
+
11
+ Commands:
12
+ init Scaffold this project to use nextkit: writes the oxlint /
13
+ oxfmt / TypeScript / Vitest config, the format-on-commit
14
+ hook, and the agent-guide import. Skips files that exist.
15
+ dev Start the Next dev server. Boots local PGlite first when
16
+ @ingram-tech/nk-db is installed (no Docker); else plain dev.
17
+ format [--check] Format code with oxfmt and SQL with Prettier. --check
18
+ verifies without writing (for CI).
19
+ lint Lint with oxlint.
20
+ check Lint + format verification, plus the agent-guide import
21
+ gate (the CI gate).
22
+ type-check next typegen && tsc --noEmit.
23
+ build [...] next build (extra args passed through).
24
+
25
+ Code formats with oxfmt and lints with oxlint; SQL formats with Prettier.`;
26
+
27
+ const [cmd, ...rest] = process.argv.slice(2);
28
+
29
+ switch (cmd) {
30
+ case "init":
31
+ init();
32
+ break;
33
+ case "dev":
34
+ dev(rest);
35
+ break;
36
+ case "format":
37
+ await format({ check: rest.includes("--check") });
38
+ break;
39
+ case "lint":
40
+ lint();
41
+ break;
42
+ case "check":
43
+ await check();
44
+ break;
45
+ case "type-check":
46
+ typeCheck();
47
+ break;
48
+ case "build":
49
+ build(rest);
50
+ break;
51
+ case "help":
52
+ case "--help":
53
+ case "-h":
54
+ case undefined:
55
+ console.log(USAGE);
56
+ break;
57
+ default:
58
+ console.error(`nk: unknown command "${cmd}"\n`);
59
+ console.log(USAGE);
60
+ process.exit(1);
61
+ }
package/guide.md ADDED
@@ -0,0 +1,60 @@
1
+ # nextkit (for AI agents)
2
+
3
+ This is a **nextkit** site — Ingram Technologies' shared Next.js foundation.
4
+ Core idea: don't reinvent shared concerns — reach for the `@ingram-tech/*`
5
+ package. Stay a thin, standard Next.js app (bun · oxlint + oxfmt · strict TS).
6
+
7
+ ## Hard rules
8
+
9
+ - **Any form that emails or stores a submission MUST use `@ingram-tech/bot-protection`**
10
+ (server: `verifyHuman` → silently drop bots; client: honeypot + signed token).
11
+ Never ship a form without it.
12
+ - **Send email only via `@ingram-tech/email`** — never add another mail client.
13
+ - Format/lint with **oxlint + oxfmt** via `nk` (`@ingram-tech/nk-cli`); don't
14
+ reintroduce ESLint, nor Prettier for code (`nk` uses Prettier only for SQL,
15
+ which oxfmt can't format). `nk` is optional convenience that only orchestrates
16
+ the standard tools — the site must stay buildable with plain `next build` / `next dev`.
17
+
18
+ ## Route & URL conventions
19
+
20
+ Keep the URL namespace honest about **who calls each route**:
21
+
22
+ - **`/auth/…` — sign-in, via `@ingram-tech/nk-auth`.** Better Auth mounts here
23
+ through `basePath: authBasePath` (handler at `app/auth/[...all]/route.ts`,
24
+ client `createAuthClient({ basePath: authBasePath })`) — **not** the framework
25
+ default `/api/auth`. So **login / social OAuth callbacks are
26
+ `<site>/auth/callback/<provider>`** (e.g. Google `…/auth/callback/google`) —
27
+ that's the redirect URI you register with the IdP. Don't confuse it with
28
+ *connector* OAuth (the app acting as a client to a provider), which lives at
29
+ `/internal/connect/<provider>/callback` below.
30
+ - **`/api/…` — the app's public API only.** Routes that external clients or your
31
+ own frontend consume *as an API*. Nothing else belongs here.
32
+ - **`/internal/…` — all plumbing the public never calls as your API.** This is
33
+ where provider integrations, webhooks, workers and crons live:
34
+ - **`/internal/connect/<provider>/{start,callback}`** — the outbound OAuth /
35
+ app-install handshake. `start` (session-gated) kicks off the redirect to the
36
+ provider; **`callback` is the URL you register with the provider** — it
37
+ finishes the exchange/records the install and redirects the user back into the
38
+ app. e.g. `/internal/connect/slack/callback`, `/internal/connect/github/callback`.
39
+ - **`/internal/webhooks/<provider>`** — inbound provider webhooks (Slack,
40
+ GitHub, Stripe, …). Authenticated by the provider's signature/secret, not a
41
+ session. App-level (one URL per provider); route to the tenant from the
42
+ payload (team id, installation id, …).
43
+ - **`/internal/worker/<name>` · `/internal/cron/<name>`** — queue drains and
44
+ scheduled jobs, gated by a shared worker secret (Vercel Cron / queue calls them).
45
+
46
+ Rule of thumb: if a human navigates to it, it's a **page** (normal route tree);
47
+ if your frontend fetches it as an API, it's **`/api/…`**; if a provider, cron, or
48
+ queue calls it, it's **`/internal/…`**. Never put OAuth callbacks or webhooks in
49
+ the UI/page tree, and never expose internal plumbing under `/api/`.
50
+
51
+ ## What nextkit provides (reach for these)
52
+
53
+ - `@ingram-tech/email` — Cloudflare email: `sendEmail`, `fromAddress`
54
+ - `@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)
55
+ - `@ingram-tech/nk-db` — Postgres data layer: `createPool` (one TLS-aware pool) + `createQueries` (raw SQL) + `createDb` (Drizzle), plus a PGlite dev/test harness at `@ingram-tech/nk-db/pglite`
56
+ - `@ingram-tech/bot-protection` — invisible form protection (honeypot + timing + Vercel BotID)
57
+ - `@ingram-tech/newsletter` — Supabase newsletter: subscribe / send, 1-click unsubscribe
58
+ - `@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` / `check` / `type-check` / `build`), the shared oxlint + oxfmt / TypeScript / Vitest config, the oxfmt format-on-commit hook, and this guide. `nk init` scaffolds a site to use it all.
59
+
60
+ For detail on any package, read its README in `node_modules/@ingram-tech/<pkg>/`.
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ // A Claude Code `@import` of the shared guide, e.g.
5
+ // @./node_modules/@ingram-tech/nk-dev/guide.md
6
+ // @./web/node_modules/@ingram-tech/nk-dev/guide.md (app in a subdir)
7
+ const IMPORT_RE = /@\S*nk-dev\/guide\.md/;
8
+
9
+ function readDeps(cwd) {
10
+ try {
11
+ const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf8"));
12
+ return { ...pkg.dependencies, ...pkg.devDependencies };
13
+ } catch {
14
+ return null; // no/unreadable package.json — nothing to enforce
15
+ }
16
+ }
17
+
18
+ // CLAUDE.md sits next to package.json for a standard site; for an app nested in a
19
+ // subdir (e.g. `web/`) it lives one level up at the repo root. Check both.
20
+ function findClaudeMd(cwd) {
21
+ for (const dir of [cwd, dirname(resolve(cwd))]) {
22
+ const p = resolve(dir, "CLAUDE.md");
23
+ if (existsSync(p)) return p;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * nextkit's shared agent guidance (`@ingram-tech/nk-dev/guide.md`) only reaches
30
+ * an AI agent if the site's CLAUDE.md `@import`s it. A site can depend on nk-dev
31
+ * yet forget the import line — then the guidance silently never loads. When the
32
+ * package is a dependency, assert CLAUDE.md actually imports it.
33
+ *
34
+ * Returns `{ ok, reason }`. `ok` is true when there's nothing to enforce (the
35
+ * package isn't a dependency) or the import is present; `reason` explains a miss.
36
+ */
37
+ export function checkAgentGuideImport(cwd = process.cwd()) {
38
+ const deps = readDeps(cwd);
39
+ if (!deps || !deps["@ingram-tech/nk-dev"]) return { ok: true };
40
+
41
+ const claudePath = findClaudeMd(cwd);
42
+ if (!claudePath) {
43
+ return {
44
+ ok: false,
45
+ reason: "depends on @ingram-tech/nk-dev but has no CLAUDE.md importing its guide",
46
+ };
47
+ }
48
+ if (!IMPORT_RE.test(readFileSync(claudePath, "utf8"))) {
49
+ return {
50
+ ok: false,
51
+ reason: "CLAUDE.md does not @import @ingram-tech/nk-dev/guide.md (shared agent guidance won't load)",
52
+ };
53
+ }
54
+ return { ok: true };
55
+ }
package/lib/dev.js ADDED
@@ -0,0 +1,42 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { resolve } from "node:path";
4
+
5
+ /**
6
+ * Whether the site has `@ingram-tech/nk-db` installed — i.e. its `nk-pglite-dev`
7
+ * bin is available. Resolved from the site's own `node_modules`, so `nk` only
8
+ * orchestrates a tool the site already provides (the carve-out).
9
+ */
10
+ function hasPgliteDev() {
11
+ try {
12
+ const require = createRequire(resolve(process.cwd(), "package.json"));
13
+ require.resolve("@ingram-tech/nk-db");
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * `nk dev` — start the Next dev server on the golden-path local database.
22
+ *
23
+ * If `@ingram-tech/nk-db` is installed, hand off to its `nk-pglite-dev` bin: it
24
+ * boots PGlite (Postgres-in-WASM, no Docker), applies the `drizzle/` migrations,
25
+ * sets `DATABASE_URL`, and runs `next dev` itself. Otherwise just `next dev`
26
+ * (static/marketing sites with no database). PGlite logic lives in nk-db, not
27
+ * here — `nk` only orchestrates.
28
+ *
29
+ * `nk dev` does not boot local Supabase; the fleet has moved off it. The few
30
+ * Supabase-Postgres holdouts start it themselves until they migrate.
31
+ */
32
+ export function dev(extraArgs = []) {
33
+ const command = hasPgliteDev()
34
+ ? ["nk-pglite-dev", ...extraArgs]
35
+ : ["next", "dev", "--turbopack", ...extraArgs];
36
+ if (command[0] === "nk-pglite-dev") {
37
+ console.log("nk: @ingram-tech/nk-db found — booting local PGlite (no Docker)…");
38
+ }
39
+ // spawnSync inherits stdio and blocks until exit, so Ctrl-C reaches the child.
40
+ const res = spawnSync("bunx", command, { stdio: "inherit" });
41
+ process.exit(res.status ?? 0);
42
+ }
package/lib/format.js ADDED
@@ -0,0 +1,108 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { join, relative } from "node:path";
5
+ import { FORMATTER } from "./formatter.js";
6
+ import { run } from "./run.js";
7
+
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 tab style + Supabase's Postgres dialect).
12
+ const SQL_DEFAULTS = { useTabs: true, language: "postgresql" };
13
+
14
+ /**
15
+ * `nk format` / `nk format --check`.
16
+ *
17
+ * Code (JS/TS/JSON/CSS) goes through oxfmt; SQL goes through Prettier, which
18
+ * oxfmt can't format. Prettier + prettier-plugin-sql are bundled with nk-cli,
19
+ * so they never appear in any app's dependencies — the "no Prettier for code"
20
+ * rule still holds, it's just the one file type oxfmt lacks.
21
+ */
22
+ export async function format({ check }) {
23
+ const op = check ? FORMATTER.checkFormat : FORMATTER.write;
24
+ const code = run(op[0], op[1]);
25
+ if (code !== 0) process.exitCode = code;
26
+
27
+ await formatSql({ check });
28
+ }
29
+
30
+ /** Format (or, with `check`, verify) every tracked `.sql` file via Prettier. */
31
+ export async function formatSql({ check }) {
32
+ const files = sqlFiles();
33
+ if (files.length === 0) return;
34
+
35
+ const prettier = require("prettier");
36
+ const pluginPath = require.resolve("prettier-plugin-sql");
37
+
38
+ let unformatted = 0;
39
+ let written = 0;
40
+ for (const file of files) {
41
+ const source = readFileSync(file, "utf8");
42
+ // The site's own .prettierrc / package.json "prettier" wins over our
43
+ // defaults; we always inject the bundled SQL plugin + parser.
44
+ const siteConfig = (await prettier.resolveConfig(file)) ?? {};
45
+ const options = {
46
+ ...SQL_DEFAULTS,
47
+ ...siteConfig,
48
+ parser: "sql",
49
+ plugins: [
50
+ pluginPath,
51
+ ...(siteConfig.plugins ?? []).filter(
52
+ (p) => !String(p).includes("prettier-plugin-sql"),
53
+ ),
54
+ ],
55
+ };
56
+
57
+ if (check) {
58
+ if (!(await prettier.check(source, options))) {
59
+ unformatted++;
60
+ console.error(` ${relative(process.cwd(), file)}`);
61
+ }
62
+ } else {
63
+ const out = await prettier.format(source, options);
64
+ if (out !== source) {
65
+ writeFileSync(file, out);
66
+ written++;
67
+ }
68
+ }
69
+ }
70
+
71
+ if (check && unformatted > 0) {
72
+ console.error(
73
+ `nk: ${unformatted} SQL file(s) need formatting — run \`nk format\`.`,
74
+ );
75
+ process.exitCode = 1;
76
+ } else if (!check && written > 0) {
77
+ console.log(`nk: formatted ${written} SQL file(s).`);
78
+ }
79
+ }
80
+
81
+ /** Tracked + untracked-not-ignored `.sql` files; falls back to an fs walk. */
82
+ function sqlFiles() {
83
+ const res = spawnSync(
84
+ "git",
85
+ ["ls-files", "--cached", "--others", "--exclude-standard", "*.sql"],
86
+ { encoding: "utf8" },
87
+ );
88
+ if (res.status === 0) {
89
+ return res.stdout
90
+ .split("\n")
91
+ .map((s) => s.trim())
92
+ .filter(Boolean);
93
+ }
94
+ return walkSql(process.cwd());
95
+ }
96
+
97
+ const SKIP_DIRS = new Set(["node_modules", ".next", ".git", "dist", "build"]);
98
+
99
+ function walkSql(dir, out = []) {
100
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
101
+ if (entry.isDirectory()) {
102
+ if (!SKIP_DIRS.has(entry.name)) walkSql(join(dir, entry.name), out);
103
+ } else if (entry.name.endsWith(".sql")) {
104
+ out.push(join(dir, entry.name));
105
+ }
106
+ }
107
+ return out;
108
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The code formatter/linter is oxc (oxlint + oxfmt). Each entry maps an
3
+ * operation nk needs to a `[tool, args]` invocation. Lint and format are kept
4
+ * as separate ops because oxc splits them across two tools; `nk check` runs
5
+ * both (see passthrough.js).
6
+ */
7
+ export const FORMATTER = {
8
+ name: "oxc",
9
+ write: ["oxfmt", ["--write", "."]],
10
+ checkFormat: ["oxfmt", ["--check", "."]],
11
+ lint: ["oxlint", []],
12
+ };
package/lib/init.js ADDED
@@ -0,0 +1,154 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // Files live at the package root, two levels up from lib/.
7
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ const log = (msg) => console.log(`nk init: ${msg}`);
10
+ const skip = (file) => console.log(`nk init: ${file} already exists — left as-is.`);
11
+
12
+ /** Write `value` as house-formatted JSON (tabs, trailing newline). */
13
+ function writeJson(file, value) {
14
+ writeFileSync(file, `${JSON.stringify(value, null, "\t")}\n`);
15
+ }
16
+
17
+ /**
18
+ * Write `file` unless it already exists. Returns true if written.
19
+ * `build` produces the contents only when needed.
20
+ */
21
+ function writeIfAbsent(file, build) {
22
+ if (existsSync(file)) {
23
+ skip(file);
24
+ return false;
25
+ }
26
+ build(file);
27
+ log(`wrote ${file}`);
28
+ return true;
29
+ }
30
+
31
+ // oxlint resolves `extends` as a path relative to the config file — NOT as a
32
+ // package specifier — so it must point into node_modules.
33
+ const OXLINTRC = {
34
+ $schema: "./node_modules/oxlint/configuration_schema.json",
35
+ extends: ["./node_modules/@ingram-tech/nk-dev/oxlintrc.json"],
36
+ ignorePatterns: ["dist", ".next"],
37
+ };
38
+
39
+ // TypeScript DOES resolve a package specifier in `extends`, so this stays a
40
+ // clean one-liner. But inherited `include`/`exclude`/`paths` would resolve
41
+ // relative to the *base* config (inside node_modules), so we declare our own.
42
+ const TSCONFIG = {
43
+ extends: "@ingram-tech/nk-dev/tsconfig/nextjs.json",
44
+ compilerOptions: {
45
+ paths: { "@/*": ["./src/*"] },
46
+ },
47
+ include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
48
+ exclude: ["node_modules"],
49
+ };
50
+
51
+ const VITEST_CONFIG = `import { mergeConfig } from "vitest/config";
52
+ import { nextkitTestConfig } from "@ingram-tech/nk-dev/vitest";
53
+
54
+ // The shared nextkit preset (jsdom, globals, jest-dom matchers, Next.js mocks).
55
+ // Add project-specific overrides as a second mergeConfig argument.
56
+ export default mergeConfig(nextkitTestConfig, {});
57
+ `;
58
+
59
+ const PRE_COMMIT = `#!/bin/sh
60
+ # nextkit pre-commit: format staged files with oxfmt, then re-stage them.
61
+ # Logic lives in @ingram-tech/nk-dev, so a version bump updates it everywhere.
62
+ set -eu
63
+ exec bunx --bun nextkit-format-staged
64
+ `;
65
+
66
+ const GUIDE_IMPORT = "@./node_modules/@ingram-tech/nk-dev/guide.md";
67
+
68
+ export function init() {
69
+ const cwd = process.cwd();
70
+
71
+ if (!existsSync(resolve(cwd, "package.json"))) {
72
+ console.error("nk init: no package.json here — run from your project root.");
73
+ process.exit(1);
74
+ }
75
+
76
+ // 1. oxlint config (extends the shared rules; add your own below).
77
+ writeIfAbsent(resolve(cwd, ".oxlintrc.json"), (f) => writeJson(f, OXLINTRC));
78
+
79
+ // 2. oxfmt config — oxfmt has no `extends`, so the house format config is
80
+ // copied in. It's tiny and stable; editors auto-discover it too.
81
+ writeIfAbsent(resolve(cwd, ".oxfmtrc.json"), (f) =>
82
+ writeFileSync(f, readFileSync(resolve(PKG_ROOT, "oxfmtrc.json"), "utf8")),
83
+ );
84
+
85
+ // 3. TypeScript config.
86
+ writeIfAbsent(resolve(cwd, "tsconfig.json"), (f) => writeJson(f, TSCONFIG));
87
+
88
+ // 4. Vitest config (the shared jsdom preset).
89
+ writeIfAbsent(resolve(cwd, "vitest.config.ts"), (f) =>
90
+ writeFileSync(f, VITEST_CONFIG),
91
+ );
92
+
93
+ // 5. Format-on-commit hook + git wiring.
94
+ setupGitHook(cwd);
95
+
96
+ // 6. Make sure the agent guide is imported into CLAUDE.md.
97
+ ensureGuideImport(cwd);
98
+
99
+ // 7. A `prepare` script so the hook re-wires itself on every `bun install`.
100
+ ensurePrepareScript(cwd);
101
+
102
+ log("done. Next: `bun install`, then `nk check`.");
103
+ }
104
+
105
+ function setupGitHook(cwd) {
106
+ const hookDir = resolve(cwd, ".githooks");
107
+ const hook = resolve(hookDir, "pre-commit");
108
+ if (!existsSync(hook)) {
109
+ mkdirSync(hookDir, { recursive: true });
110
+ writeFileSync(hook, PRE_COMMIT);
111
+ log("wrote .githooks/pre-commit");
112
+ } else {
113
+ skip(".githooks/pre-commit");
114
+ }
115
+ chmodSync(hook, 0o755);
116
+ // Point git at the committed hooks dir now (the prepare script repeats this
117
+ // on future installs). Harmless if not a git repo.
118
+ spawnSync("git", ["config", "core.hooksPath", ".githooks"], {
119
+ cwd,
120
+ stdio: "ignore",
121
+ });
122
+ }
123
+
124
+ function ensureGuideImport(cwd) {
125
+ const claudePath = resolve(cwd, "CLAUDE.md");
126
+ if (!existsSync(claudePath)) {
127
+ writeFileSync(claudePath, `# Project\n\n${GUIDE_IMPORT}\n`);
128
+ log("wrote CLAUDE.md with the agent-guide import");
129
+ return;
130
+ }
131
+ const body = readFileSync(claudePath, "utf8");
132
+ if (body.includes("nk-dev/guide.md")) {
133
+ log("CLAUDE.md already imports the agent guide.");
134
+ return;
135
+ }
136
+ writeFileSync(claudePath, `${body.replace(/\n*$/, "")}\n\n${GUIDE_IMPORT}\n`);
137
+ log("added the agent-guide import to CLAUDE.md");
138
+ }
139
+
140
+ function ensurePrepareScript(cwd) {
141
+ const pkgPath = resolve(cwd, "package.json");
142
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
143
+ pkg.scripts ??= {};
144
+ const wanted = "git config core.hooksPath .githooks || true";
145
+ if (pkg.scripts.prepare?.includes("core.hooksPath")) {
146
+ log("package.json already has a hooks `prepare` script.");
147
+ return;
148
+ }
149
+ pkg.scripts.prepare = pkg.scripts.prepare
150
+ ? `${pkg.scripts.prepare} && ${wanted}`
151
+ : wanted;
152
+ writeJson(pkgPath, pkg);
153
+ log("added a `prepare` script to package.json (wires core.hooksPath).");
154
+ }
@@ -0,0 +1,41 @@
1
+ import { checkAgentGuideImport } from "./agent-guide.js";
2
+ import { formatSql } from "./format.js";
3
+ import { FORMATTER } from "./formatter.js";
4
+ import { run } from "./run.js";
5
+
6
+ /** `nk lint` — oxlint. */
7
+ export function lint() {
8
+ process.exit(run(FORMATTER.lint[0], FORMATTER.lint[1]));
9
+ }
10
+
11
+ /** `nk check` — the CI gate: lint + format verify (code) plus SQL format verify. */
12
+ export async function check() {
13
+ // Run every gate before deciding (no short-circuit), so one failure doesn't
14
+ // hide another. oxc splits lint (oxlint) and format (oxfmt), so we run both.
15
+ const lintFailed = run(FORMATTER.lint[0], FORMATTER.lint[1]) !== 0;
16
+ const fmtFailed = run(FORMATTER.checkFormat[0], FORMATTER.checkFormat[1]) !== 0;
17
+ await formatSql({ check: true });
18
+ const sqlFailed = Boolean(process.exitCode);
19
+ // Keep the site on the shared-guidance channel: if it depends on
20
+ // @ingram-tech/nk-dev, its CLAUDE.md must @import the guide.
21
+ const guide = checkAgentGuideImport();
22
+ if (!guide.ok) {
23
+ console.error(`nk check: ${guide.reason}`);
24
+ console.error(
25
+ " → add `@./node_modules/@ingram-tech/nk-dev/guide.md` to your CLAUDE.md (or run `nk init`).",
26
+ );
27
+ }
28
+ process.exit(lintFailed || fmtFailed || sqlFailed || !guide.ok ? 1 : 0);
29
+ }
30
+
31
+ /** `nk type-check` — the house type-check: regenerate Next's types, then tsc. */
32
+ export function typeCheck() {
33
+ const typegen = run("next", ["typegen"]);
34
+ if (typegen !== 0) process.exit(typegen);
35
+ process.exit(run("tsc", ["--noEmit"]));
36
+ }
37
+
38
+ /** `nk build [...]` — next build, with extra args passed through. */
39
+ export function build(extraArgs = []) {
40
+ process.exit(run("next", ["build", ...extraArgs]));
41
+ }
package/lib/run.js ADDED
@@ -0,0 +1,44 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * Run a site-local tool through `bunx` (resolves node_modules/.bin first) with
5
+ * inherited stdio. Returns the exit code; never throws on a non-zero exit.
6
+ */
7
+ export function run(tool, args = [], opts = {}) {
8
+ const res = spawnSync("bunx", [tool, ...args], { stdio: "inherit", ...opts });
9
+ if (res.error) {
10
+ if (res.error.code === "ENOENT") {
11
+ fail("could not run `bunx` — is bun installed and on PATH?");
12
+ }
13
+ throw res.error;
14
+ }
15
+ return res.status ?? 0;
16
+ }
17
+
18
+ /**
19
+ * Run a tool capturing its stdout (stderr still streams to the terminal).
20
+ * Exits the process if the tool fails — callers depend on the captured output.
21
+ */
22
+ export function capture(tool, args = [], opts = {}) {
23
+ const res = spawnSync("bunx", [tool, ...args], {
24
+ encoding: "utf8",
25
+ stdio: ["inherit", "pipe", "inherit"],
26
+ ...opts,
27
+ });
28
+ if (res.error) {
29
+ if (res.error.code === "ENOENT") {
30
+ fail("could not run `bunx` — is bun installed and on PATH?");
31
+ }
32
+ throw res.error;
33
+ }
34
+ if (res.status !== 0) {
35
+ fail(`\`${tool} ${args.join(" ")}\` exited with ${res.status}`);
36
+ }
37
+ return res.stdout ?? "";
38
+ }
39
+
40
+ /** Print an `nk:`-prefixed error and exit non-zero. */
41
+ export function fail(message) {
42
+ console.error(`nk: ${message}`);
43
+ process.exit(1);
44
+ }
package/oxfmtrc.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "useTabs": true,
3
+ "tabWidth": 4,
4
+ "printWidth": 88,
5
+ "singleQuote": false,
6
+ "jsxSingleQuote": false,
7
+ "quoteProps": "as-needed",
8
+ "trailingComma": "all",
9
+ "semi": true,
10
+ "arrowParens": "always",
11
+ "bracketSameLine": false,
12
+ "bracketSpacing": true,
13
+ "sortPackageJson": false,
14
+ "sortImports": false,
15
+ "sortTailwindcss": false,
16
+ "ignorePatterns": [
17
+ "**/*.md",
18
+ "**/*.mdx",
19
+ "**/*.yaml",
20
+ "**/*.yml",
21
+ "**/*.toml",
22
+ "**/*.html",
23
+ "dist",
24
+ ".next",
25
+ "build"
26
+ ]
27
+ }
package/oxlintrc.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "import"],
3
+ "categories": {
4
+ "correctness": "error"
5
+ },
6
+ "rules": {
7
+ "no-unused-vars": "warn",
8
+ "typescript/no-non-null-assertion": "error",
9
+ "typescript/no-explicit-any": "error",
10
+ "typescript/consistent-type-imports": "off",
11
+ "unicorn/prefer-node-protocol": "off",
12
+ "react/exhaustive-deps": "warn",
13
+ "react/no-array-index-key": "off",
14
+ "react/no-danger": "off",
15
+ "jsx-a11y/label-has-associated-control": "off",
16
+ "jsx-a11y/click-events-have-key-events": "off",
17
+ "jsx-a11y/no-static-element-interactions": "off",
18
+ "jsx-a11y/interactive-supports-focus": "off",
19
+ "jsx-a11y/control-has-associated-label": "off",
20
+ "jsx-a11y/prefer-tag-over-role": "off",
21
+ "jsx-a11y/no-autofocus": "off",
22
+ "jsx-a11y/role-has-required-aria-props": "off"
23
+ }
24
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@ingram-tech/nk-dev",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ingram-technologies/nextkit.git",
10
+ "directory": "packages/nk-dev"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "lib",
18
+ "tsconfig",
19
+ "vitest",
20
+ "oxlintrc.json",
21
+ "oxfmtrc.json",
22
+ "tier-b.json",
23
+ "guide.md"
24
+ ],
25
+ "bin": {
26
+ "nk": "bin/nk.js",
27
+ "nextkit-format-staged": "bin/format-staged.mjs"
28
+ },
29
+ "exports": {
30
+ "./oxlintrc.json": "./oxlintrc.json",
31
+ "./oxfmtrc.json": "./oxfmtrc.json",
32
+ "./tier-b.json": "./tier-b.json",
33
+ "./tsconfig": "./tsconfig/nextjs.json",
34
+ "./tsconfig/base.json": "./tsconfig/base.json",
35
+ "./tsconfig/nextjs.json": "./tsconfig/nextjs.json",
36
+ "./vitest": "./vitest/index.ts",
37
+ "./vitest/setup": "./vitest/setup.ts",
38
+ "./guide.md": "./guide.md"
39
+ },
40
+ "scripts": {
41
+ "build": "true",
42
+ "type-check": "true",
43
+ "test": "vitest run"
44
+ },
45
+ "dependencies": {
46
+ "@testing-library/jest-dom": "^6.9.1",
47
+ "jsdom": "^29.1.1",
48
+ "oxfmt": "^0.55.0",
49
+ "oxlint": "^1.70.0",
50
+ "prettier": "^3.8.3",
51
+ "prettier-plugin-sql": "^0.20.0",
52
+ "typescript": "^6.0.3",
53
+ "vitest": "^4.1.6"
54
+ },
55
+ "engines": {
56
+ "node": ">=20"
57
+ }
58
+ }
package/tier-b.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3
+ "rules": {
4
+ "no-restricted-imports": [
5
+ "error",
6
+ {
7
+ "paths": [
8
+ {
9
+ "name": "@supabase/supabase-js",
10
+ "message": "Golden-path data access uses @ingram-tech/nk-db (direct pg + Drizzle), not supabase-js."
11
+ },
12
+ {
13
+ "name": "pg",
14
+ "importNames": ["Pool", "Client"],
15
+ "allowTypeImports": true,
16
+ "message": "Build the pool with createPool() from @ingram-tech/nk-db, not `new Pool`/`new Client`. (`import type { Pool }` is fine.)"
17
+ }
18
+ ]
19
+ }
20
+ ]
21
+ },
22
+ "overrides": [
23
+ {
24
+ "files": [
25
+ "**/scripts/**",
26
+ "**/*.test.ts",
27
+ "**/*.test.tsx",
28
+ "**/__tests__/**",
29
+ "**/test/**",
30
+ "**/tests/**"
31
+ ],
32
+ "rules": { "no-restricted-imports": "off" }
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "@ingram-tech/typescript-config (base)",
4
+ "compilerOptions": {
5
+ "target": "ESNext",
6
+ "lib": ["esnext"],
7
+ "module": "esnext",
8
+ "moduleResolution": "bundler",
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "noUncheckedIndexedAccess": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "esModuleInterop": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "incremental": true,
18
+ "declaration": true,
19
+ "declarationMap": true,
20
+ "sourceMap": true
21
+ }
22
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "display": "@ingram-tech/typescript-config (Next.js app)",
4
+ "extends": "./base.json",
5
+ "compilerOptions": {
6
+ "lib": ["dom", "dom.iterable", "esnext"],
7
+ "jsx": "preserve",
8
+ "noEmit": true,
9
+ "declaration": false,
10
+ "declarationMap": false,
11
+ "plugins": [{ "name": "next" }],
12
+ "paths": {
13
+ "@/*": ["./src/*"]
14
+ }
15
+ },
16
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
17
+ "exclude": ["node_modules"]
18
+ }
@@ -0,0 +1,37 @@
1
+ import type { ViteUserConfig } from "vitest/config";
2
+
3
+ /**
4
+ * Default Vitest configuration for Ingram Technologies projects: jsdom
5
+ * environment, global test APIs, v8 coverage, and the shared setup file
6
+ * ({@link ./setup}) that wires up jest-dom matchers and common Next.js mocks.
7
+ *
8
+ * Compose it in your `vitest.config.ts`:
9
+ *
10
+ * ```ts
11
+ * import { defineConfig, mergeConfig } from "vitest/config";
12
+ * import { nextkitTestConfig } from "@ingram-tech/nk-dev/vitest";
13
+ *
14
+ * export default mergeConfig(
15
+ * nextkitTestConfig,
16
+ * defineConfig({
17
+ * // project-specific overrides (e.g. resolve.alias for "@")
18
+ * }),
19
+ * );
20
+ * ```
21
+ *
22
+ * Server-only library packages that don't need a DOM should instead set
23
+ * `test.environment: "node"` and skip the setup file.
24
+ */
25
+ export const nextkitTestConfig: ViteUserConfig = {
26
+ test: {
27
+ environment: "jsdom",
28
+ globals: true,
29
+ setupFiles: ["@ingram-tech/nk-dev/vitest/setup"],
30
+ include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
31
+ coverage: {
32
+ provider: "v8",
33
+ reporter: ["text", "json", "html"],
34
+ exclude: ["node_modules/", "**/*.d.ts", "**/*.config.*", "**/*.type.ts"],
35
+ },
36
+ },
37
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared Vitest setup, loaded via `setupFiles`. Keep this lean — it runs before
3
+ * every test file in consuming projects.
4
+ *
5
+ * - Registers `@testing-library/jest-dom` matchers (toBeInTheDocument, etc.).
6
+ * - Mocks `next/navigation` so components using `useRouter`/`usePathname`/
7
+ * `useSearchParams` render in isolation without a real router.
8
+ */
9
+ import "@testing-library/jest-dom/vitest";
10
+ import { vi } from "vitest";
11
+
12
+ vi.mock("next/navigation", () => ({
13
+ useRouter: () => ({
14
+ push: vi.fn(),
15
+ replace: vi.fn(),
16
+ back: vi.fn(),
17
+ forward: vi.fn(),
18
+ refresh: vi.fn(),
19
+ prefetch: vi.fn(),
20
+ }),
21
+ usePathname: () => "/",
22
+ useSearchParams: () => new URLSearchParams(),
23
+ useParams: () => ({}),
24
+ redirect: vi.fn(),
25
+ notFound: vi.fn(),
26
+ }));