d365fo-mcp 1.17.0 → 1.17.2

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.
@@ -87,6 +87,19 @@ export const SETTINGS = [
87
87
  'which volume that is depends on the VM image (K:, C:, J:, …).',
88
88
  placeholder: 'C:\\AOSService\\PackagesLocalDirectory',
89
89
  },
90
+ {
91
+ path: 'environment.scanDrives',
92
+ env: 'D365FO_SCAN_DRIVES',
93
+ section: 'environment',
94
+ tier: 'advanced',
95
+ type: 'string',
96
+ label: 'Drive letters probed for AosService',
97
+ description: 'Comma-separated letters the packages-root scan probes when no packagePath is configured, e.g. "C,K". ' +
98
+ 'Empty probes C: to Z: — the letters that have ever held AosService first, the rest inside a 2 s budget. ' +
99
+ 'Set it on a machine with a disconnected mapped network drive: one stat on such a drive stalls for the ' +
100
+ 'SMB timeout, and the scan runs on the first tool call of a session.',
101
+ placeholder: 'C,K',
102
+ },
90
103
  {
91
104
  path: 'environment.customModels',
92
105
  env: 'CUSTOM_MODELS',
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { createXppMcpServer } from './server/mcpServer.js';
15
15
  import { createStreamableHttpTransport } from './server/transport.js';
16
16
  import { XppSymbolIndex } from './metadata/symbolIndex.js';
17
17
  import { shouldWarmIndexes, warmIndexes, renderWarmupReport } from './metadata/indexWarmup.js';
18
+ import { canIndexOffThread, indexMetadataOffThread } from './metadata/startupIndexing.js';
18
19
  import { XppMetadataParser } from './metadata/xmlParser.js';
19
20
  import { WorkspaceScanner } from './workspace/workspaceScanner.js';
20
21
  import { HybridSearch } from './workspace/hybridSearch.js';
@@ -300,8 +301,15 @@ async function initializeServices() {
300
301
  log.warn('No symbols found in database — run `npm run index-metadata` first');
301
302
  log.detail('or set METADATA_PATH and the server will index on startup');
302
303
  // If metadata path exists, index it
304
+ let metadataAccessible = false;
303
305
  try {
304
306
  await fs.access(METADATA_PATH);
307
+ metadataAccessible = true;
308
+ }
309
+ catch {
310
+ log.warn('Metadata path not accessible — starting with empty index');
311
+ }
312
+ if (metadataAccessible) {
305
313
  log.step(`Indexing metadata from ${METADATA_PATH}` + glyph.ellipsis);
306
314
  serverState.statusMessage = 'Indexing metadata...';
307
315
  const modelNamesStr = process.env.CUSTOM_MODELS || 'CustomModel';
@@ -310,11 +318,31 @@ async function initializeServices() {
310
318
  // Single pass over all requested models — the FTS index is rebuilt once at the
311
319
  // end of the call, so looping per model would repeat a full-table rebuild.
312
320
  log.detail(`indexing ${modelNames.join(', ')}` + glyph.ellipsis);
313
- await symbolIndex.indexMetadataDirectory(METADATA_PATH, modelNames);
314
- log.ok(`Indexed ${symbolIndex.getSymbolCount().toLocaleString('en-US')} symbols from ${modelNames.length} model(s)`);
315
- }
316
- catch {
317
- log.warn('Metadata path not accessible starting with empty index');
321
+ try {
322
+ if (canIndexOffThread(DB_PATH, LABELS_DB_PATH)) {
323
+ // On a worker thread: the build is synchronous end to end, and inline
324
+ // it blocked the event loop for its whole duration — every tool call,
325
+ // get_workspace_info included, hung until it finished. dbReady is still
326
+ // held until it completes, so symbol-backed tools keep answering "still
327
+ // loading" rather than returning empty results; the loop stays free.
328
+ const { elapsedMs } = await indexMetadataOffThread({
329
+ dbPath: DB_PATH,
330
+ labelsDbPath: LABELS_DB_PATH,
331
+ metadataPath: METADATA_PATH,
332
+ modelNames,
333
+ output: process.stderr,
334
+ });
335
+ log.detail(`indexed in ${(elapsedMs / 1000).toFixed(1)}s on a worker thread`);
336
+ }
337
+ else {
338
+ await symbolIndex.indexMetadataDirectory(METADATA_PATH, modelNames);
339
+ }
340
+ log.ok(`Indexed ${symbolIndex.getSymbolCount().toLocaleString('en-US')} symbols from ${modelNames.length} model(s)`);
341
+ }
342
+ catch (error) {
343
+ log.warn(`Metadata indexing failed — starting with empty index: ${error}`);
344
+ log.detail('run `npm run index-metadata` to build the database');
345
+ }
318
346
  }
319
347
  }
320
348
  else {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * First-start metadata indexing, off the main thread.
3
+ *
4
+ * When the server starts with an empty symbol database and METADATA_PATH set,
5
+ * it indexes the requested models before it declares itself ready. That build
6
+ * is synchronous end to end — a recursive readdirSync per model, node:sqlite
7
+ * inserts, one FTS rebuild — and inline on the main thread it blocked the
8
+ * event loop for the whole duration: every tool call, get_workspace_info
9
+ * included, hung until the build finished, on exactly the machine where the
10
+ * user was trying the server for the first time.
11
+ *
12
+ * Here the same XppSymbolIndex.indexMetadataDirectory runs on its own
13
+ * connection. WAL mode lets the main thread keep serving from the same file —
14
+ * the startup path never takes the EXCLUSIVE lock the build scripts use, so
15
+ * the two connections coexist — and the main thread sees each model as its
16
+ * transaction commits.
17
+ *
18
+ * Spawned by indexMetadataOffThread() (startupIndexing.ts) and posts:
19
+ * { type: 'done', elapsedMs, symbolCount } | { type: 'error', error }
20
+ *
21
+ * Bundled by build:scripts beside the other workers (tests/packaging/workerBundles).
22
+ */
23
+ export interface StartupIndexWorkerData {
24
+ dbPath: string;
25
+ labelsDbPath: string;
26
+ metadataPath: string;
27
+ modelNames: string[];
28
+ }
29
+ export type StartupIndexMessage = {
30
+ type: 'done';
31
+ elapsedMs: number;
32
+ symbolCount: number;
33
+ } | {
34
+ type: 'error';
35
+ error: string;
36
+ };
37
+ //# sourceMappingURL=startupIndexWorker.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * First-start metadata indexing, off the main thread.
3
+ *
4
+ * When the server starts with an empty symbol database and METADATA_PATH set,
5
+ * it indexes the requested models before it declares itself ready. That build
6
+ * is synchronous end to end — a recursive readdirSync per model, node:sqlite
7
+ * inserts, one FTS rebuild — and inline on the main thread it blocked the
8
+ * event loop for the whole duration: every tool call, get_workspace_info
9
+ * included, hung until the build finished, on exactly the machine where the
10
+ * user was trying the server for the first time.
11
+ *
12
+ * Here the same XppSymbolIndex.indexMetadataDirectory runs on its own
13
+ * connection. WAL mode lets the main thread keep serving from the same file —
14
+ * the startup path never takes the EXCLUSIVE lock the build scripts use, so
15
+ * the two connections coexist — and the main thread sees each model as its
16
+ * transaction commits.
17
+ *
18
+ * Spawned by indexMetadataOffThread() (startupIndexing.ts) and posts:
19
+ * { type: 'done', elapsedMs, symbolCount } | { type: 'error', error }
20
+ *
21
+ * Bundled by build:scripts beside the other workers (tests/packaging/workerBundles).
22
+ */
23
+ import { parentPort, workerData } from 'node:worker_threads';
24
+ import { XppSymbolIndex } from './symbolIndex.js';
25
+ const data = workerData;
26
+ async function run() {
27
+ const started = Date.now();
28
+ // backgroundIndexBuilds: false — this thread IS the background; nesting a
29
+ // second worker per file-path index buys nothing and complicates shutdown.
30
+ const index = new XppSymbolIndex(data.dbPath, data.labelsDbPath, { backgroundIndexBuilds: false });
31
+ try {
32
+ await index.indexMetadataDirectory(data.metadataPath, data.modelNames);
33
+ const symbolCount = index.getSymbolCount();
34
+ parentPort.postMessage({ type: 'done', elapsedMs: Date.now() - started, symbolCount });
35
+ }
36
+ finally {
37
+ index.close();
38
+ }
39
+ }
40
+ run().catch(e => {
41
+ parentPort.postMessage({ type: 'error', error: String(e?.stack ?? e) });
42
+ });
43
+ //# sourceMappingURL=startupIndexWorker.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Run the first-start metadata index in a worker thread and wait for it.
3
+ *
4
+ * See startupIndexWorker.ts for why. The parent side is thin: it spawns the
5
+ * worker, routes its console output where the caller says (stderr in stdio
6
+ * mode — stdout is the MCP protocol channel there), and settles once with the
7
+ * worker's result. It still WAITS: the caller holds dbReady until the index is
8
+ * populated, so tools that need symbols keep getting the "still loading"
9
+ * answer instead of silently empty results — the difference is that the event
10
+ * loop is free meanwhile, so the tools that need no symbols answer at once.
11
+ */
12
+ import type { StartupIndexWorkerData } from './startupIndexWorker.js';
13
+ export interface StartupIndexOptions extends StartupIndexWorkerData {
14
+ /** Injected in tests; the real one resolves next to the compiled worker. */
15
+ workerUrl?: URL;
16
+ /** Where the worker's stdout/stderr go. Defaults to the parent's stderr. */
17
+ output?: NodeJS.WritableStream;
18
+ /**
19
+ * Heap cap for the worker. A worker's default old-generation limit is derived
20
+ * from the parent's, which is sized for serving, not for indexing.
21
+ */
22
+ maxOldGenerationSizeMb?: number;
23
+ }
24
+ export interface StartupIndexResult {
25
+ elapsedMs: number;
26
+ symbolCount: number;
27
+ }
28
+ /** In-memory databases cannot be shared with a worker — the caller indexes inline. */
29
+ export declare function canIndexOffThread(dbPath: string, labelsDbPath: string): boolean;
30
+ export declare function indexMetadataOffThread(opts: StartupIndexOptions): Promise<StartupIndexResult>;
31
+ //# sourceMappingURL=startupIndexing.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Run the first-start metadata index in a worker thread and wait for it.
3
+ *
4
+ * See startupIndexWorker.ts for why. The parent side is thin: it spawns the
5
+ * worker, routes its console output where the caller says (stderr in stdio
6
+ * mode — stdout is the MCP protocol channel there), and settles once with the
7
+ * worker's result. It still WAITS: the caller holds dbReady until the index is
8
+ * populated, so tools that need symbols keep getting the "still loading"
9
+ * answer instead of silently empty results — the difference is that the event
10
+ * loop is free meanwhile, so the tools that need no symbols answer at once.
11
+ */
12
+ import { Worker } from 'node:worker_threads';
13
+ /** In-memory databases cannot be shared with a worker — the caller indexes inline. */
14
+ export function canIndexOffThread(dbPath, labelsDbPath) {
15
+ return dbPath !== ':memory:' && labelsDbPath !== ':memory:';
16
+ }
17
+ export function indexMetadataOffThread(opts) {
18
+ const url = opts.workerUrl ?? new URL('./startupIndexWorker.js', import.meta.url);
19
+ const output = opts.output ?? process.stderr;
20
+ const workerData = {
21
+ dbPath: opts.dbPath,
22
+ labelsDbPath: opts.labelsDbPath,
23
+ metadataPath: opts.metadataPath,
24
+ modelNames: opts.modelNames,
25
+ };
26
+ return new Promise((resolve, reject) => {
27
+ let settled = false;
28
+ const settle = (fn) => {
29
+ if (settled)
30
+ return;
31
+ settled = true;
32
+ fn();
33
+ };
34
+ let worker;
35
+ try {
36
+ worker = new Worker(url, {
37
+ workerData,
38
+ // Own the streams: with the defaults a worker's stdout is piped straight
39
+ // into the parent's stdout, which in stdio mode is the protocol channel.
40
+ stdout: true,
41
+ stderr: true,
42
+ resourceLimits: { maxOldGenerationSizeMb: opts.maxOldGenerationSizeMb ?? 4096 },
43
+ });
44
+ }
45
+ catch (e) {
46
+ reject(e instanceof Error ? e : new Error(String(e)));
47
+ return;
48
+ }
49
+ worker.stdout.pipe(output, { end: false });
50
+ worker.stderr.pipe(output, { end: false });
51
+ worker.on('message', (msg) => {
52
+ if (msg.type === 'done') {
53
+ settle(() => resolve({ elapsedMs: msg.elapsedMs, symbolCount: msg.symbolCount }));
54
+ void worker.terminate();
55
+ }
56
+ else if (msg.type === 'error') {
57
+ settle(() => reject(new Error(msg.error)));
58
+ void worker.terminate();
59
+ }
60
+ });
61
+ worker.once('error', e => settle(() => reject(e)));
62
+ // A promise settles once — this covers "exited before the done message".
63
+ worker.once('exit', code => settle(() => reject(new Error(`startup index worker exited with code ${code}`))));
64
+ });
65
+ }
66
+ //# sourceMappingURL=startupIndexing.js.map
@@ -34,6 +34,16 @@ var SETTINGS = [
34
34
  description: "AOT packages folder (PackagesLocalDirectory) used as the read-only source for indexing. Machine-wide on a traditional VM; UDE resolves it from the XPP config instead. Left empty, the server scans the machine's drives for AosService\\PackagesLocalDirectory \u2014 which volume that is depends on the VM image (K:, C:, J:, \u2026).",
35
35
  placeholder: "C:\\AOSService\\PackagesLocalDirectory"
36
36
  },
37
+ {
38
+ path: "environment.scanDrives",
39
+ env: "D365FO_SCAN_DRIVES",
40
+ section: "environment",
41
+ tier: "advanced",
42
+ type: "string",
43
+ label: "Drive letters probed for AosService",
44
+ description: 'Comma-separated letters the packages-root scan probes when no packagePath is configured, e.g. "C,K". Empty probes C: to Z: \u2014 the letters that have ever held AosService first, the rest inside a 2 s budget. Set it on a machine with a disconnected mapped network drive: one stat on such a drive stalls for the SMB timeout, and the scan runs on the first tool call of a session.',
45
+ placeholder: "C,K"
46
+ },
37
47
  {
38
48
  path: "environment.customModels",
39
49
  env: "CUSTOM_MODELS",
@@ -5394,6 +5404,15 @@ import * as fs6 from "fs";
5394
5404
  var FALLBACK_PACKAGES_ROOT = "C:\\AosService\\PackagesLocalDirectory";
5395
5405
  var PREFERRED_DRIVES = ["C", "K", "J", "I"];
5396
5406
  var SCANNED_DRIVES = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("");
5407
+ var DRIVE_SCAN_BUDGET_MS = 2e3;
5408
+ var SLOW_PROBE_MS = 1e3;
5409
+ var lastReport = null;
5410
+ function driveLettersToProbe(pinnedSpec) {
5411
+ const pinned = (pinnedSpec ?? "").toUpperCase().split(/[,;\s]+/).map((s) => s.replace(/[:\\/]/g, "")).filter((l) => /^[C-Z]$/.test(l));
5412
+ if (pinned.length > 0) return { letters: [...new Set(pinned)], pinned: true };
5413
+ const rest = SCANNED_DRIVES.filter((l) => !PREFERRED_DRIVES.includes(l));
5414
+ return { letters: [...PREFERRED_DRIVES, ...rest], pinned: false };
5415
+ }
5397
5416
  var realIo = {
5398
5417
  // Read through to process.platform on every access rather than snapshotting it
5399
5418
  // at import time — a frozen copy makes the scan ignore a platform override, so
@@ -5423,20 +5442,34 @@ function plausibility(root, io) {
5423
5442
  if (entries.some((e) => e.toLowerCase() === "bin")) return 2;
5424
5443
  return 1;
5425
5444
  }
5426
- function scanPackagesRoots(io = realIo) {
5445
+ function scanPackagesRoots(io = realIo, opts = {}) {
5427
5446
  if (io.platform !== "win32") return [];
5447
+ const clock = opts.clock ?? Date.now;
5448
+ const budgetMs = opts.budgetMs ?? DRIVE_SCAN_BUDGET_MS;
5449
+ const { letters, pinned } = driveLettersToProbe(opts.drives ?? process.env.D365FO_SCAN_DRIVES);
5450
+ const report = { probed: [], skipped: [], slow: [], pinned };
5428
5451
  const hits = [];
5429
- for (const letter of SCANNED_DRIVES) {
5430
- if (!io.isDirectory(`${letter}:\\`)) continue;
5431
- const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
5432
- if (!io.isDirectory(root)) continue;
5452
+ const start = clock();
5453
+ for (const letter of letters) {
5433
5454
  const preferred = PREFERRED_DRIVES.indexOf(letter);
5455
+ if (!pinned && preferred === -1 && clock() - start > budgetMs) {
5456
+ report.skipped.push(letter);
5457
+ continue;
5458
+ }
5459
+ const t0 = clock();
5460
+ const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
5461
+ const hit = io.isDirectory(`${letter}:\\`) && io.isDirectory(root);
5462
+ const ms = clock() - t0;
5463
+ report.probed.push(letter);
5464
+ if (ms >= SLOW_PROBE_MS) report.slow.push({ letter, ms });
5465
+ if (!hit) continue;
5434
5466
  hits.push({
5435
5467
  root,
5436
5468
  score: plausibility(root, io),
5437
5469
  rank: preferred === -1 ? PREFERRED_DRIVES.length : preferred
5438
5470
  });
5439
5471
  }
5472
+ lastReport = report;
5440
5473
  return hits.sort((a, b) => b.score - a.score || a.rank - b.rank || a.root.localeCompare(b.root)).map((hit) => hit.root);
5441
5474
  }
5442
5475
  var cached = null;
@@ -34,6 +34,16 @@ var SETTINGS = [
34
34
  description: "AOT packages folder (PackagesLocalDirectory) used as the read-only source for indexing. Machine-wide on a traditional VM; UDE resolves it from the XPP config instead. Left empty, the server scans the machine's drives for AosService\\PackagesLocalDirectory \u2014 which volume that is depends on the VM image (K:, C:, J:, \u2026).",
35
35
  placeholder: "C:\\AOSService\\PackagesLocalDirectory"
36
36
  },
37
+ {
38
+ path: "environment.scanDrives",
39
+ env: "D365FO_SCAN_DRIVES",
40
+ section: "environment",
41
+ tier: "advanced",
42
+ type: "string",
43
+ label: "Drive letters probed for AosService",
44
+ description: 'Comma-separated letters the packages-root scan probes when no packagePath is configured, e.g. "C,K". Empty probes C: to Z: \u2014 the letters that have ever held AosService first, the rest inside a 2 s budget. Set it on a machine with a disconnected mapped network drive: one stat on such a drive stalls for the SMB timeout, and the scan runs on the first tool call of a session.',
45
+ placeholder: "C,K"
46
+ },
37
47
  {
38
48
  path: "environment.customModels",
39
49
  env: "CUSTOM_MODELS",
@@ -5199,6 +5209,15 @@ import * as fs4 from "fs";
5199
5209
  var FALLBACK_PACKAGES_ROOT = "C:\\AosService\\PackagesLocalDirectory";
5200
5210
  var PREFERRED_DRIVES = ["C", "K", "J", "I"];
5201
5211
  var SCANNED_DRIVES = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("");
5212
+ var DRIVE_SCAN_BUDGET_MS = 2e3;
5213
+ var SLOW_PROBE_MS = 1e3;
5214
+ var lastReport = null;
5215
+ function driveLettersToProbe(pinnedSpec) {
5216
+ const pinned = (pinnedSpec ?? "").toUpperCase().split(/[,;\s]+/).map((s) => s.replace(/[:\\/]/g, "")).filter((l) => /^[C-Z]$/.test(l));
5217
+ if (pinned.length > 0) return { letters: [...new Set(pinned)], pinned: true };
5218
+ const rest = SCANNED_DRIVES.filter((l) => !PREFERRED_DRIVES.includes(l));
5219
+ return { letters: [...PREFERRED_DRIVES, ...rest], pinned: false };
5220
+ }
5202
5221
  var realIo = {
5203
5222
  // Read through to process.platform on every access rather than snapshotting it
5204
5223
  // at import time — a frozen copy makes the scan ignore a platform override, so
@@ -5228,20 +5247,34 @@ function plausibility(root, io) {
5228
5247
  if (entries.some((e) => e.toLowerCase() === "bin")) return 2;
5229
5248
  return 1;
5230
5249
  }
5231
- function scanPackagesRoots(io = realIo) {
5250
+ function scanPackagesRoots(io = realIo, opts = {}) {
5232
5251
  if (io.platform !== "win32") return [];
5252
+ const clock = opts.clock ?? Date.now;
5253
+ const budgetMs = opts.budgetMs ?? DRIVE_SCAN_BUDGET_MS;
5254
+ const { letters, pinned } = driveLettersToProbe(opts.drives ?? process.env.D365FO_SCAN_DRIVES);
5255
+ const report = { probed: [], skipped: [], slow: [], pinned };
5233
5256
  const hits = [];
5234
- for (const letter of SCANNED_DRIVES) {
5235
- if (!io.isDirectory(`${letter}:\\`)) continue;
5236
- const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
5237
- if (!io.isDirectory(root)) continue;
5257
+ const start = clock();
5258
+ for (const letter of letters) {
5238
5259
  const preferred = PREFERRED_DRIVES.indexOf(letter);
5260
+ if (!pinned && preferred === -1 && clock() - start > budgetMs) {
5261
+ report.skipped.push(letter);
5262
+ continue;
5263
+ }
5264
+ const t0 = clock();
5265
+ const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
5266
+ const hit = io.isDirectory(`${letter}:\\`) && io.isDirectory(root);
5267
+ const ms = clock() - t0;
5268
+ report.probed.push(letter);
5269
+ if (ms >= SLOW_PROBE_MS) report.slow.push({ letter, ms });
5270
+ if (!hit) continue;
5239
5271
  hits.push({
5240
5272
  root,
5241
5273
  score: plausibility(root, io),
5242
5274
  rank: preferred === -1 ? PREFERRED_DRIVES.length : preferred
5243
5275
  });
5244
5276
  }
5277
+ lastReport = report;
5245
5278
  return hits.sort((a, b) => b.score - a.score || a.rank - b.rank || a.root.localeCompare(b.root)).map((hit) => hit.root);
5246
5279
  }
5247
5280
  var cached = null;
@@ -34,6 +34,16 @@ var SETTINGS = [
34
34
  description: "AOT packages folder (PackagesLocalDirectory) used as the read-only source for indexing. Machine-wide on a traditional VM; UDE resolves it from the XPP config instead. Left empty, the server scans the machine's drives for AosService\\PackagesLocalDirectory \u2014 which volume that is depends on the VM image (K:, C:, J:, \u2026).",
35
35
  placeholder: "C:\\AOSService\\PackagesLocalDirectory"
36
36
  },
37
+ {
38
+ path: "environment.scanDrives",
39
+ env: "D365FO_SCAN_DRIVES",
40
+ section: "environment",
41
+ tier: "advanced",
42
+ type: "string",
43
+ label: "Drive letters probed for AosService",
44
+ description: 'Comma-separated letters the packages-root scan probes when no packagePath is configured, e.g. "C,K". Empty probes C: to Z: \u2014 the letters that have ever held AosService first, the rest inside a 2 s budget. Set it on a machine with a disconnected mapped network drive: one stat on such a drive stalls for the SMB timeout, and the scan runs on the first tool call of a session.',
45
+ placeholder: "C,K"
46
+ },
37
47
  {
38
48
  path: "environment.customModels",
39
49
  env: "CUSTOM_MODELS",
@@ -2633,6 +2643,15 @@ import * as fs6 from "fs";
2633
2643
  var FALLBACK_PACKAGES_ROOT = "C:\\AosService\\PackagesLocalDirectory";
2634
2644
  var PREFERRED_DRIVES = ["C", "K", "J", "I"];
2635
2645
  var SCANNED_DRIVES = "CDEFGHIJKLMNOPQRSTUVWXYZ".split("");
2646
+ var DRIVE_SCAN_BUDGET_MS = 2e3;
2647
+ var SLOW_PROBE_MS = 1e3;
2648
+ var lastReport = null;
2649
+ function driveLettersToProbe(pinnedSpec) {
2650
+ const pinned = (pinnedSpec ?? "").toUpperCase().split(/[,;\s]+/).map((s) => s.replace(/[:\\/]/g, "")).filter((l) => /^[C-Z]$/.test(l));
2651
+ if (pinned.length > 0) return { letters: [...new Set(pinned)], pinned: true };
2652
+ const rest = SCANNED_DRIVES.filter((l) => !PREFERRED_DRIVES.includes(l));
2653
+ return { letters: [...PREFERRED_DRIVES, ...rest], pinned: false };
2654
+ }
2636
2655
  var realIo = {
2637
2656
  // Read through to process.platform on every access rather than snapshotting it
2638
2657
  // at import time — a frozen copy makes the scan ignore a platform override, so
@@ -2662,20 +2681,34 @@ function plausibility(root, io) {
2662
2681
  if (entries.some((e) => e.toLowerCase() === "bin")) return 2;
2663
2682
  return 1;
2664
2683
  }
2665
- function scanPackagesRoots(io = realIo) {
2684
+ function scanPackagesRoots(io = realIo, opts = {}) {
2666
2685
  if (io.platform !== "win32") return [];
2686
+ const clock = opts.clock ?? Date.now;
2687
+ const budgetMs = opts.budgetMs ?? DRIVE_SCAN_BUDGET_MS;
2688
+ const { letters, pinned } = driveLettersToProbe(opts.drives ?? process.env.D365FO_SCAN_DRIVES);
2689
+ const report = { probed: [], skipped: [], slow: [], pinned };
2667
2690
  const hits = [];
2668
- for (const letter of SCANNED_DRIVES) {
2669
- if (!io.isDirectory(`${letter}:\\`)) continue;
2670
- const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
2671
- if (!io.isDirectory(root)) continue;
2691
+ const start = clock();
2692
+ for (const letter of letters) {
2672
2693
  const preferred = PREFERRED_DRIVES.indexOf(letter);
2694
+ if (!pinned && preferred === -1 && clock() - start > budgetMs) {
2695
+ report.skipped.push(letter);
2696
+ continue;
2697
+ }
2698
+ const t0 = clock();
2699
+ const root = `${letter}:\\AosService\\PackagesLocalDirectory`;
2700
+ const hit = io.isDirectory(`${letter}:\\`) && io.isDirectory(root);
2701
+ const ms = clock() - t0;
2702
+ report.probed.push(letter);
2703
+ if (ms >= SLOW_PROBE_MS) report.slow.push({ letter, ms });
2704
+ if (!hit) continue;
2673
2705
  hits.push({
2674
2706
  root,
2675
2707
  score: plausibility(root, io),
2676
2708
  rank: preferred === -1 ? PREFERRED_DRIVES.length : preferred
2677
2709
  });
2678
2710
  }
2711
+ lastReport = report;
2679
2712
  return hits.sort((a, b) => b.score - a.score || a.rank - b.rank || a.root.localeCompare(b.root)).map((hit) => hit.root);
2680
2713
  }
2681
2714
  var cached = null;