From 422417f90e0a1a88ade5135d0161d2c17a5f7dd2 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Mon, 25 May 2026 17:35:00 +0200 Subject: [PATCH 1/2] Add @ingram-tech/nk-cli and bot-protection /react export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nk-cli: the `nk` command. `nk dev` boots local Supabase (wiring its env in) before `next dev` when supabase/config.toml is present, replacing per-site dev.sh. Adds `nk format` (Biome for code, bundled Prettier for SQL — so Prettier never enters app deps), plus lint/check/type-check/build. The code formatter is behind a small indirection so it can move to oxc later via package.json { "nk": { "formatter": "oxc" } }. bot-protection: new /react client export (useBotProtection + HoneypotInput) to replace the src/lib/bot-protection.tsx copy that had been duplicated across malinamore.studio, aidefencesummit.eu and ingram.tech. agent-guide: mention nk-cli; clarify that Prettier is used only for SQL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/agent-guide-nk.md | 5 ++ .changeset/bot-protection-react.md | 5 ++ .changeset/nk-cli-initial.md | 5 ++ bun.lock | 55 +++++++++++- packages/agent-guide/guide.md | 6 +- packages/bot-protection/README.md | 45 ++++++++++ packages/bot-protection/package.json | 4 + packages/bot-protection/src/react.tsx | 74 ++++++++++++++++ packages/bot-protection/tsconfig.json | 2 + packages/nk-cli/README.md | 61 ++++++++++++++ packages/nk-cli/bin/nk.js | 54 ++++++++++++ packages/nk-cli/lib/config.js | 15 ++++ packages/nk-cli/lib/dev.js | 66 +++++++++++++++ packages/nk-cli/lib/format.js | 116 ++++++++++++++++++++++++++ packages/nk-cli/lib/formatter.js | 42 ++++++++++ packages/nk-cli/lib/passthrough.js | 30 +++++++ packages/nk-cli/lib/run.js | 44 ++++++++++ packages/nk-cli/package.json | 34 ++++++++ 18 files changed, 658 insertions(+), 5 deletions(-) create mode 100644 .changeset/agent-guide-nk.md create mode 100644 .changeset/bot-protection-react.md create mode 100644 .changeset/nk-cli-initial.md create mode 100644 packages/bot-protection/src/react.tsx create mode 100644 packages/nk-cli/README.md create mode 100755 packages/nk-cli/bin/nk.js create mode 100644 packages/nk-cli/lib/config.js create mode 100644 packages/nk-cli/lib/dev.js create mode 100644 packages/nk-cli/lib/format.js create mode 100644 packages/nk-cli/lib/formatter.js create mode 100644 packages/nk-cli/lib/passthrough.js create mode 100644 packages/nk-cli/lib/run.js create mode 100644 packages/nk-cli/package.json diff --git a/.changeset/agent-guide-nk.md b/.changeset/agent-guide-nk.md new file mode 100644 index 0000000..56d3300 --- /dev/null +++ b/.changeset/agent-guide-nk.md @@ -0,0 +1,5 @@ +--- +"@ingram-tech/agent-guide": patch +--- + +Mention `@ingram-tech/nk-cli` (the `nk` command) and refine the formatting rule: Biome formats code, Prettier is used only for SQL (via `nk`), which Biome can't format. diff --git a/.changeset/bot-protection-react.md b/.changeset/bot-protection-react.md new file mode 100644 index 0000000..63e408c --- /dev/null +++ b/.changeset/bot-protection-react.md @@ -0,0 +1,5 @@ +--- +"@ingram-tech/bot-protection": minor +--- + +Add a `/react` client export: `useBotProtection(tokenEndpoint)` + `HoneypotInput`, for client components that POST JSON to their own route. Replaces the hand-copied `src/lib/bot-protection.tsx` that had been duplicated across sites, keeping the honeypot field name and timing token in lockstep with the server verifier. diff --git a/.changeset/nk-cli-initial.md b/.changeset/nk-cli-initial.md new file mode 100644 index 0000000..08f6c12 --- /dev/null +++ b/.changeset/nk-cli-initial.md @@ -0,0 +1,5 @@ +--- +"@ingram-tech/nk-cli": minor +--- + +New package: `nk`, the nextkit CLI. `nk dev` starts Next and boots local Supabase first (wiring its env in) when `supabase/config.toml` is present — replacing per-site `dev.sh`. Adds `nk format` (Biome for code, Prettier for SQL — bundled, so Prettier never lands in app deps), plus `lint` / `check` / `type-check` / `build`. The code formatter sits behind a small indirection so it can move to oxc later via `{ "nk": { "formatter": "oxc" } }`. diff --git a/bun.lock b/bun.lock index f21d1b6..7cb53a3 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,10 @@ "typescript": "^6.0.3", }, }, + "packages/agent-guide": { + "name": "@ingram-tech/agent-guide", + "version": "0.1.0", + }, "packages/biome-config": { "name": "@ingram-tech/biome-config", "version": "0.1.0", @@ -19,7 +23,7 @@ }, "packages/bot-protection": { "name": "@ingram-tech/bot-protection", - "version": "0.1.2", + "version": "0.2.0", "devDependencies": { "@ingram-tech/typescript-config": "workspace:*", "@types/node": "^20.0.0", @@ -56,7 +60,7 @@ }, "packages/newsletter": { "name": "@ingram-tech/newsletter", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@ingram-tech/email": "workspace:^", }, @@ -71,6 +75,17 @@ "@supabase/supabase-js": ">=2.0.0", }, }, + "packages/nk-cli": { + "name": "@ingram-tech/nk-cli", + "version": "0.1.0", + "bin": { + "nk": "bin/nk.mjs", + }, + "dependencies": { + "prettier": "^3.8.3", + "prettier-plugin-sql": "^0.20.0", + }, + }, "packages/test-config": { "name": "@ingram-tech/test-config", "version": "0.1.0", @@ -186,6 +201,8 @@ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@ingram-tech/agent-guide": ["@ingram-tech/agent-guide@workspace:packages/agent-guide"], + "@ingram-tech/biome-config": ["@ingram-tech/biome-config@workspace:packages/biome-config"], "@ingram-tech/bot-protection": ["@ingram-tech/bot-protection@workspace:packages/bot-protection"], @@ -196,6 +213,8 @@ "@ingram-tech/newsletter": ["@ingram-tech/newsletter@workspace:packages/newsletter"], + "@ingram-tech/nk-cli": ["@ingram-tech/nk-cli@workspace:packages/nk-cli"], + "@ingram-tech/test-config": ["@ingram-tech/test-config@workspace:packages/test-config"], "@ingram-tech/typescript-config": ["@ingram-tech/typescript-config@workspace:packages/typescript-config"], @@ -278,6 +297,8 @@ "@types/node": ["@types/node@20.19.41", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ=="], + "@types/pegjs": ["@types/pegjs@0.10.6", "", {}, "sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], @@ -310,12 +331,16 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -336,6 +361,8 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], @@ -404,6 +431,8 @@ "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "jsox": ["jsox@1.2.125", "", { "bin": { "jsox": "lib/cli.js" } }, "sha512-HIf1uwublnXZsy7p3yHTrhzMzrLO6xKnqXytT9pEil5QxaXi8eyer7Is4luF5hYSV4kD3v03Y32FWoAeVYTghQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -444,10 +473,16 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="], + + "node-sql-parser": ["node-sql-parser@5.4.0", "", { "dependencies": { "@types/pegjs": "^0.10.0", "big-integer": "^1.6.48" } }, "sha512-jVe6Z61gPcPjCElPZ6j8llB3wnqGcuQzefim1ERsqIakxnEy5JlzV7XKdO1KmacRG5TKwPc4vJTgSRQ0LfkbFw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], @@ -482,7 +517,9 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "prettier-plugin-sql": ["prettier-plugin-sql@0.20.0", "", { "dependencies": { "jsox": "^1.2.123", "node-sql-parser": "^5.3.10", "sql-formatter": "^15.7.0", "tslib": "^2.8.1" }, "peerDependencies": { "prettier": "^3.0.3" } }, "sha512-7GRuduJIbWd10bFfYdFuymZdcVF37ZCukiuVi2a4c5ccqjAxHQtKbK7zSKyqGrFjCOBLQVVz4BStKWV7YvxAFA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -490,6 +527,10 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="], + + "randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], @@ -500,6 +541,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], @@ -528,6 +571,8 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "sql-formatter": ["sql-formatter@15.8.0", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-HnjdRHlSsO4Ap2erB5YXAvWggrnk/S4TezUn8zmpq9J/hEKn9+6gGaqiKPyDtI10Xf4zJmHYPREGjMjZmmP1fg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], @@ -590,6 +635,10 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + + "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], diff --git a/packages/agent-guide/guide.md b/packages/agent-guide/guide.md index bfd0e4c..39a821d 100644 --- a/packages/agent-guide/guide.md +++ b/packages/agent-guide/guide.md @@ -10,14 +10,16 @@ package. Stay a thin, standard Next.js app (bun · Biome · strict TS). (server: `verifyHuman` → silently drop bots; client: honeypot + signed token). Never ship a form without it. - **Send email only via `@ingram-tech/email`** — never add another mail client. -- Lint/format with **Biome** (`@ingram-tech/biome-config`); don't reintroduce - ESLint/Prettier. +- Format/lint with **Biome** via `nk` (`@ingram-tech/nk-cli`); don't reintroduce + ESLint, nor Prettier for code (`nk` uses Prettier only for SQL, which Biome + can't format). ## What nextkit provides (reach for these) - `@ingram-tech/email` — Cloudflare email: `sendEmail`, `fromAddress` - `@ingram-tech/bot-protection` — invisible form protection (honeypot + timing + Vercel BotID) - `@ingram-tech/newsletter` — Supabase newsletter: subscribe / send, 1-click unsubscribe +- `@ingram-tech/nk-cli` — the `nk` command: `nk dev` (Next + local Supabase), plus `nk format` / `lint` / `check` / `type-check` / `build` - `@ingram-tech/biome-config` · `typescript-config` · `test-config` — shared config - `@ingram-tech/git-hooks` — Biome format-on-commit diff --git a/packages/bot-protection/README.md b/packages/bot-protection/README.md index e03b420..7ee314f 100644 --- a/packages/bot-protection/README.md +++ b/packages/bot-protection/README.md @@ -59,6 +59,51 @@ export async function POST(request: Request) { `verifyHuman` also accepts a plain object (`{ formData: { ...fields } }`) and a `timing` window (`{ minMs, maxMs }`). Pass `botid: false` to skip layer 3. +### Client forms (JSON POST) + +For client components that POST JSON instead of a server-rendered `
`, use +the `/react` hook. It fetches the token from your route's `GET` on mount and +hands you the fields to merge into the body. The route's `GET` returns the +token; its `POST` verifies: + +```tsx +"use client"; +import { HoneypotInput, useBotProtection } from "@ingram-tech/bot-protection/react"; + +export function ContactForm() { + const { honeypotRef, botFields } = useBotProtection("/api/contact"); + + async function onSubmit(values: FormValues) { + await fetch("/api/contact", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...values, ...botFields() }), + }); + } + return ( + + {/* ...your real fields... */} + + + ); +} +``` + +```ts +// app/api/contact/route.ts +import { createFormToken, verifyHuman } from "@ingram-tech/bot-protection"; + +export const GET = () => Response.json({ token: createFormToken() }); + +export async function POST(request: Request) { + const body = await request.json(); + const result = await verifyHuman({ formData: body }); + if (!result.ok) return Response.json({ ok: true }); // silently drop + // ...send the email / save the lead... + return Response.json({ ok: true }); +} +``` + The honeypot field defaults to a name browsers and password managers won't autofill (filling it would falsely flag real users). If that default collides with a real field in your form, override it on both sides — they must match: diff --git a/packages/bot-protection/package.json b/packages/bot-protection/package.json index 775b698..f6707b5 100644 --- a/packages/bot-protection/package.json +++ b/packages/bot-protection/package.json @@ -24,6 +24,10 @@ "types": "./dist/honeypot.d.ts", "import": "./dist/honeypot.js" }, + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js" + }, "./fields": { "types": "./dist/fields.d.ts", "import": "./dist/fields.js" diff --git a/packages/bot-protection/src/react.tsx b/packages/bot-protection/src/react.tsx new file mode 100644 index 0000000..70726eb --- /dev/null +++ b/packages/bot-protection/src/react.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { type RefObject, useEffect, useRef, useState } from "react"; +import { HONEYPOT_FIELD, TOKEN_FIELD } from "./fields"; + +/** + * Client-side bot protection for forms that POST JSON to your own route. + * + * Fetches a signed timing token from `tokenEndpoint` on mount — the route's GET + * handler should return `{ token: createFormToken() }` — then exposes a honeypot + * ref plus a `botFields()` helper returning the fields to spread into the + * request body. The matching POST handler calls `verifyHuman({ formData: body })`. + * + * Pair with {@link HoneypotInput}, which renders the trap and wires the ref. + * + * @param tokenEndpoint - GET route that mints the token (e.g. "/api/contact"). + * + * @example + * const { honeypotRef, botFields } = useBotProtection("/api/contact"); + * // ...in the form: + * // ...on submit: body: JSON.stringify({ ...values, ...botFields() }) + */ +export function useBotProtection(tokenEndpoint: string) { + const [token, setToken] = useState(""); + const honeypotRef = useRef(null); + + useEffect(() => { + fetch(tokenEndpoint) + .then((res) => res.json()) + .then((data: { token?: string }) => setToken(data.token ?? "")) + .catch(() => {}); + }, [tokenEndpoint]); + + const botFields = (): Record => ({ + [HONEYPOT_FIELD]: honeypotRef.current?.value ?? "", + [TOKEN_FIELD]: token, + }); + + return { honeypotRef, botFields }; +} + +/** + * Visually-hidden honeypot input for {@link useBotProtection}. Real users never + * see or fill it (off-screen, aria-hidden, tabIndex -1, autofill opted out); + * bots that fill every field give themselves away. + */ +export function HoneypotInput({ + inputRef, +}: { + inputRef: RefObject; +}) { + return ( + + ); +} diff --git a/packages/bot-protection/tsconfig.json b/packages/bot-protection/tsconfig.json index ae61b66..518f9aa 100644 --- a/packages/bot-protection/tsconfig.json +++ b/packages/bot-protection/tsconfig.json @@ -4,6 +4,8 @@ "outDir": "dist", "rootDir": "src", "jsx": "react-jsx", + // The client export (react.tsx) touches fetch + DOM element types. + "lib": ["esnext", "dom", "dom.iterable"], "types": ["node", "react"] }, "include": ["src"], diff --git a/packages/nk-cli/README.md b/packages/nk-cli/README.md new file mode 100644 index 0000000..5a6e164 --- /dev/null +++ b/packages/nk-cli/README.md @@ -0,0 +1,61 @@ +# @ingram-tech/nk-cli + +`nk` — the nextkit CLI. One command for the things every Ingram Next.js site +does the same way, so they aren't reimplemented as per-site shell scripts. + +## Install + +```sh +bun add -D @ingram-tech/nk-cli +``` + +Then point your package.json scripts at it: + +```jsonc +{ + "scripts": { + "dev": "nk dev", + "format": "nk format", + "lint": "nk lint", + "check": "nk check", + "type-check": "nk type-check", + "build": "nk build" + } +} +``` + +`nk` shells out to the site's own `bunx`-resolved tools (Biome, Next, tsc, +Supabase), so versions stay under each site's control — nk just orchestrates. + +## Commands + +- **`nk dev`** — start the Next dev server. If `supabase/config.toml` is present, + it runs `supabase start`, reads `supabase status` into the env var names our + apps expect (`SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, + `SUPABASE_SECRET_KEY`), then launches `next dev --turbopack`. No Supabase dir → + it just starts Next. Replaces hand-written `scripts/dev.sh`. +- **`nk format` / `nk format --check`** — formats code (JS/TS/JSON/CSS) with + Biome and SQL with Prettier. `--check` verifies without writing (CI). +- **`nk lint`** — `biome lint .` +- **`nk check`** — `biome check .` (lint + format) plus SQL format verification. + The CI gate. +- **`nk type-check`** — `next typegen && tsc --noEmit`. +- **`nk build [...]`** — `next build`, extra args passed through. + +## Why Prettier for SQL? + +Biome is the formatter for code and stays that way — Prettier is never used for +JS/TS. But Biome can't format SQL, so `nk` bundles `prettier` + +`prettier-plugin-sql` **as its own dependencies** and uses them only for `.sql` +files. Prettier therefore never lands in any app's `package.json`. A site's own +`.prettierrc` / package.json `"prettier"` settings are honored if present; +otherwise nk defaults to tabs + the Postgres dialect. + +## Swapping the formatter + +Biome is the default. The code formatter is behind a small indirection, so when +oxc's formatter is GA you can switch a single site with: + +```jsonc +{ "nk": { "formatter": "oxc" } } +``` diff --git a/packages/nk-cli/bin/nk.js b/packages/nk-cli/bin/nk.js new file mode 100755 index 0000000..66a68ca --- /dev/null +++ b/packages/nk-cli/bin/nk.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import { dev } from "../lib/dev.js"; +import { format } from "../lib/format.js"; +import { build, check, lint, typeCheck } from "../lib/passthrough.js"; + +const USAGE = `nk — the nextkit CLI + +Usage: nk [options] + +Commands: + dev Start the Next dev server. If supabase/config.toml exists, + boots local Supabase first and wires its env in. + format [--check] Format code with the configured formatter (Biome) and SQL + with Prettier. --check verifies without writing (for CI). + lint Lint with the configured formatter. + check Lint + format verification (the CI gate). + type-check next typegen && tsc --noEmit. + build [...] next build (extra args passed through). + +The formatter is Biome by default. Switch it per-site with +{ "nk": { "formatter": "oxc" } } in package.json.`; + +const [cmd, ...rest] = process.argv.slice(2); + +switch (cmd) { + case "dev": + dev(rest); + break; + case "format": + await format({ check: rest.includes("--check") }); + break; + case "lint": + lint(); + break; + case "check": + await check(); + break; + case "type-check": + typeCheck(); + break; + case "build": + build(rest); + break; + case "help": + case "--help": + case "-h": + case undefined: + console.log(USAGE); + break; + default: + console.error(`nk: unknown command "${cmd}"\n`); + console.log(USAGE); + process.exit(1); +} diff --git a/packages/nk-cli/lib/config.js b/packages/nk-cli/lib/config.js new file mode 100644 index 0000000..c1306e2 --- /dev/null +++ b/packages/nk-cli/lib/config.js @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Read the optional `"nk"` block from the consuming site's package.json, e.g. + * `{ "nk": { "formatter": "oxc" } }`. Returns `{}` when absent or unreadable. + */ +export function readNkConfig(cwd = process.cwd()) { + try { + const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf8")); + return pkg.nk ?? {}; + } catch { + return {}; + } +} diff --git a/packages/nk-cli/lib/dev.js b/packages/nk-cli/lib/dev.js new file mode 100644 index 0000000..f7b6fae --- /dev/null +++ b/packages/nk-cli/lib/dev.js @@ -0,0 +1,66 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { capture, fail, run } from "./run.js"; + +const SUPABASE_CONFIG = "supabase/config.toml"; + +// The house mapping from Supabase's local status output to the env var names our +// apps actually read (the publishable/secret-key naming @supabase/ssr expects). +// Undocumented `supabase status` override keys — see peppost's old dev.sh. +const STATUS_OVERRIDES = [ + "--override-name", + "api.url=SUPABASE_URL", + "--override-name", + "auth.publishable_key=NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", + "--override-name", + "auth.secret_key=SUPABASE_SECRET_KEY", +]; + +/** + * `nk dev` — start the Next dev server. If the site has a local Supabase + * (`supabase/config.toml`), boot it first and inject its connection env so the + * app talks to the local stack. The standard, correct way to run a Supabase + + * Next site locally, so no per-site dev.sh to maintain. + */ +export function dev(extraArgs = []) { + const env = { ...process.env }; + + if (existsSync(resolve(process.cwd(), SUPABASE_CONFIG))) { + console.log("nk: supabase/config.toml found — starting local Supabase…"); + if (run("supabase", ["start"]) !== 0) fail("`supabase start` failed."); + const statusEnv = capture("supabase", [ + "status", + "-o", + "env", + ...STATUS_OVERRIDES, + ]); + Object.assign(env, parseEnv(statusEnv)); + } + + // Hand off to Next. spawnSync inherits stdio and blocks until exit, so + // Ctrl-C reaches the dev server. + const res = spawnSync("bunx", ["next", "dev", "--turbopack", ...extraArgs], { + stdio: "inherit", + env, + }); + process.exit(res.status ?? 0); +} + +/** Parse `KEY=value` / `KEY="value"` lines from `supabase status -o env`. */ +function parseEnv(text) { + const out = {}; + for (const line of text.split("\n")) { + const match = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (!match) continue; + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[match[1]] = value; + } + return out; +} diff --git a/packages/nk-cli/lib/format.js b/packages/nk-cli/lib/format.js new file mode 100644 index 0000000..63ab250 --- /dev/null +++ b/packages/nk-cli/lib/format.js @@ -0,0 +1,116 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join, relative } from "node:path"; +import { resolveFormatter } from "./formatter.js"; +import { run } from "./run.js"; + +const require = createRequire(import.meta.url); + +// House SQL defaults, used only when the site has no Prettier config of its own +// (matches the studios' biome tab style + Supabase's Postgres dialect). +const SQL_DEFAULTS = { useTabs: true, language: "postgresql" }; + +/** + * `nk format` / `nk format --check`. + * + * Code (JS/TS/JSON/CSS) goes through the configured formatter (Biome); SQL goes + * through Prettier, which Biome can't format. Prettier + prettier-plugin-sql are + * bundled with nk-cli, so they never appear in any app's dependencies — the "no + * Prettier for code" rule still holds, it's just the one file type Biome lacks. + */ +export async function format({ check }) { + const formatter = resolveFormatter(); + + const op = check ? formatter.checkFormat : formatter.write; + if (op) { + const code = run(op[0], op[1]); + if (code !== 0) process.exitCode = code; + } else { + console.warn( + `nk: formatter "${formatter.name}" can't format code yet — skipping (SQL still handled).`, + ); + } + + await formatSql({ check }); +} + +/** Format (or, with `check`, verify) every tracked `.sql` file via Prettier. */ +export async function formatSql({ check }) { + const files = sqlFiles(); + if (files.length === 0) return; + + const prettier = require("prettier"); + const pluginPath = require.resolve("prettier-plugin-sql"); + + let unformatted = 0; + let written = 0; + for (const file of files) { + const source = readFileSync(file, "utf8"); + // The site's own .prettierrc / package.json "prettier" wins over our + // defaults; we always inject the bundled SQL plugin + parser. + const siteConfig = (await prettier.resolveConfig(file)) ?? {}; + const options = { + ...SQL_DEFAULTS, + ...siteConfig, + parser: "sql", + plugins: [ + pluginPath, + ...(siteConfig.plugins ?? []).filter( + (p) => !String(p).includes("prettier-plugin-sql"), + ), + ], + }; + + if (check) { + if (!(await prettier.check(source, options))) { + unformatted++; + console.error(` ${relative(process.cwd(), file)}`); + } + } else { + const out = await prettier.format(source, options); + if (out !== source) { + writeFileSync(file, out); + written++; + } + } + } + + if (check && unformatted > 0) { + console.error( + `nk: ${unformatted} SQL file(s) need formatting — run \`nk format\`.`, + ); + process.exitCode = 1; + } else if (!check && written > 0) { + console.log(`nk: formatted ${written} SQL file(s).`); + } +} + +/** Tracked + untracked-not-ignored `.sql` files; falls back to an fs walk. */ +function sqlFiles() { + const res = spawnSync( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard", "*.sql"], + { encoding: "utf8" }, + ); + if (res.status === 0) { + return res.stdout + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + } + return walkSql(process.cwd()); +} + +const SKIP_DIRS = new Set(["node_modules", ".next", ".git", "dist", "build"]); + +function walkSql(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walkSql(join(dir, entry.name), out); + } else if (entry.name.endsWith(".sql")) { + out.push(join(dir, entry.name)); + } + } + return out; +} diff --git a/packages/nk-cli/lib/formatter.js b/packages/nk-cli/lib/formatter.js new file mode 100644 index 0000000..d838932 --- /dev/null +++ b/packages/nk-cli/lib/formatter.js @@ -0,0 +1,42 @@ +import { readNkConfig } from "./config.js"; + +/** + * The code formatter/linter is swappable so we can move off Biome later (e.g. + * to oxc) without touching every command or every site. Each entry maps the + * operations nk needs to a `[tool, args]` invocation; `null` means the tool + * doesn't (yet) support that operation. + * + * Biome is the default and only fully-wired option today. oxc is sketched so + * the switch is a one-word config change once its formatter (oxfmt) is GA. + */ +const FORMATTERS = { + biome: { + name: "biome", + write: ["biome", ["format", "--write", "."]], + checkFormat: ["biome", ["format", "."]], + lint: ["biome", ["lint", "."]], + check: ["biome", ["check", "."]], + }, + oxc: { + name: "oxc", + // oxc's formatter (oxfmt) isn't GA yet — nk skips code formatting and + // warns when it is, still formatting SQL via Prettier. + write: null, + checkFormat: null, + lint: ["oxlint", []], + check: ["oxlint", []], + }, +}; + +/** Resolve the configured formatter (default: biome). Exits on an unknown name. */ +export function resolveFormatter(cwd = process.cwd()) { + const name = readNkConfig(cwd).formatter ?? "biome"; + const formatter = FORMATTERS[name]; + if (!formatter) { + console.error( + `nk: unknown formatter "${name}" — expected one of: ${Object.keys(FORMATTERS).join(", ")}`, + ); + process.exit(1); + } + return formatter; +} diff --git a/packages/nk-cli/lib/passthrough.js b/packages/nk-cli/lib/passthrough.js new file mode 100644 index 0000000..a19bc1c --- /dev/null +++ b/packages/nk-cli/lib/passthrough.js @@ -0,0 +1,30 @@ +import { formatSql } from "./format.js"; +import { resolveFormatter } from "./formatter.js"; +import { run } from "./run.js"; + +/** `nk lint` — lint with the configured formatter. */ +export function lint() { + const formatter = resolveFormatter(); + process.exit(run(formatter.lint[0], formatter.lint[1])); +} + +/** `nk check` — the CI gate: lint + format verify (code) plus SQL format verify. */ +export async function check() { + const formatter = resolveFormatter(); + let failed = run(formatter.check[0], formatter.check[1]) !== 0; + await formatSql({ check: true }); + if (process.exitCode) failed = true; + process.exit(failed ? 1 : 0); +} + +/** `nk type-check` — the house type-check: regenerate Next's types, then tsc. */ +export function typeCheck() { + const typegen = run("next", ["typegen"]); + if (typegen !== 0) process.exit(typegen); + process.exit(run("tsc", ["--noEmit"])); +} + +/** `nk build [...]` — next build, with extra args passed through. */ +export function build(extraArgs = []) { + process.exit(run("next", ["build", ...extraArgs])); +} diff --git a/packages/nk-cli/lib/run.js b/packages/nk-cli/lib/run.js new file mode 100644 index 0000000..66caeb6 --- /dev/null +++ b/packages/nk-cli/lib/run.js @@ -0,0 +1,44 @@ +import { spawnSync } from "node:child_process"; + +/** + * Run a site-local tool through `bunx` (resolves node_modules/.bin first) with + * inherited stdio. Returns the exit code; never throws on a non-zero exit. + */ +export function run(tool, args = [], opts = {}) { + const res = spawnSync("bunx", [tool, ...args], { stdio: "inherit", ...opts }); + if (res.error) { + if (res.error.code === "ENOENT") { + fail("could not run `bunx` — is bun installed and on PATH?"); + } + throw res.error; + } + return res.status ?? 0; +} + +/** + * Run a tool capturing its stdout (stderr still streams to the terminal). + * Exits the process if the tool fails — callers depend on the captured output. + */ +export function capture(tool, args = [], opts = {}) { + const res = spawnSync("bunx", [tool, ...args], { + encoding: "utf8", + stdio: ["inherit", "pipe", "inherit"], + ...opts, + }); + if (res.error) { + if (res.error.code === "ENOENT") { + fail("could not run `bunx` — is bun installed and on PATH?"); + } + throw res.error; + } + if (res.status !== 0) { + fail(`\`${tool} ${args.join(" ")}\` exited with ${res.status}`); + } + return res.stdout ?? ""; +} + +/** Print an `nk:`-prefixed error and exit non-zero. */ +export function fail(message) { + console.error(`nk: ${message}`); + process.exit(1); +} diff --git a/packages/nk-cli/package.json b/packages/nk-cli/package.json new file mode 100644 index 0000000..fe2a4e1 --- /dev/null +++ b/packages/nk-cli/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ingram-tech/nk-cli", + "version": "0.0.0", + "description": "nk — the nextkit CLI. One command for dev (Next + local Supabase) and formatting (Biome for code, Prettier for SQL) across Ingram Next.js sites.", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/ingram-technologies/nextkit.git", + "directory": "packages/nk-cli" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "bin", + "lib" + ], + "bin": { + "nk": "bin/nk.js" + }, + "scripts": { + "build": "true", + "type-check": "true", + "test": "true" + }, + "dependencies": { + "prettier": "^3.8.3", + "prettier-plugin-sql": "^0.20.0" + }, + "engines": { + "node": ">=20" + } +} From 8a2e1d33759c3df8370b5301df2aa43aacb4a4e6 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Mon, 25 May 2026 17:39:18 +0200 Subject: [PATCH 2/2] Build before type-check in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI job ran type-check before build, but newsletter's type-check resolves @ingram-tech/email's types from its built dist/*.d.ts — which didn't exist yet. This is why CI had been red. Reorder to check → build → type-check → test (check still runs first on the clean tree, so Biome never sees dist/). Same fix applied to the root `ci` script for local parity. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 7 +++++-- package.json | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aef52d..1cd9473 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,12 @@ jobs: - run: bun install --frozen-lockfile - name: Lint & format check run: bun run check - - name: Type-check - run: bun run type-check + # Build before type-check: packages whose deps resolve to built workspace + # types (e.g. newsletter → email's dist/*.d.ts) need dist to exist first. + # check runs first on the clean tree, so Biome never sees dist/. - name: Build run: bun run build + - name: Type-check + run: bun run type-check - name: Test run: bun run test diff --git a/package.json b/package.json index a5b03b9..0a85f69 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check": "biome check .", "type-check": "bun run --filter '*' type-check", "test": "bun run --filter '*' test", - "ci": "bun run check && bun run type-check && bun run test", + "ci": "bun run check && bun run build && bun run type-check && bun run test", "changeset": "changeset", "version-packages": "changeset version", "release": "bun run build && changeset publish",