@ingram-tech/nk-dev 0.7.0 → 0.9.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/guide.md +6 -0
- package/lib/oxlint-plugins/icu-arguments.js +79 -0
- package/lib/oxlint-plugins/index.js +6 -0
- package/lib/oxlint-plugins/no-crypto-random-uuid.js +117 -0
- package/lib/oxlint-plugins/t-no-positional-args.js +52 -0
- package/lib/oxlint-plugins/t-requires-values.js +92 -0
- package/oxlintrc.json +12 -1
- package/package.json +1 -1
package/guide.md
CHANGED
|
@@ -77,6 +77,12 @@ the UI/page tree, and never expose internal plumbing under `/api/`.
|
|
|
77
77
|
`@ingram-tech/nk-db`'s drift-aware runner (`@ingram-tech/nk-db/migrate`), which
|
|
78
78
|
surfaces the real Postgres error and pre-flights journal drift. Generate **and
|
|
79
79
|
apply** in the same step; don't leave "run the migration" as a handoff.
|
|
80
|
+
- **A migration that moves data asserts how much it moved.** Any backfill /
|
|
81
|
+
seed / copy counts the rows it expects, compares that to the `row_count` it
|
|
82
|
+
got (`get diagnostics`), and `raise exception`s on a mismatch — inside the
|
|
83
|
+
transaction, before commit. A blind move that touches nothing (an RLS mask, a
|
|
84
|
+
wrong `where`) otherwise reports success, and the drop of the source columns
|
|
85
|
+
in the same migration makes it unrecoverable.
|
|
80
86
|
- **`drizzle-kit` is GENERATE-ONLY — it must never apply schema.** Use it for
|
|
81
87
|
`drizzle-kit generate` (and `generate --custom` for a package-owned/raw SQL
|
|
82
88
|
migration). Applying is always **`nk-pg-migrate`** (the bin from
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Shared helper for the `t-*` oxlint rules: extract the ICU MessageFormat
|
|
2
|
+
// argument list from a message.
|
|
3
|
+
//
|
|
4
|
+
// Only depth-0 braces are argument positions. Braces nested inside a
|
|
5
|
+
// plural/select sub-message are ordinary text and must not be read as
|
|
6
|
+
// arguments -- `{count, plural, one {# item} other {# items}}` has exactly one
|
|
7
|
+
// argument, `count`, not three. A brace-depth scan gets this exactly right,
|
|
8
|
+
// which is why this lives in the linter rather than in the package's types:
|
|
9
|
+
// TypeScript template-literal types cannot match brace pairs, so the same check
|
|
10
|
+
// expressed as a type would report `# item` as an argument.
|
|
11
|
+
//
|
|
12
|
+
// A depth-0 brace whose contents do not open with an identifier or a number is
|
|
13
|
+
// not an argument at all -- it is literal text the author wrote. `{"a": 1}`,
|
|
14
|
+
// `{}`, and `body { color: red }` all fall out here and are skipped silently.
|
|
15
|
+
// That bias matters: in this i18n scheme the English source string *is* the
|
|
16
|
+
// catalog key, so an author cannot ICU-escape a stray brace without changing
|
|
17
|
+
// the key and every translation of it. Staying quiet on ambiguous braces is the
|
|
18
|
+
// only behaviour that leaves those messages usable.
|
|
19
|
+
|
|
20
|
+
// ICU argNameOrNumber, anchored at a brace: an identifier or a number, followed
|
|
21
|
+
// by the argument's `,` separator or its closing `}`.
|
|
22
|
+
const ARGUMENT_HEAD = /^\{\s*([A-Za-z_$][\w$]*|\d+)\s*[,}]/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The ICU arguments of `message`, in source order.
|
|
26
|
+
*
|
|
27
|
+
* Under-reports rather than over-reports. A message using ICU's apostrophe
|
|
28
|
+
* escaping for a literal brace (`Use '{' here`) leaves the scan's depth
|
|
29
|
+
* unbalanced, so later arguments go unseen -- a missed lint, never a false one.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} message
|
|
32
|
+
* @returns {{ name: string, positional: boolean }[]}
|
|
33
|
+
*/
|
|
34
|
+
export function icuArguments(message) {
|
|
35
|
+
const args = [];
|
|
36
|
+
let depth = 0;
|
|
37
|
+
for (let index = 0; index < message.length; index++) {
|
|
38
|
+
const char = message[index];
|
|
39
|
+
if (char === "}") {
|
|
40
|
+
if (depth > 0) depth--;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char !== "{") continue;
|
|
44
|
+
if (depth === 0) {
|
|
45
|
+
const match = ARGUMENT_HEAD.exec(message.slice(index));
|
|
46
|
+
if (match) {
|
|
47
|
+
args.push({ name: match[1], positional: /^\d+$/.test(match[1]) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
depth++;
|
|
51
|
+
}
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether `node` is a call to a translator: `t(...)`, by convention throughout
|
|
57
|
+
* the fleet (`const t = createT(locale, scope)` / `const t = useT({ fr, nl })`).
|
|
58
|
+
* Gating on the callee name keeps the rules inert everywhere else without
|
|
59
|
+
* needing to resolve the binding back to `createT`/`useT`.
|
|
60
|
+
*
|
|
61
|
+
* @param {{ callee: { type: string, name?: string } }} node
|
|
62
|
+
*/
|
|
63
|
+
export function isTranslatorCall(node) {
|
|
64
|
+
return node.callee.type === "Identifier" && node.callee.name === "t";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The message literal of a translator call, or `null` when the first argument
|
|
69
|
+
* is not a plain string literal (a runtime key -- nothing to check statically).
|
|
70
|
+
*
|
|
71
|
+
* @param {{ arguments: { type: string, value?: unknown }[] }} node
|
|
72
|
+
*/
|
|
73
|
+
export function messageLiteral(node) {
|
|
74
|
+
const first = node.arguments[0];
|
|
75
|
+
if (!first || first.type !== "Literal" || typeof first.value !== "string") {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return first;
|
|
79
|
+
}
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
import baseUi from "./base-ui.js";
|
|
6
6
|
import deferredCurrentTarget from "./deferred-current-target.js";
|
|
7
7
|
import lucideIconSuffix from "./lucide-icon-suffix.js";
|
|
8
|
+
import noCryptoRandomUuid from "./no-crypto-random-uuid.js";
|
|
8
9
|
import noRedirectOnlyPage from "./no-redirect-only-page.js";
|
|
9
10
|
import redundantUseStateType from "./redundant-usestate-type.js";
|
|
11
|
+
import tNoPositionalArgs from "./t-no-positional-args.js";
|
|
12
|
+
import tRequiresValues from "./t-requires-values.js";
|
|
10
13
|
|
|
11
14
|
export default {
|
|
12
15
|
meta: { name: "nextkit" },
|
|
@@ -14,7 +17,10 @@ export default {
|
|
|
14
17
|
...baseUi.rules,
|
|
15
18
|
...deferredCurrentTarget.rules,
|
|
16
19
|
...lucideIconSuffix.rules,
|
|
20
|
+
...noCryptoRandomUuid.rules,
|
|
17
21
|
...noRedirectOnlyPage.rules,
|
|
18
22
|
...redundantUseStateType.rules,
|
|
23
|
+
...tNoPositionalArgs.rules,
|
|
24
|
+
...tRequiresValues.rules,
|
|
19
25
|
},
|
|
20
26
|
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: keep `crypto.randomUUID()` off the id write
|
|
2
|
+
// path.
|
|
3
|
+
//
|
|
4
|
+
// nextkit ids are UUIDv7: time-ordered, so inserts land at the right edge of the
|
|
5
|
+
// primary-key B-tree instead of scattering across it. `crypto.randomUUID()` is
|
|
6
|
+
// v4 — uniformly random — so a single call site minting a stored id fragments
|
|
7
|
+
// that index while every other row in the table stays ordered. The mismatch is
|
|
8
|
+
// invisible until the table is large, which is exactly when it is expensive to
|
|
9
|
+
// undo.
|
|
10
|
+
//
|
|
11
|
+
// The mint is `uuidGenerateId()` from `@ingram-tech/nk-db/id`, already typed
|
|
12
|
+
// `Uuid`. Most rows need no mint at all: `uuid("id").primaryKey().default(sql`
|
|
13
|
+
// `uuidv7()`)` lets the database do it, and the app only mints when it needs the
|
|
14
|
+
// id *before* the insert (a client-chosen document PK it must also use as the
|
|
15
|
+
// storage object name).
|
|
16
|
+
//
|
|
17
|
+
// Deliberately not autofixable. The right replacement depends on what the value
|
|
18
|
+
// is, and one of the answers is "leave it alone":
|
|
19
|
+
//
|
|
20
|
+
// - a stored id -> uuidGenerateId(), or drop it for the column default
|
|
21
|
+
// - a bearer token / nonce -> keep crypto.randomUUID()
|
|
22
|
+
//
|
|
23
|
+
// v7 is the *wrong* choice for a secret. It spends 48 bits on a millisecond
|
|
24
|
+
// timestamp, leaving 74 random bits against v4's 122, and it leaks its own
|
|
25
|
+
// creation time to whoever holds it. Invitation tokens, OAuth `state`, password
|
|
26
|
+
// reset links and similar unguessable values must stay v4 — silence the rule at
|
|
27
|
+
// those call sites with a justified disable comment rather than "fixing" them:
|
|
28
|
+
//
|
|
29
|
+
// // oxlint-disable-next-line nextkit/no-crypto-random-uuid -- CSRF nonce, wants v4 entropy
|
|
30
|
+
//
|
|
31
|
+
// Test files are exempt via an override in the shared oxlintrc: test rows are
|
|
32
|
+
// ephemeral, so index locality is meaningless there and `crypto.randomUUID()`
|
|
33
|
+
// stays the zero-import default that keeps fixtures readable.
|
|
34
|
+
|
|
35
|
+
const NODE_CRYPTO_MODULES = new Set(["crypto", "node:crypto"]);
|
|
36
|
+
|
|
37
|
+
/** `crypto.randomUUID` or `globalThis.crypto.randomUUID`, and not shadowed. */
|
|
38
|
+
const isGlobalCryptoRandomUuid = (callee, scope) => {
|
|
39
|
+
if (callee.type !== "MemberExpression") return false;
|
|
40
|
+
if (callee.computed) return false;
|
|
41
|
+
if (callee.property.type !== "Identifier") return false;
|
|
42
|
+
if (callee.property.name !== "randomUUID") return false;
|
|
43
|
+
|
|
44
|
+
const object = callee.object;
|
|
45
|
+
let root;
|
|
46
|
+
if (object.type === "Identifier" && object.name === "crypto") {
|
|
47
|
+
root = object;
|
|
48
|
+
} else if (
|
|
49
|
+
object.type === "MemberExpression" &&
|
|
50
|
+
!object.computed &&
|
|
51
|
+
object.object.type === "Identifier" &&
|
|
52
|
+
object.object.name === "globalThis" &&
|
|
53
|
+
object.property.type === "Identifier" &&
|
|
54
|
+
object.property.name === "crypto"
|
|
55
|
+
) {
|
|
56
|
+
// `globalThis.crypto` can't be shadowed by a local binding.
|
|
57
|
+
return true;
|
|
58
|
+
} else {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A local `crypto` (a mock, an injected dependency) is not the global one.
|
|
63
|
+
for (let current = scope; current; current = current.upper) {
|
|
64
|
+
const variable = current.variables?.find((v) => v.name === root.name);
|
|
65
|
+
if (!variable) continue;
|
|
66
|
+
return variable.defs.length === 0;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const noCryptoRandomUuid = {
|
|
72
|
+
meta: {
|
|
73
|
+
type: "problem",
|
|
74
|
+
docs: {
|
|
75
|
+
description:
|
|
76
|
+
"Disallow crypto.randomUUID() (UUIDv4) where nextkit ids are UUIDv7",
|
|
77
|
+
},
|
|
78
|
+
messages: {
|
|
79
|
+
cryptoRandomUuid:
|
|
80
|
+
"`crypto.randomUUID()` is UUIDv4; stored ids are UUIDv7. Mint with `uuidGenerateId()` from `@ingram-tech/nk-db/id`, or omit the id and let the `uuidv7()` column default apply. If this is a bearer token or nonce, keep v4 and add `// oxlint-disable-next-line nextkit/no-crypto-random-uuid -- <reason>`.",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
create(context) {
|
|
84
|
+
const sourceCode = context.sourceCode;
|
|
85
|
+
// Local names bound to `randomUUID` imported from node:crypto.
|
|
86
|
+
const importedNames = new Set();
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ImportDeclaration(node) {
|
|
90
|
+
if (!NODE_CRYPTO_MODULES.has(node.source.value)) return;
|
|
91
|
+
for (const specifier of node.specifiers) {
|
|
92
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
93
|
+
if (specifier.imported.type !== "Identifier") continue;
|
|
94
|
+
if (specifier.imported.name !== "randomUUID") continue;
|
|
95
|
+
importedNames.add(specifier.local.name);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
CallExpression(node) {
|
|
99
|
+
const callee = node.callee;
|
|
100
|
+
const isImported =
|
|
101
|
+
callee.type === "Identifier" && importedNames.has(callee.name);
|
|
102
|
+
if (
|
|
103
|
+
!isImported &&
|
|
104
|
+
!isGlobalCryptoRandomUuid(callee, sourceCode.getScope(node))
|
|
105
|
+
) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
context.report({ node, messageId: "cryptoRandomUuid" });
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default {
|
|
115
|
+
meta: { name: "nextkit" },
|
|
116
|
+
rules: { "no-crypto-random-uuid": noCryptoRandomUuid },
|
|
117
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: forbid positional ICU arguments (`{0}`, `{1}`)
|
|
2
|
+
// in `t()` messages.
|
|
3
|
+
//
|
|
4
|
+
// ICU MessageFormat permits numbered arguments, but they are wrong for this
|
|
5
|
+
// i18n scheme in two ways. The English source string is the catalog key and is
|
|
6
|
+
// what a translator reads, so `{0}` gives them no clue what the value is, and
|
|
7
|
+
// reordering it for another language's word order becomes guesswork. Named
|
|
8
|
+
// arguments carry that context in the key itself.
|
|
9
|
+
//
|
|
10
|
+
// Banning them also lets the runtime stay conservative about what counts as a
|
|
11
|
+
// placeholder: `@ingram-tech/nk-i18n` only treats identifier-headed braces as
|
|
12
|
+
// arguments, so prose and embedded JSON (`t('This is JSON: {"a": 1}')`) are
|
|
13
|
+
// passed through untouched. That heuristic would have to admit digits -- and
|
|
14
|
+
// with them `{2: "x"}` -- if positional arguments were allowed anywhere.
|
|
15
|
+
|
|
16
|
+
import { icuArguments, isTranslatorCall, messageLiteral } from "./icu-arguments.js";
|
|
17
|
+
|
|
18
|
+
const tNoPositionalArgs = {
|
|
19
|
+
meta: {
|
|
20
|
+
type: "problem",
|
|
21
|
+
docs: {
|
|
22
|
+
description: "Forbid positional ICU arguments in t() messages",
|
|
23
|
+
},
|
|
24
|
+
messages: {
|
|
25
|
+
positionalArgument:
|
|
26
|
+
"`t()` message uses the positional placeholder `{{{name}}}`. Name it instead -- the English source is the catalog key, so translators read the placeholder and may need to reorder it.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
create(context) {
|
|
30
|
+
return {
|
|
31
|
+
CallExpression(node) {
|
|
32
|
+
if (!isTranslatorCall(node)) return;
|
|
33
|
+
const message = messageLiteral(node);
|
|
34
|
+
if (!message) return;
|
|
35
|
+
|
|
36
|
+
for (const arg of icuArguments(message.value)) {
|
|
37
|
+
if (!arg.positional) continue;
|
|
38
|
+
context.report({
|
|
39
|
+
node: message,
|
|
40
|
+
messageId: "positionalArgument",
|
|
41
|
+
data: { name: arg.name },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
meta: { name: "nextkit" },
|
|
51
|
+
rules: { "t-no-positional-args": tNoPositionalArgs },
|
|
52
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// nextkit oxlint JS plugin rule: a `t()` message with ICU placeholders must be
|
|
2
|
+
// passed the values those placeholders need.
|
|
3
|
+
//
|
|
4
|
+
// `@ingram-tech/nk-i18n`'s translator returns the message unformatted when no
|
|
5
|
+
// values argument is given -- it never reaches IntlMessageFormat, so nothing
|
|
6
|
+
// throws and nothing warns. `t("Results for {query}")` therefore ships the
|
|
7
|
+
// literal text `Results for {query}` to users with no runtime signal at all.
|
|
8
|
+
// Every other failure in that package degrades loudly; this one is silent,
|
|
9
|
+
// which makes it the one worth catching at author time.
|
|
10
|
+
//
|
|
11
|
+
// Two reports:
|
|
12
|
+
// - the values argument is missing entirely, while the message has arguments;
|
|
13
|
+
// - the values argument is an object literal that omits a required key, which
|
|
14
|
+
// is what a misspelling looks like (`t("… {query}", { qeury })`).
|
|
15
|
+
//
|
|
16
|
+
// When the values argument is anything other than a plain object literal (a
|
|
17
|
+
// variable, a call, a spread), only the first check applies -- the keys are not
|
|
18
|
+
// statically known and guessing would produce false reports.
|
|
19
|
+
|
|
20
|
+
import { icuArguments, isTranslatorCall, messageLiteral } from "./icu-arguments.js";
|
|
21
|
+
|
|
22
|
+
const tRequiresValues = {
|
|
23
|
+
meta: {
|
|
24
|
+
type: "problem",
|
|
25
|
+
docs: {
|
|
26
|
+
description: "Require values for the ICU placeholders in a t() message",
|
|
27
|
+
},
|
|
28
|
+
messages: {
|
|
29
|
+
missingValues:
|
|
30
|
+
"`t()` message uses the placeholder{{plural}} {{names}} but no values argument was passed. The placeholder text is rendered to users verbatim.",
|
|
31
|
+
missingValue:
|
|
32
|
+
"`t()` values object is missing `{{name}}`, required by the message's `{{{name}}}` placeholder.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
create(context) {
|
|
36
|
+
return {
|
|
37
|
+
CallExpression(node) {
|
|
38
|
+
if (!isTranslatorCall(node)) return;
|
|
39
|
+
const message = messageLiteral(node);
|
|
40
|
+
if (!message) return;
|
|
41
|
+
|
|
42
|
+
const args = icuArguments(message.value);
|
|
43
|
+
if (args.length === 0) return;
|
|
44
|
+
|
|
45
|
+
const names = args.map((arg) => arg.name);
|
|
46
|
+
const values = node.arguments[1];
|
|
47
|
+
if (!values) {
|
|
48
|
+
context.report({
|
|
49
|
+
node,
|
|
50
|
+
messageId: "missingValues",
|
|
51
|
+
data: {
|
|
52
|
+
plural: names.length === 1 ? "" : "s",
|
|
53
|
+
names: names.map((name) => `\`${name}\``).join(", "),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (values.type !== "ObjectExpression") return;
|
|
60
|
+
// A spread can supply anything; the key set is no longer known.
|
|
61
|
+
if (
|
|
62
|
+
values.properties.some((property) => property.type !== "Property")
|
|
63
|
+
) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const provided = new Set();
|
|
67
|
+
for (const property of values.properties) {
|
|
68
|
+
if (property.computed) return;
|
|
69
|
+
if (property.key.type === "Identifier")
|
|
70
|
+
provided.add(property.key.name);
|
|
71
|
+
else if (property.key.type === "Literal") {
|
|
72
|
+
provided.add(String(property.key.value));
|
|
73
|
+
} else return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
if (provided.has(name)) continue;
|
|
78
|
+
context.report({
|
|
79
|
+
node: values,
|
|
80
|
+
messageId: "missingValue",
|
|
81
|
+
data: { name },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export default {
|
|
90
|
+
meta: { name: "nextkit" },
|
|
91
|
+
rules: { "t-requires-values": tRequiresValues },
|
|
92
|
+
};
|
package/oxlintrc.json
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"nextkit/no-redundant-usestate-type": "warn",
|
|
11
11
|
"nextkit/lucide-icon-suffix": "warn",
|
|
12
12
|
"nextkit/no-redirect-only-page": "warn",
|
|
13
|
+
"nextkit/t-requires-values": "error",
|
|
14
|
+
"nextkit/t-no-positional-args": "error",
|
|
15
|
+
"nextkit/no-crypto-random-uuid": "warn",
|
|
13
16
|
"no-unused-vars": "warn",
|
|
14
17
|
"typescript/no-non-null-assertion": "error",
|
|
15
18
|
"typescript/no-explicit-any": "error",
|
|
@@ -26,5 +29,13 @@
|
|
|
26
29
|
"jsx-a11y/prefer-tag-over-role": "off",
|
|
27
30
|
"jsx-a11y/no-autofocus": "off",
|
|
28
31
|
"jsx-a11y/role-has-required-aria-props": "off"
|
|
29
|
-
}
|
|
32
|
+
},
|
|
33
|
+
"overrides": [
|
|
34
|
+
{
|
|
35
|
+
"files": ["**/__tests__/**", "**/*.test.*", "**/*.spec.*", "**/test/**"],
|
|
36
|
+
"rules": {
|
|
37
|
+
"nextkit/no-crypto-random-uuid": "off"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
]
|
|
30
41
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ingram-tech/nk-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|