@ahmednawaz/crank 0.2.5 → 0.2.7

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/bin/crank.js CHANGED
@@ -710,12 +710,9 @@ async function main() {
710
710
  });
711
711
  }
712
712
 
713
- // One-shot parity: setup/default always try inject on React apps (idempotent).
714
- if (
715
- (command === 'init' || command === 'run') &&
716
- detectEnv.hasReact &&
717
- flags.mcp !== false
718
- ) {
713
+ // One-shot: always try <Crank /> inject (idempotent). Do not gate on hasReact —
714
+ // root package.json often omits react in monorepos / Replit layouts.
715
+ if ((command === 'init' || command === 'run') && flags.mcp !== false) {
719
716
  const injectResult = injectCrank(projectRoot, { install: true });
720
717
  initResult = initResult || { wrote: [], projectRoot };
721
718
  initResult.inject = injectResult;
package/lib/detect.js CHANGED
@@ -123,6 +123,91 @@ function packageHasDep(pkg, name) {
123
123
  );
124
124
  }
125
125
 
126
+ const REACT_DEP_NAMES = [
127
+ 'react',
128
+ 'react-dom',
129
+ '@vitejs/plugin-react',
130
+ '@vitejs/plugin-react-swc',
131
+ 'next'
132
+ ];
133
+
134
+ const REACT_ENTRY_HINTS = [
135
+ 'src/main.tsx',
136
+ 'src/main.jsx',
137
+ 'src/index.tsx',
138
+ 'src/index.jsx',
139
+ 'client/src/main.tsx',
140
+ 'app/main.tsx',
141
+ 'main.tsx',
142
+ 'main.jsx',
143
+ 'index.tsx',
144
+ 'index.jsx'
145
+ ];
146
+
147
+ function packageLooksLikeReact(pkg) {
148
+ return REACT_DEP_NAMES.some((name) => packageHasDep(pkg, name));
149
+ }
150
+
151
+ function dirHasNextConfig(dir) {
152
+ return (
153
+ exists(path.join(dir, 'next.config.js')) ||
154
+ exists(path.join(dir, 'next.config.mjs')) ||
155
+ exists(path.join(dir, 'next.config.ts'))
156
+ );
157
+ }
158
+
159
+ function dirHasReactEntryHint(dir) {
160
+ return REACT_ENTRY_HINTS.some((rel) => exists(path.join(dir, rel)));
161
+ }
162
+
163
+ /**
164
+ * True when this directory (or a common app child) looks like a React app.
165
+ * Root package.json often omits react in monorepos / Replit multi-package layouts.
166
+ */
167
+ function detectHasReact(projectRoot, packageJson, viteConfig) {
168
+ if (packageLooksLikeReact(packageJson) || dirHasNextConfig(projectRoot)) {
169
+ return true;
170
+ }
171
+ if (dirHasReactEntryHint(projectRoot)) {
172
+ return true;
173
+ }
174
+
175
+ const dirs = new Set([projectRoot]);
176
+ for (const sub of APP_DIR_CANDIDATES) {
177
+ if (sub === '.') continue;
178
+ dirs.add(path.join(projectRoot, sub));
179
+ }
180
+ if (viteConfig) {
181
+ dirs.add(path.dirname(viteConfig));
182
+ }
183
+
184
+ for (const dir of dirs) {
185
+ if (!exists(dir) || dir === projectRoot) continue;
186
+ const pkg = readJson(path.join(dir, 'package.json'), null);
187
+ if (packageLooksLikeReact(pkg) || dirHasNextConfig(dir) || dirHasReactEntryHint(dir)) {
188
+ return true;
189
+ }
190
+ }
191
+
192
+ // Vite + JSX plugin import in config is a strong React signal
193
+ if (viteConfig && exists(viteConfig)) {
194
+ try {
195
+ const src = fs.readFileSync(viteConfig, 'utf8');
196
+ if (
197
+ /@vitejs\/plugin-react/.test(src) ||
198
+ /\bplugin-react\b/.test(src) ||
199
+ /from ['"]react['"]/.test(src)
200
+ ) {
201
+ return true;
202
+ }
203
+ } catch {
204
+ // ignore
205
+ }
206
+ }
207
+
208
+ return false;
209
+ }
210
+
126
211
  function detectEnvironment(projectRoot) {
127
212
  const env = process.env;
128
213
  const isReplit = Boolean(
@@ -134,16 +219,13 @@ function detectEnvironment(projectRoot) {
134
219
  : null;
135
220
  const viteConfig = findViteConfig(projectRoot);
136
221
  const hasVite = Boolean(viteConfig) || packageHasDep(packageJson, 'vite');
137
- const hasReact =
138
- packageHasDep(packageJson, 'react') ||
139
- exists(path.join(projectRoot, 'next.config.js')) ||
140
- exists(path.join(projectRoot, 'next.config.mjs')) ||
141
- exists(path.join(projectRoot, 'next.config.ts'));
222
+ const hasReact = detectHasReact(projectRoot, packageJson, viteConfig);
142
223
  const hasNext =
143
224
  packageHasDep(packageJson, 'next') ||
144
- exists(path.join(projectRoot, 'next.config.js')) ||
145
- exists(path.join(projectRoot, 'next.config.mjs')) ||
146
- exists(path.join(projectRoot, 'next.config.ts'));
225
+ dirHasNextConfig(projectRoot) ||
226
+ [...APP_DIR_CANDIDATES]
227
+ .filter((s) => s !== '.')
228
+ .some((sub) => dirHasNextConfig(path.join(projectRoot, sub)));
147
229
  const hasWebpack =
148
230
  packageHasDep(packageJson, 'webpack') ||
149
231
  exists(path.join(projectRoot, 'webpack.config.js')) ||
@@ -182,6 +264,8 @@ module.exports = {
182
264
  readJson,
183
265
  writeJson,
184
266
  detectEnvironment,
267
+ detectHasReact,
268
+ packageLooksLikeReact,
185
269
  findViteConfig,
186
270
  VITE_CONFIG_NAMES,
187
271
  APP_DIR_CANDIDATES
package/lib/init.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
 
13
13
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
14
14
  const { resolveUrls } = require('./urls');
15
- const { installCrankPackage } = require('./wire-react');
15
+ const { installCrankPackage, findEntry } = require('./wire-react');
16
16
 
17
17
  function ensureGitignore(projectRoot) {
18
18
  const file = path.join(projectRoot, '.gitignore');
@@ -187,6 +187,98 @@ module.exports = {
187
187
  return stubPath;
188
188
  }
189
189
 
190
+ const NODE_IMPORT_SPECS = [
191
+ { re: /import\s+\{\s*createRequire\s*\}\s+from\s+['"]node:module['"]\s*;?/, line: "import { createRequire } from 'node:module'" },
192
+ { re: /import\s+\{\s*existsSync\s*\}\s+from\s+['"]node:fs['"]\s*;?/, line: "import { existsSync } from 'node:fs'" },
193
+ { re: /import\s+\{\s*fileURLToPath\s*\}\s+from\s+['"]node:url['"]\s*;?/, line: "import { fileURLToPath } from 'node:url'" }
194
+ ];
195
+
196
+ function hasCrankVitePatch(source) {
197
+ return (
198
+ /_crankPluginPath/.test(source) ||
199
+ /vite-plugin\.cjs/.test(source) ||
200
+ /Crank Vite plugin/.test(source) ||
201
+ (/\bcrank\s*\?\s*\[\s*crank\s*\(\s*\)\s*\]/.test(source) && /plugins/.test(source)) ||
202
+ (/(\bcrank)\s*\(/.test(source) && /plugins/.test(source) && /\.crank\//.test(source))
203
+ );
204
+ }
205
+
206
+ /** Drop later duplicate ESM imports of the Node builtins Crank injects. */
207
+ function dedupeCrankNodeImports(source) {
208
+ let next = source;
209
+ let changed = false;
210
+ for (const { re } of NODE_IMPORT_SPECS) {
211
+ const globalRe = new RegExp(re.source, 'g');
212
+ const matches = next.match(globalRe);
213
+ if (!matches || matches.length < 2) continue;
214
+ let seen = 0;
215
+ next = next.replace(globalRe, (m) => {
216
+ seen += 1;
217
+ if (seen === 1) return m;
218
+ changed = true;
219
+ return '';
220
+ });
221
+ // Clean blank lines left by removals (keep file readable)
222
+ next = next.replace(/\n{3,}/g, '\n\n');
223
+ }
224
+ return { source: next, changed };
225
+ }
226
+
227
+ function missingNodeImportLines(source) {
228
+ return NODE_IMPORT_SPECS.filter(({ re }) => !re.test(source)).map(({ line }) => line);
229
+ }
230
+
231
+ function ensureCrankInPlugins(source) {
232
+ if (/\bcrank\s*\?\s*\[\s*crank\s*\(\s*\)\s*\]/.test(source) || /\[\s*\.\.\.\s*\(\s*crank\s*\?/.test(source)) {
233
+ return { source, changed: false, ok: true };
234
+ }
235
+ if (/plugins\s*:\s*\[\s*\]/.test(source)) {
236
+ return {
237
+ source: source.replace(/plugins\s*:\s*\[\s*\]/, 'plugins: [...(crank ? [crank()] : [])]'),
238
+ changed: true,
239
+ ok: true
240
+ };
241
+ }
242
+ if (/plugins\s*:\s*\[/.test(source)) {
243
+ return {
244
+ source: source.replace(/plugins\s*:\s*\[/, 'plugins: [...(crank ? [crank()] : []), '),
245
+ changed: true,
246
+ ok: true
247
+ };
248
+ }
249
+ if (/defineConfig\s*\(\s*\{/.test(source)) {
250
+ return {
251
+ source: source.replace(
252
+ /defineConfig\s*\(\s*\{/,
253
+ 'defineConfig({\n plugins: [...(crank ? [crank()] : [])],'
254
+ ),
255
+ changed: true,
256
+ ok: true
257
+ };
258
+ }
259
+ if (/export\s+default\s+\{/.test(source)) {
260
+ return {
261
+ source: source.replace(
262
+ /export\s+default\s+\{/,
263
+ 'export default {\n plugins: [...(crank ? [crank()] : [])],'
264
+ ),
265
+ changed: true,
266
+ ok: true
267
+ };
268
+ }
269
+ if (/module\.exports\s*=\s*\{/.test(source)) {
270
+ return {
271
+ source: source.replace(
272
+ /module\.exports\s*=\s*\{/,
273
+ 'module.exports = {\n plugins: [...(crank ? [crank()] : [])],'
274
+ ),
275
+ changed: true,
276
+ ok: true
277
+ };
278
+ }
279
+ return { source, changed: false, ok: false };
280
+ }
281
+
190
282
  function patchViteConfig(projectRoot) {
191
283
  const configPath = findViteConfig(projectRoot);
192
284
  const stubPath = writeVitePluginStub(projectRoot);
@@ -196,11 +288,21 @@ function patchViteConfig(projectRoot) {
196
288
  }
197
289
 
198
290
  let source = fs.readFileSync(configPath, 'utf8');
199
- if (
200
- /vite-plugin\.cjs/.test(source) ||
201
- (/(\bcrank)\s*\(/.test(source) && /plugins/.test(source))
202
- ) {
203
- return { patched: false, reason: 'already-present', file: configPath, plugin: stubPath };
291
+
292
+ // Heal configs broken by older Crank versions (duplicate node: imports).
293
+ const repaired = dedupeCrankNodeImports(source);
294
+ if (repaired.changed) {
295
+ source = repaired.source;
296
+ fs.writeFileSync(configPath, source);
297
+ }
298
+
299
+ if (hasCrankVitePatch(source)) {
300
+ return {
301
+ patched: repaired.changed,
302
+ reason: repaired.changed ? 'repaired-duplicate-imports' : 'already-present',
303
+ file: configPath,
304
+ plugin: stubPath
305
+ };
204
306
  }
205
307
 
206
308
  let relStub = path.relative(path.dirname(configPath), stubPath).replace(/\\/g, '/');
@@ -218,11 +320,8 @@ function patchViteConfig(projectRoot) {
218
320
  const crankDecl = isTs
219
321
  ? 'let crank: null | (() => unknown) = null'
220
322
  : 'let crank = null';
221
- const gracefulBlock = `import { createRequire } from 'node:module'
222
- import { existsSync } from 'node:fs'
223
- import { fileURLToPath } from 'node:url'
224
-
225
- // Crank Vite plugin — graceful load (same pattern as Vizpatch); skip if unavailable
323
+ const importLines = missingNodeImportLines(source);
324
+ const gracefulBlock = `${importLines.length ? `${importLines.join('\n')}\n\n` : ''}// Crank Vite plugin — graceful load; skip if unavailable
226
325
  ${crankDecl}
227
326
  try {
228
327
  const _crankPluginPath = fileURLToPath(new URL('${relStub}', import.meta.url))
@@ -233,31 +332,14 @@ try {
233
332
  } catch {}
234
333
 
235
334
  `;
335
+ // If the file already uses createRequire/existsSync/fileURLToPath without
336
+ // importing them, missingNodeImportLines still adds the needed imports once.
236
337
  if (!/_crankPluginPath/.test(source)) {
237
338
  source = gracefulBlock + source;
238
339
  }
239
340
 
240
- if (/plugins\s*:\s*\[\s*\]/.test(source)) {
241
- source = source.replace(
242
- /plugins\s*:\s*\[\s*\]/,
243
- 'plugins: [...(crank ? [crank()] : [])]'
244
- );
245
- } else if (/plugins\s*:\s*\[/.test(source)) {
246
- source = source.replace(
247
- /plugins\s*:\s*\[/,
248
- 'plugins: [...(crank ? [crank()] : []), '
249
- );
250
- } else if (/defineConfig\s*\(\s*\{/.test(source)) {
251
- source = source.replace(
252
- /defineConfig\s*\(\s*\{/,
253
- 'defineConfig({\n plugins: [...(crank ? [crank()] : [])],'
254
- );
255
- } else if (/export\s+default\s+\{/.test(source)) {
256
- source = source.replace(
257
- /export\s+default\s+\{/,
258
- 'export default {\n plugins: [...(crank ? [crank()] : [])],'
259
- );
260
- } else {
341
+ const plugged = ensureCrankInPlugins(source);
342
+ if (!plugged.ok) {
261
343
  return {
262
344
  patched: false,
263
345
  reason: 'unrecognized-vite-shape',
@@ -265,33 +347,20 @@ try {
265
347
  plugin: stubPath
266
348
  };
267
349
  }
350
+ source = plugged.source;
268
351
  } else {
269
- const gracefulBlock = `// Crank Vite plugin — graceful load (same pattern as Vizpatch)
352
+ const gracefulBlock = `// Crank Vite plugin — graceful load; skip if unavailable
270
353
  let crank = null;
271
354
  try {
272
355
  crank = require('${relStub}').crank;
273
356
  } catch {}
274
357
 
275
358
  `;
276
- if (!/vite-plugin\.cjs/.test(source)) {
359
+ if (!/vite-plugin\.cjs/.test(source) && !/_crankPluginPath/.test(source)) {
277
360
  source = gracefulBlock + source;
278
361
  }
279
- if (/plugins\s*:\s*\[\s*\]/.test(source)) {
280
- source = source.replace(
281
- /plugins\s*:\s*\[\s*\]/,
282
- 'plugins: [...(crank ? [crank()] : [])]'
283
- );
284
- } else if (/plugins\s*:\s*\[/.test(source)) {
285
- source = source.replace(
286
- /plugins\s*:\s*\[/,
287
- 'plugins: [...(crank ? [crank()] : []), '
288
- );
289
- } else if (/module\.exports\s*=\s*\{/.test(source)) {
290
- source = source.replace(
291
- /module\.exports\s*=\s*\{/,
292
- 'module.exports = {\n plugins: [...(crank ? [crank()] : [])],'
293
- );
294
- } else {
362
+ const plugged = ensureCrankInPlugins(source);
363
+ if (!plugged.ok) {
295
364
  return {
296
365
  patched: false,
297
366
  reason: 'unrecognized-vite-shape',
@@ -299,12 +368,27 @@ try {
299
368
  plugin: stubPath
300
369
  };
301
370
  }
371
+ source = plugged.source;
302
372
  }
303
373
 
374
+ // Final safety: never leave duplicate node imports.
375
+ source = dedupeCrankNodeImports(source).source;
376
+
304
377
  fs.writeFileSync(configPath, source);
305
378
  return { patched: true, file: configPath, plugin: stubPath };
306
379
  }
307
380
 
381
+ /** Repair-only: dedupe node: imports left by older Crank Vite patches (React path). */
382
+ function repairViteConfigDuplicates(projectRoot) {
383
+ const configPath = findViteConfig(projectRoot);
384
+ if (!configPath) return { repaired: false, reason: 'no-vite-config' };
385
+ const source = fs.readFileSync(configPath, 'utf8');
386
+ const repaired = dedupeCrankNodeImports(source);
387
+ if (!repaired.changed) return { repaired: false, reason: 'clean', file: configPath };
388
+ fs.writeFileSync(configPath, repaired.source);
389
+ return { repaired: true, reason: 'repaired-duplicate-imports', file: configPath };
390
+ }
391
+
308
392
  function writeProjectEnvExample(projectRoot) {
309
393
  const dest = path.join(projectRoot, '.env.crank.example');
310
394
  const src = path.join(PACKAGE_ROOT, '.env.example');
@@ -536,10 +620,21 @@ function initProject(projectRoot, options = {}) {
536
620
  installCrankSkill(projectRoot, results);
537
621
  }
538
622
 
539
- // React apps: install dep + write out inject (Sentry/Clerk-style). Do not patch source here.
540
- if (env.hasReact && options.react !== false) {
623
+ // React apps: <Crank /> only do not inject the Vite plugin (avoids duplicating
624
+ // node: imports when vite.config already has Vizpatch-style loaders).
625
+ // Still repair duplicate imports left by older Crank versions.
626
+ // Treat findEntry hits as React even when root package.json omits the dep.
627
+ const looksLikeReact = env.hasReact || Boolean(findEntry(projectRoot));
628
+ if (looksLikeReact && options.react !== false) {
541
629
  writeReactMountHint(projectRoot, env, results);
542
630
  results.reactMount = true;
631
+ if (env.hasVite) {
632
+ const repaired = repairViteConfigDuplicates(projectRoot);
633
+ if (repaired.repaired) {
634
+ results.vite = repaired;
635
+ results.wrote.push(path.relative(projectRoot, repaired.file));
636
+ }
637
+ }
543
638
  } else if (env.hasVite && options.vite !== false) {
544
639
  const viteResult = patchViteConfig(projectRoot);
545
640
  results.vite = viteResult;
@@ -901,6 +996,7 @@ module.exports = {
901
996
  writeReplitPlatformGuide,
902
997
  writeVitePluginStub,
903
998
  patchViteConfig,
999
+ repairViteConfigDuplicates,
904
1000
  writeInjectionGuide,
905
1001
  writeReactMountHint,
906
1002
  patchReplitConfig,
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execSync } = require('child_process');
6
- const { exists, readJson } = require('./detect');
6
+ const { exists, readJson, findViteConfig, packageLooksLikeReact } = require('./detect');
7
7
 
8
8
  const APP_DIR_CANDIDATES = [
9
9
  '.',
@@ -37,6 +37,59 @@ function hasFrameworkMarker(dir) {
37
37
  return FRAMEWORK_FILES.some((name) => exists(path.join(dir, name)));
38
38
  }
39
39
 
40
+ function dirPackageLooksLikeReact(dir) {
41
+ return packageLooksLikeReact(readJson(path.join(dir, 'package.json'), null));
42
+ }
43
+
44
+ /**
45
+ * Prefer the real app package (Vite/React) over a bare repo-root package.json.
46
+ */
47
+ function findAppDirectory(repoRoot) {
48
+ const root = path.resolve(repoRoot);
49
+
50
+ // 1. Known app subdirs with a framework config (before root — monorepos)
51
+ for (const rel of APP_DIR_CANDIDATES) {
52
+ if (rel === '.') continue;
53
+ const candidate = path.join(root, rel);
54
+ if (exists(candidate) && hasFrameworkMarker(candidate)) {
55
+ return { appRoot: candidate, reason: `framework in ${rel}/` };
56
+ }
57
+ }
58
+
59
+ // 2. Vite config found via shallow search (e.g. artifacts/<app>/ on Replit)
60
+ const viteConfig = findViteConfig(root);
61
+ if (viteConfig) {
62
+ const viteDir = path.dirname(viteConfig);
63
+ if (viteDir !== root) {
64
+ // Prefer the vite package dir when it looks like React, or root does not.
65
+ if (dirPackageLooksLikeReact(viteDir) || !dirPackageLooksLikeReact(root)) {
66
+ return {
67
+ appRoot: viteDir,
68
+ reason: `vite.config in ${path.relative(root, viteDir) || '.'}`
69
+ };
70
+ }
71
+ }
72
+ }
73
+
74
+ // 3. Root has framework
75
+ if (hasFrameworkMarker(root)) {
76
+ return { appRoot: root, reason: 'framework at repo root' };
77
+ }
78
+
79
+ // 4. Subdir / root package.json
80
+ for (const rel of APP_DIR_CANDIDATES) {
81
+ const candidate = path.join(root, rel === '.' ? '' : rel);
82
+ if (exists(candidate) && exists(path.join(candidate, 'package.json'))) {
83
+ return {
84
+ appRoot: candidate,
85
+ reason: `package.json in ${rel === '.' ? 'root' : rel + '/'}`
86
+ };
87
+ }
88
+ }
89
+
90
+ return { appRoot: root, reason: 'repo root (fallback)' };
91
+ }
92
+
40
93
  function readCrankConfig(startDir) {
41
94
  let dir = path.resolve(startDir);
42
95
  for (let i = 0; i < 12; i++) {
@@ -95,33 +148,6 @@ function findPackageRoot(startDir) {
95
148
  return path.resolve(startDir);
96
149
  }
97
150
 
98
- /**
99
- * Retune-style: find the app directory inside a repo (app/, client/, web/, …).
100
- */
101
- function findAppDirectory(repoRoot) {
102
- const root = path.resolve(repoRoot);
103
- if (hasFrameworkMarker(root)) {
104
- return { appRoot: root, reason: 'framework at repo root' };
105
- }
106
-
107
- for (const rel of APP_DIR_CANDIDATES) {
108
- if (rel === '.') continue;
109
- const candidate = path.join(root, rel);
110
- if (exists(candidate) && hasFrameworkMarker(candidate)) {
111
- return { appRoot: candidate, reason: `framework in ${rel}/` };
112
- }
113
- }
114
-
115
- for (const rel of APP_DIR_CANDIDATES) {
116
- const candidate = path.join(root, rel === '.' ? '' : rel);
117
- if (exists(candidate) && exists(path.join(candidate, 'package.json'))) {
118
- return { appRoot: candidate, reason: `package.json in ${rel === '.' ? 'root' : rel + '/'}` };
119
- }
120
- }
121
-
122
- return { appRoot: root, reason: 'repo root (fallback)' };
123
- }
124
-
125
151
  /**
126
152
  * Resolve where the user's app code lives — no manual path required.
127
153
  * Priority: CRANK_PROJECT_ROOT → .crank/config → git root + app dir → package.json walk → cwd.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ahmednawaz/crank",
3
- "version": "0.2.5",
4
- "description": "Internal Motive UX copy iteration: select UI text Glean variants MCP apply. Cursor, Replit, Claude.",
3
+ "version": "0.2.7",
4
+ "description": "Internal Motive UX copy iteration: select UI text \u2192 Glean variants \u2192 MCP apply. Cursor, Replit, Claude.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/ahmednawaz619/crank.git"
@@ -66,7 +66,9 @@
66
66
  "test:resolve": "node scripts/test-resolve-project.js",
67
67
  "prepare:publish": "node scripts/prepare-publish.js",
68
68
  "verify:deps": "node scripts/verify-deps.js",
69
- "publish:team": "node scripts/prepare-publish.js && node scripts/verify-deps.js && npm publish --access public"
69
+ "publish:team": "node scripts/prepare-publish.js && node scripts/verify-deps.js && npm publish --access public",
70
+ "test:patch-vite": "node scripts/test-patch-vite.js",
71
+ "test:react-detect": "node scripts/test-react-detect.js"
70
72
  },
71
73
  "engines": {
72
74
  "node": ">=18"