locale-ai 0.6.5 → 0.6.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/README.md CHANGED
@@ -32,7 +32,7 @@ Download 577 MB? (Y/n)
32
32
  - **Placeholders are never translated or lost**: `{name}`, `{{count}}`, `%s`, `%1$s`, `%(name)s`, `%{name}`, `${name}`, `:attribute`, `$t(key)`, `@:key`, `<b>…</b>`, `<0>…</0>`, `&nbsp;`, URLs, e-mails.
33
33
  - **ICU MessageFormat**: `{count, plural, one {# file} other {# files}}`, `select`, nesting. Only the text is translated.
34
34
  - **i18next plurals**: adds the forms the target language needs (`_few`, `_many` for Arabic, Russian, Polish...), so the app doesn't fall back to the English string — each added form copies the `_other` translation verbatim rather than inflecting it, so review and adjust the wording of `_zero`/`_two`/`_few`/`_many` by hand for languages where those forms use a different noun/adjective form. The `_one` form is translated with its `{{count}}` temporarily replaced by a literal "1", since the model otherwise sometimes picks the target's plural noun form despite the English source being grammatically singular.
35
- - **Incremental**: only missing keys are translated, existing translations are kept, re-runs are no-ops. `--overwrite` re-translates everything.
35
+ - **Incremental, and edit-aware**: only missing or changed keys are translated; untouched translations are kept, so re-runs are no-ops. A small `locale-ai.lock.json` (commit it, like a package lock) remembers a hash of each source string and of the output last written for it per target locale — so editing a source string gets it re-translated automatically (it isn't enough for the target to already hold *some* text), while editing a target string by hand is detected and reported for review instead of being silently overwritten on the next run. `--overwrite` bypasses the lock and re-translates everything.
36
36
  - **Structure**: key order, numbers, booleans, arrays, indentation and trailing newline are preserved.
37
37
 
38
38
  **Format notes**: for `.po`, `msgid` is the stable key (never changed) and `msgstr` is the translated value, as gettext expects — the English `.po` file's `msgstr` must hold the source text (most generators already fill it; an empty `msgstr` is treated as empty, not inherited from `msgid`). `msgid_plural`/`msgstr[n]` plural entries are translated too (the singular and plural source forms), then expanded to however many `msgstr[n]` the target language's plural rules need (6 for Arabic, 1 for Japanese, ...) — not full grammatical inflection for in-between categories (`few`/`many`/...), which get the plural-sourced translation verbatim, the same simplification `addPluralForms` makes for i18next. `Plural-Forms` in the header is computed for the target language, not copied from the source; an existing target file's header is otherwise left as you last edited it (a hand-fixed `Plural-Forms`, `Last-Translator`, ...) instead of being regenerated on every rerun. For `.arb`, `@key` metadata and `@@locale` are never translated; `@@locale` is set to the target locale on output.
@@ -41,7 +41,7 @@ Download 577 MB? (Y/n)
41
41
 
42
42
  - **No API keys, no cloud bill.** Google/DeepL/OpenAI-based translation tools work well but mean an account, a key, a per-character cost, and your strings leaving your machine. locale-ai runs the model on your own CPU.
43
43
  - **Built for i18n files, not plain text.** General MT tools translate strings; they don't know that `{count}`, `<0>bold</0>`, or `$t(common.footer.tagline)` must survive untouched. locale-ai is built around i18next/ICU conventions specifically, so placeholders, plurals, and nested references come out intact instead of mangled or dropped.
44
- - **Incremental by default.** Add one new key to `en.json` and only that key gets translated — existing translations in `fr.json`/`es.json`/etc. are left alone, so re-runs are cheap and safe to automate in CI.
44
+ - **Incremental by default.** Add one new key to `en.json` and only that key gets translated; edit an existing one and the lockfile catches it and re-translates just that key — existing, unrelated translations are left alone, so re-runs are cheap and safe to automate in CI.
45
45
  - **Honest about quality.** The [Languages](#languages) section tells you which pairs are review-light vs. which need a human pass, instead of presenting every language as equally production-ready.
46
46
 
47
47
  **When *not* to use it**: if you need professional/legal-grade translation, or languages outside the [supported list](#languages), use a human translator or a larger cloud model instead — locale-ai is built for keeping UI strings in sync across languages during development, with a review pass before shipping.
@@ -51,6 +51,7 @@ Download 577 MB? (Y/n)
51
51
  ```bash
52
52
  locale-ai translate --to fr,pt-BR [--from en] [--input dir] [--dry-run] [--overwrite] [--fast] [--report file] [-y]
53
53
  locale-ai check [--from en] [--input dir] # CI: fails if a translated locale has missing keys or placeholder mismatches
54
+ # (also warns, without failing, about strings left identical to the source)
54
55
  locale-ai models list [--from en] # supported language pairs
55
56
  locale-ai models installed
56
57
  locale-ai models install en-de # pre-download (e.g. in CI)
@@ -62,6 +63,8 @@ locale-ai models convert <hf-marian-model> --out <dir> # bring your own model
62
63
 
63
64
  Models are cached once per machine in `~/.cache/locale-ai` (`LOCALE_AI_CACHE` to change). In CI, pass `--yes`.
64
65
 
66
+ Installing trims the `onnxruntime-node` dependency down to the one CPU binary locale-ai actually loads (it ships binaries for every platform/arch plus GPU providers locale-ai never uses) — but only when nothing else in your `node_modules` also depends on it, so a shared copy another package needs for GPU inference is left alone. Set `LOCALE_AI_SKIP_TRIM=1` to disable this entirely.
67
+
65
68
  ## Project config (optional)
66
69
 
67
70
  `locale-ai.config.json` in the project root:
package/dist/cli.js CHANGED
@@ -9,9 +9,10 @@ import { CATALOG, customEntries, findById, reachableTargets, resolveRoute, } fro
9
9
  import { translateStrings } from "./engine.js";
10
10
  import { CONFIG_FILE, glossaryLookup, loadConfig, protectRegex } from "./glossary.js";
11
11
  import { intlCode, parseLocale } from "./lang.js";
12
- import { addPluralForms, buildTarget, detectLayout, existingTargets, flattenStrings, keyPathProtect, pendingStrings, pluralOneStrings, readLocaleDoc, sourceFiles, targetFile, } from "./locales.js";
12
+ import { editedPaths, isSourceStale, LOCK_FILENAME, loadLock, recordEntries, saveLock } from "./lock.js";
13
+ import { addPluralForms, buildTarget, detectLayout, existingTargets, flattenStrings, keyPathProtect, pendingStrings, pluralOneStrings, readLocaleDoc, sourceFiles, targetFile, usesI18nextPlurals, } from "./locales.js";
13
14
  import { cacheRoot, install, isInstalled, loadTranslator, localModelProblem, planInstall, readManifest, uninstall, } from "./models.js";
14
- import { mask } from "./placeholders.js";
15
+ import { hasTranslatableText, mask } from "./placeholders.js";
15
16
  /** Read from package.json so `npm version` is the single source of truth. */
16
17
  const VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
17
18
  const HELP = `locale-ai ${VERSION} — translate i18n locale files locally, on CPU, for free.
@@ -54,6 +55,8 @@ const endStatus = () => {
54
55
  };
55
56
  const mb = (bytes) => `${Math.round(bytes / 1e6)} MB`;
56
57
  const rel = (p) => path.relative(process.cwd(), p) || ".";
58
+ /** Portable lock-file key: forward slashes regardless of platform. */
59
+ const fileKey = (p) => rel(p).split(path.sep).join("/");
57
60
  /**
58
61
  * `Number("abc")` is NaN, and NaN silently poisons everything downstream (an
59
62
  * all-garbage translation batch, or a raw ONNX crash deep in the model) instead of
@@ -160,6 +163,8 @@ async function cmdTranslate(argv) {
160
163
  const protect = protectRegex(config);
161
164
  const isProtectedKey = keyPathProtect(config.doNotTranslateKeys);
162
165
  const layout = detectLayout(process.cwd(), from.tag, values.input);
166
+ const lockPath = path.resolve(process.cwd(), LOCK_FILENAME);
167
+ const lock = loadLock(lockPath);
163
168
  const sources = sourceFiles(layout, from.tag).map((f) => ({ file: f, doc: readLocaleDoc(f, from.tag) }));
164
169
  const singularHints = new Set(sources.flatMap(({ doc }) => [...pluralOneStrings(doc.data)]));
165
170
  log(`locale-ai ${VERSION}`);
@@ -179,12 +184,13 @@ async function cmdTranslate(argv) {
179
184
  const files = sources.map(({ file, doc }) => {
180
185
  const target = targetFile(layout, from.tag, locale.tag, file);
181
186
  const existing = fs.existsSync(target) ? readLocaleDoc(target, locale.tag).data : undefined;
187
+ const isStale = (p, s) => isSourceStale(lock, fileKey(file), p, s);
182
188
  return {
183
189
  source: file,
184
190
  target,
185
191
  doc,
186
192
  existing,
187
- pending: pendingStrings(doc.data, existing, values.overwrite, isProtectedKey),
193
+ pending: pendingStrings(doc.data, existing, values.overwrite, isProtectedKey, isStale),
188
194
  };
189
195
  });
190
196
  const unique = new Set(files.flatMap((f) => f.pending)).size;
@@ -244,16 +250,25 @@ async function cmdTranslate(argv) {
244
250
  let written = 0;
245
251
  let pluralsAdded = 0;
246
252
  for (const f of job.files) {
247
- const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey);
248
- const { doc, added } = addPluralForms(built, intlCode(job.locale));
249
- pluralsAdded += added;
253
+ const lockKey = fileKey(f.source);
254
+ const isStale = (p, s) => isSourceStale(lock, lockKey, p, s);
255
+ if (f.existing !== undefined) {
256
+ for (const editedPath of editedPaths(lock, lockKey, job.locale.tag, flattenStrings(f.existing))) {
257
+ allIssues.push({ source: editedPath, kind: "edited", locale: job.locale.tag });
258
+ }
259
+ }
260
+ const built = buildTarget(f.doc.data, f.existing, (s) => translations.get(s) ?? s, values.overwrite, isProtectedKey, isStale);
261
+ const plural = usesI18nextPlurals(f.source) ? addPluralForms(built, intlCode(job.locale)) : { doc: built, added: 0 };
262
+ pluralsAdded += plural.added;
250
263
  const before = fs.existsSync(f.target) ? fs.readFileSync(f.target, "utf8") : undefined;
251
- const text = f.doc.serialize(doc, job.locale.tag, before);
264
+ const text = f.doc.serialize(plural.doc, job.locale.tag, before);
252
265
  if (text !== before) {
253
266
  fs.mkdirSync(path.dirname(f.target), { recursive: true });
254
267
  fs.writeFileSync(f.target, text);
255
268
  written++;
256
269
  }
270
+ const outFlat = new Map(flattenStrings(plural.doc).map((e) => [e.path, e.value]));
271
+ recordEntries(lock, lockKey, job.locale.tag, flattenStrings(f.doc.data).map(({ path: p, value }) => ({ path: p, source: value, written: outFlat.get(p) ?? "" })));
257
272
  }
258
273
  const secs = ((Date.now() - started) / 1000).toFixed(1);
259
274
  const issues = allIssues.filter((i) => i.locale === job.locale.tag).length;
@@ -264,6 +279,8 @@ async function cmdTranslate(argv) {
264
279
  }
265
280
  for (const t of loaded.values())
266
281
  await t.dispose();
282
+ if (jobs.length)
283
+ saveLock(lockPath, lock);
267
284
  if (allIssues.length) {
268
285
  log();
269
286
  log(`${allIssues.length} string${allIssues.length === 1 ? "" : "s"} worth a quick review:`);
@@ -407,9 +424,11 @@ async function cmdCheck(argv) {
407
424
  return 0;
408
425
  }
409
426
  let problems = 0;
427
+ let identicalTotal = 0;
410
428
  for (const to of targets) {
411
429
  let missing = 0;
412
430
  let mismatched = 0;
431
+ let identical = 0;
413
432
  for (const { file, doc } of sources) {
414
433
  const target = targetFile(layout, from.tag, to, file);
415
434
  if (!fs.existsSync(target)) {
@@ -426,19 +445,27 @@ async function cmdCheck(argv) {
426
445
  log(`✗ ${to}: ${rel(target)} missing key "${p}"`);
427
446
  continue;
428
447
  }
429
- const srcTokens = mask(srcVal, "natural").tokens.toSorted();
448
+ const srcMasked = mask(srcVal, "natural");
430
449
  const tgtTokens = mask(tgtVal, "natural").tokens.toSorted();
431
- if (srcTokens.join("\u0001") !== tgtTokens.join("\u0001")) {
450
+ if (srcMasked.tokens.toSorted().join("\u0001") !== tgtTokens.join("\u0001")) {
432
451
  mismatched++;
433
- log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${srcTokens.join(", ")}], target [${tgtTokens.join(", ")}]`);
452
+ log(`✗ ${to}: ${rel(target)} "${p}" placeholders differ: source [${srcMasked.tokens.toSorted().join(", ")}], target [${tgtTokens.join(", ")}]`);
453
+ continue;
454
+ }
455
+ if (tgtVal === srcVal && hasTranslatableText(srcMasked.text)) {
456
+ identical++;
457
+ log(`⚠ ${to}: ${rel(target)} "${p}" is untouched, identical to the ${from.tag} source`);
434
458
  }
435
459
  }
436
460
  }
461
+ identicalTotal += identical;
437
462
  if (missing || mismatched)
438
463
  problems++;
439
464
  else
440
- log(`✓ ${to}`);
465
+ log(`✓ ${to}${identical ? ` (${identical} left identical to ${from.tag})` : ""}`);
441
466
  }
467
+ if (identicalTotal)
468
+ log(`\n${identicalTotal} string${identicalTotal === 1 ? "" : "s"} left identical to the source — not a failure, but worth a look.`);
442
469
  return problems ? 1 : 0;
443
470
  }
444
471
  async function main() {
package/dist/locales.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import { parseLocale } from "./lang.js";
4
5
  const EXTENSIONS = [".json", ".yaml", ".yml", ".po", ".arb", ".strings"];
5
6
  const CANDIDATES = [
6
7
  "locales",
@@ -82,6 +83,16 @@ export function targetFile(layout, from, to, src) {
82
83
  return path.join(layout.root, `${to}${layout.ext}`);
83
84
  return path.join(layout.root, to, path.relative(path.join(layout.root, from), src));
84
85
  }
86
+ /** True when `tag` parses as a locale (e.g. "fr", "pt-BR") — rejects names like "__mocks__" or "schemas". */
87
+ function looksLikeLocale(tag) {
88
+ try {
89
+ parseLocale(tag);
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
85
96
  /** Locale tags already translated alongside `from` in this layout (for `locale-ai check`). */
86
97
  export function existingTargets(layout, from) {
87
98
  if (layout.kind === "file") {
@@ -90,12 +101,14 @@ export function existingTargets(layout, from) {
90
101
  .readdirSync(layout.root)
91
102
  .filter((f) => f.endsWith(ext) && f.slice(0, -ext.length) !== from)
92
103
  .map((f) => f.slice(0, -ext.length))
104
+ .filter(looksLikeLocale)
93
105
  .sort();
94
106
  }
95
107
  return fs
96
108
  .readdirSync(layout.root, { withFileTypes: true })
97
109
  .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && e.name !== from)
98
110
  .map((e) => e.name)
111
+ .filter(looksLikeLocale)
99
112
  .filter((name) => listLocaleFiles(path.join(layout.root, name)).length > 0)
100
113
  .sort();
101
114
  }
@@ -461,11 +474,11 @@ export function keyPathProtect(doNotTranslateKeys) {
461
474
  return undefined;
462
475
  return (path) => keys.some((k) => path === k || path.startsWith(`${k}.`) || path.startsWith(`${k}[`));
463
476
  }
464
- export function pendingStrings(source, existing, overwrite, isProtected) {
477
+ export function pendingStrings(source, existing, overwrite, isProtected, isStale) {
465
478
  const out = [];
466
479
  const visit = (s, e, path) => {
467
480
  if (typeof s === "string") {
468
- if (!isProtected?.(path) && needsTranslation(e, overwrite))
481
+ if (!isProtected?.(path) && (needsTranslation(e, overwrite) || !!isStale?.(path, s)))
469
482
  out.push(s);
470
483
  }
471
484
  else if (Array.isArray(s)) {
@@ -481,16 +494,19 @@ export function pendingStrings(source, existing, overwrite, isProtected) {
481
494
  }
482
495
  /**
483
496
  * Builds the target document: source structure and key order, existing
484
- * translations kept (unless overwrite), new strings from `translate`, and keys
485
- * that only exist in the target preserved at the end. A string at a protected
486
- * path (see keyPathProtect) is always copied from the source verbatim.
497
+ * translations kept (unless overwrite or stale), new strings from `translate`,
498
+ * and keys that only exist in the target preserved at the end. A string at a
499
+ * protected path (see keyPathProtect) is always copied from the source
500
+ * verbatim. `isStale` (see the lock module) forces re-translation of a
501
+ * non-empty target when its source string has changed since it was last
502
+ * translated.
487
503
  */
488
- export function buildTarget(source, existing, translate, overwrite, isProtected) {
504
+ export function buildTarget(source, existing, translate, overwrite, isProtected, isStale) {
489
505
  const visit = (s, e, path) => {
490
506
  if (typeof s === "string") {
491
507
  if (isProtected?.(path))
492
508
  return s;
493
- return needsTranslation(e, overwrite) ? translate(s) : e;
509
+ return needsTranslation(e, overwrite) || isStale?.(path, s) ? translate(s) : e;
494
510
  }
495
511
  if (Array.isArray(s))
496
512
  return s.map((v, i) => visit(v, Array.isArray(e) ? e[i] : undefined, `${path}[${i}]`));
@@ -535,6 +551,15 @@ export function pluralOneStrings(data, out = new Set()) {
535
551
  visit(data);
536
552
  return out;
537
553
  }
554
+ /**
555
+ * True for the file extensions that use i18next's `key_one`/`key_other` plural-key
556
+ * convention (flat JSON/YAML locale trees). A `.po`, `.arb` or `.strings` file might
557
+ * happen to have a key ending the same way without meaning it as an i18next plural,
558
+ * so addPluralForms should only ever run on these.
559
+ */
560
+ export function usesI18nextPlurals(file) {
561
+ return /\.(json|ya?ml)$/i.test(file);
562
+ }
538
563
  /**
539
564
  * i18next stores plurals as `key_one` / `key_other`. Languages such as Arabic,
540
565
  * Russian or Polish need more forms (`_few`, `_many` ...) and i18next does not fall
package/dist/lock.js ADDED
@@ -0,0 +1,56 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ export const LOCK_FILENAME = "locale-ai.lock.json";
4
+ export function hashOf(s) {
5
+ return crypto.createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
6
+ }
7
+ export function loadLock(file) {
8
+ try {
9
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
10
+ if (parsed && typeof parsed === "object" && parsed.files)
11
+ return { version: 1, files: parsed.files };
12
+ }
13
+ catch {
14
+ // Missing or invalid: start fresh rather than fail the run.
15
+ }
16
+ return { version: 1, files: {} };
17
+ }
18
+ export function saveLock(file, lock) {
19
+ fs.writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
20
+ }
21
+ /**
22
+ * True when `path`'s source string in `fileKey` has changed since it was last
23
+ * translated — meaning the existing target text is stale and needs redoing
24
+ * even though it's non-empty. False for a path the lock has never seen (new
25
+ * adoption, or a brand-new key, both already handled by the empty-target check).
26
+ */
27
+ export function isSourceStale(lock, fileKey, path, source) {
28
+ const entry = lock.files[fileKey]?.[path];
29
+ return entry !== undefined && entry.source !== hashOf(source);
30
+ }
31
+ /**
32
+ * Key paths whose existing target text no longer matches the output this tool
33
+ * last wrote there for `targetTag` — i.e. a translator edited it since. Only
34
+ * reported; the edit is never overwritten.
35
+ */
36
+ export function editedPaths(lock, fileKey, targetTag, entries) {
37
+ const file = lock.files[fileKey];
38
+ if (!file)
39
+ return [];
40
+ const out = [];
41
+ for (const { path, value } of entries) {
42
+ const mt = file[path]?.mt[targetTag];
43
+ if (mt !== undefined && mt !== hashOf(value))
44
+ out.push(path);
45
+ }
46
+ return out;
47
+ }
48
+ /** Records, for every path translated this run, the current source hash and the hash of what ended up on disk. */
49
+ export function recordEntries(lock, fileKey, targetTag, entries) {
50
+ const file = (lock.files[fileKey] ??= {});
51
+ for (const { path, source, written } of entries) {
52
+ const entry = (file[path] ??= { source: hashOf(source), mt: {} });
53
+ entry.source = hashOf(source);
54
+ entry.mt[targetTag] = hashOf(written);
55
+ }
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locale-ai",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "Translate your i18n locale files locally on CPU. Free, offline, no API keys.",
5
5
  "author": "Taha KHABOUSS",
6
6
  "repository": {
@@ -9,6 +9,15 @@
9
9
  // onnxruntime-node has finished installing, shrinking a ~550 MB unpacked
10
10
  // dependency down to the one binary locale-ai actually loads.
11
11
  //
12
+ // Node's resolution can hoist onnxruntime-node to a node_modules shared with
13
+ // other packages, so this only trims when nothing else next to it in that
14
+ // same node_modules also depends on it — another consumer there might need a
15
+ // GPU provider, or run on a different platform than this machine (e.g. a
16
+ // node_modules folder built here and later mounted into a container). Set
17
+ // LOCALE_AI_SKIP_TRIM=1 to disable this entirely, e.g. if node_modules is
18
+ // going to be reused somewhere this check can't see (a different machine, a
19
+ // Docker image built from it later).
20
+ //
12
21
  // Best-effort: never fails the install, even if onnxruntime-node changes
13
22
  // its layout in a future version.
14
23
 
@@ -23,9 +32,44 @@ function rm(p) {
23
32
  }
24
33
  }
25
34
 
35
+ /** Any OTHER package in `nodeModulesDir` that itself depends on onnxruntime-node. */
36
+ function hasOtherConsumer(nodeModulesDir) {
37
+ let entries;
38
+ try {
39
+ entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true });
40
+ } catch {
41
+ return false;
42
+ }
43
+ for (const e of entries) {
44
+ if (!e.isDirectory() || e.name === 'onnxruntime-node' || e.name === '.bin') continue;
45
+ const names = e.name.startsWith('@')
46
+ ? fs.readdirSync(path.join(nodeModulesDir, e.name), { withFileTypes: true })
47
+ .filter((s) => s.isDirectory())
48
+ .map((s) => `${e.name}/${s.name}`)
49
+ : [e.name];
50
+ for (const name of names) {
51
+ if (name === 'locale-ai') continue;
52
+ try {
53
+ const pkg = JSON.parse(fs.readFileSync(path.join(nodeModulesDir, name, 'package.json'), 'utf8'));
54
+ const deps = { ...pkg.dependencies, ...pkg.optionalDependencies, ...pkg.peerDependencies };
55
+ if (deps && 'onnxruntime-node' in deps) return true;
56
+ } catch {
57
+ // Not a real package (or unreadable) — ignore.
58
+ }
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ if (process.env.LOCALE_AI_SKIP_TRIM) process.exit(0);
65
+
26
66
  try {
27
67
  const pkgJson = require.resolve('onnxruntime-node/package.json');
28
- const binRoot = path.join(path.dirname(pkgJson), 'bin', 'napi-v6');
68
+ const onnxRoot = path.dirname(pkgJson);
69
+ const nodeModulesDir = path.dirname(onnxRoot);
70
+ if (hasOtherConsumer(nodeModulesDir)) process.exit(0);
71
+
72
+ const binRoot = path.join(onnxRoot, 'bin', 'napi-v6');
29
73
  if (!fs.existsSync(binRoot)) process.exit(0);
30
74
 
31
75
  const platform = process.platform;