@atomic-ehr/fhir-canonical-manager 0.0.6 → 0.0.8

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/src/index.ts CHANGED
@@ -3,10 +3,148 @@
3
3
  * A package manager for FHIR resources with canonical URL resolution
4
4
  */
5
5
 
6
- import * as path from 'path';
7
- import * as fs from 'fs/promises';
8
- import { createHash } from 'crypto';
9
- import { $, ShellError } from './compat.js';
6
+ import * as path from "path";
7
+ import * as fs from "fs/promises";
8
+ import { createHash } from "crypto";
9
+ import { exec } from "child_process";
10
+ import { promisify } from "util";
11
+
12
+ // Shell command utilities
13
+ const execAsync = promisify(exec);
14
+
15
+ // Shell error type
16
+ class ShellError extends Error {
17
+ exitCode: number;
18
+ stdout: string;
19
+ stderr: string;
20
+
21
+ constructor(
22
+ message: string,
23
+ exitCode: number,
24
+ stdout: string,
25
+ stderr: string,
26
+ ) {
27
+ super(message);
28
+ this.name = "ShellError";
29
+ this.exitCode = exitCode;
30
+ this.stdout = stdout;
31
+ this.stderr = stderr;
32
+ }
33
+ }
34
+
35
+ interface ShellResult {
36
+ stdout: string;
37
+ stderr: string;
38
+ exitCode: number;
39
+ }
40
+
41
+ interface ShellPromise extends Promise<ShellResult> {
42
+ quiet(): Promise<ShellResult>;
43
+ env(variables: Record<string, string>): ShellPromise;
44
+ }
45
+
46
+ /**
47
+ * Shell command execution using standard Node.js child_process
48
+ */
49
+ function $(strings: TemplateStringsArray, ...values: any[]): ShellPromise {
50
+ const command = strings.reduce((acc, str, i) => {
51
+ return acc + str + (values[i] || "");
52
+ }, "");
53
+
54
+ let envVars: Record<string, string> = {};
55
+
56
+ // Always use Node.js child_process for consistent behavior
57
+ const execute = async (options: { quiet?: boolean } = {}) => {
58
+ try {
59
+ const execOptions: any = {
60
+ shell: true,
61
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer
62
+ };
63
+
64
+ // Apply environment variables if set
65
+ if (Object.keys(envVars).length > 0) {
66
+ execOptions.env = { ...process.env, ...envVars };
67
+ }
68
+
69
+ const { stdout, stderr } = await execAsync(command, execOptions);
70
+
71
+ return {
72
+ stdout: stdout?.toString() || "",
73
+ stderr: stderr?.toString() || "",
74
+ exitCode: 0,
75
+ };
76
+ } catch (error: any) {
77
+ // Extract error details
78
+ const code = error.code || 1;
79
+ const stdout = error.stdout?.toString() || "";
80
+ const stderr = error.stderr?.toString() || error.message || "";
81
+
82
+ throw new ShellError(`Command failed: ${command}`, code, stdout, stderr);
83
+ }
84
+ };
85
+
86
+ // Create a lazy promise that only executes when awaited
87
+ let executed = false;
88
+ let resultPromise: Promise<ShellResult> | null = null;
89
+
90
+ const lazyPromise = {
91
+ then(onFulfilled?: any, onRejected?: any) {
92
+ if (!executed) {
93
+ executed = true;
94
+ resultPromise = execute();
95
+ }
96
+ return resultPromise!.then(onFulfilled, onRejected);
97
+ },
98
+ catch(onRejected?: any) {
99
+ return this.then(undefined, onRejected);
100
+ },
101
+ finally(onFinally?: any) {
102
+ return this.then(
103
+ (value: any) => {
104
+ onFinally?.();
105
+ return value;
106
+ },
107
+ (reason: any) => {
108
+ onFinally?.();
109
+ throw reason;
110
+ },
111
+ );
112
+ },
113
+ quiet() {
114
+ return execute({ quiet: true });
115
+ },
116
+ env(vars: Record<string, string>) {
117
+ envVars = vars;
118
+ return this;
119
+ },
120
+ // Add Symbol.toStringTag to satisfy Promise interface
121
+ [Symbol.toStringTag]: "ShellPromise" as const,
122
+ };
123
+
124
+ return lazyPromise as any as ShellPromise;
125
+ }
126
+
127
+ /**
128
+ * Detect available package manager
129
+ */
130
+ async function detectPackageManager(): Promise<"bun" | "npm" | null> {
131
+ try {
132
+ // Check for bun first
133
+ await $`bun --version`.quiet();
134
+ return "bun";
135
+ } catch {
136
+ try {
137
+ // Fall back to npm
138
+ await $`npm --version`.quiet();
139
+ return "npm";
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+ }
145
+
146
+ // Export shell utilities for testing
147
+ export { ShellError, detectPackageManager };
10
148
 
11
149
  // Types
12
150
  export interface Reference {
@@ -51,36 +189,51 @@ export interface CanonicalManager {
51
189
  init(): Promise<void>;
52
190
  destroy(): Promise<void>;
53
191
  packages(): Promise<PackageId[]>;
54
- resolveEntry(canonicalUrl: string, options?: {
55
- package?: string,
56
- version?: string,
57
- sourceContext?: SourceContext
58
- }): Promise<IndexEntry>;
59
- resolve(canonicalUrl: string, options?: {
60
- package?: string,
61
- version?: string,
62
- sourceContext?: SourceContext
63
- }): Promise<Resource>;
192
+ resolveEntry(
193
+ canonicalUrl: string,
194
+ options?: {
195
+ package?: string;
196
+ version?: string;
197
+ sourceContext?: SourceContext;
198
+ },
199
+ ): Promise<IndexEntry>;
200
+ resolve(
201
+ canonicalUrl: string,
202
+ options?: {
203
+ package?: string;
204
+ version?: string;
205
+ sourceContext?: SourceContext;
206
+ },
207
+ ): Promise<Resource>;
64
208
  read(reference: Reference): Promise<Resource>;
65
209
  searchEntries(params: {
66
- kind?: string,
67
- url?: string,
68
- type?: string,
69
- version?: string,
70
- package?: PackageId
210
+ kind?: string;
211
+ url?: string;
212
+ type?: string;
213
+ version?: string;
214
+ package?: PackageId;
71
215
  }): Promise<IndexEntry[]>;
72
216
  search(params: {
73
- kind?: string,
74
- url?: string,
75
- type?: string,
76
- version?: string,
77
- package?: PackageId
217
+ kind?: string;
218
+ url?: string;
219
+ type?: string;
220
+ version?: string;
221
+ package?: PackageId;
78
222
  }): Promise<Resource[]>;
223
+ smartSearch(
224
+ searchTerms: string[],
225
+ filters?: {
226
+ resourceType?: string;
227
+ type?: string;
228
+ kind?: string;
229
+ package?: PackageId;
230
+ }
231
+ ): Promise<IndexEntry[]>;
79
232
  }
80
233
 
81
234
  // Internal types
82
235
  interface IndexFile {
83
- 'index-version': number;
236
+ "index-version": number;
84
237
  files: IndexFileEntry[];
85
238
  }
86
239
 
@@ -149,7 +302,7 @@ const generateReferenceId = (metadata: {
149
302
  filePath: string;
150
303
  }): string => {
151
304
  const input = `${metadata.packageName}@${metadata.packageVersion}:${metadata.filePath}`;
152
- return createHash('sha256').update(input).digest('base64url');
305
+ return createHash("sha256").update(input).digest("base64url");
153
306
  };
154
307
 
155
308
  export const ReferenceManager = (): ReferenceStore & {
@@ -175,8 +328,8 @@ export const ReferenceManager = (): ReferenceStore & {
175
328
  };
176
329
 
177
330
  const clear = (): void => {
178
- Object.keys(references).forEach(key => delete references[key]);
179
- Object.keys(urlToIds).forEach(key => delete urlToIds[key]);
331
+ Object.keys(references).forEach((key) => delete references[key]);
332
+ Object.keys(urlToIds).forEach((key) => delete urlToIds[key]);
180
333
  };
181
334
 
182
335
  return {
@@ -189,32 +342,34 @@ export const ReferenceManager = (): ReferenceStore & {
189
342
  getIdsByUrl: (url: string) => urlToIds[url] || [],
190
343
  createReference: (id: string, metadata: ReferenceMetadata): Reference => ({
191
344
  id,
192
- resourceType: metadata.resourceType
345
+ resourceType: metadata.resourceType,
193
346
  }),
194
- getAllReferences: () => references
347
+ getAllReferences: () => references,
195
348
  };
196
349
  };
197
350
 
198
351
  // Parser functions
199
352
  const isValidFileEntry = (entry: any): boolean => {
200
- if (!entry || typeof entry !== 'object') return false;
201
- if (!entry.filename || typeof entry.filename !== 'string') return false;
202
- if (!entry.resourceType || typeof entry.resourceType !== 'string') return false;
203
- if (!entry.id || typeof entry.id !== 'string') return false;
204
-
205
- const optionalStringFields = ['url', 'version', 'kind', 'type'];
353
+ if (!entry || typeof entry !== "object") return false;
354
+ if (!entry.filename || typeof entry.filename !== "string") return false;
355
+ if (!entry.resourceType || typeof entry.resourceType !== "string")
356
+ return false;
357
+ if (!entry.id || typeof entry.id !== "string") return false;
358
+
359
+ const optionalStringFields = ["url", "version", "kind", "type"];
206
360
  for (const field of optionalStringFields) {
207
- if (entry[field] !== undefined && typeof entry[field] !== 'string') {
361
+ if (entry[field] !== undefined && typeof entry[field] !== "string") {
208
362
  return false;
209
363
  }
210
364
  }
211
-
365
+
212
366
  return true;
213
367
  };
214
368
 
215
369
  const isValidIndexFile = (data: any): boolean => {
216
- if (!data || typeof data !== 'object') return false;
217
- if (!data['index-version'] || typeof data['index-version'] !== 'number') return false;
370
+ if (!data || typeof data !== "object") return false;
371
+ if (!data["index-version"] || typeof data["index-version"] !== "number")
372
+ return false;
218
373
  if (!Array.isArray(data.files)) return false;
219
374
  return data.files.every((file: any) => isValidFileEntry(file));
220
375
  };
@@ -250,7 +405,7 @@ const ensureDir = async (dirPath: string): Promise<void> => {
250
405
  };
251
406
 
252
407
  const isFhirPackage = async (dirPath: string): Promise<boolean> => {
253
- const indexPath = path.join(dirPath, '.index.json');
408
+ const indexPath = path.join(dirPath, ".index.json");
254
409
  return fileExists(indexPath);
255
410
  };
256
411
 
@@ -263,40 +418,56 @@ const createCache = (): IndexCache & {
263
418
  entries: {},
264
419
  packages: {},
265
420
  references: {},
266
- referenceManager
421
+ referenceManager,
267
422
  };
268
423
  };
269
424
 
270
- // Compute hash of package-lock.json for cache validation
271
- const computePackageLockHash = async (workingDir: string): Promise<string | null> => {
425
+ // Compute hash of package-lock.json or bun.lock for cache validation
426
+ const computePackageLockHash = async (
427
+ workingDir: string,
428
+ ): Promise<string | null> => {
272
429
  try {
273
- const packageLockPath = path.join(workingDir, 'package-lock.json');
274
- const content = await fs.readFile(packageLockPath, 'utf-8');
275
- return createHash('sha256').update(content).digest('hex');
430
+ // Try package-lock.json first
431
+ const packageLockPath = path.join(workingDir, "package-lock.json");
432
+ try {
433
+ const content = await fs.readFile(packageLockPath, "utf-8");
434
+ return createHash("sha256").update(content).digest("hex");
435
+ } catch {
436
+ // Try bun.lock if package-lock.json doesn't exist
437
+ const bunLockPath = path.join(workingDir, "bun.lock");
438
+ const content = await fs.readFile(bunLockPath, "utf-8");
439
+ return createHash("sha256").update(content).digest("hex");
440
+ }
276
441
  } catch {
277
442
  return null;
278
443
  }
279
444
  };
280
445
 
281
446
  // Cache persistence functions
282
- const saveCacheToDisk = async (cache: ReturnType<typeof createCache>, cacheDir: string, workingDir: string): Promise<void> => {
447
+ const saveCacheToDisk = async (
448
+ cache: ReturnType<typeof createCache>,
449
+ cacheDir: string,
450
+ workingDir: string,
451
+ ): Promise<void> => {
283
452
  const packageLockHash = await computePackageLockHash(workingDir);
284
-
453
+
285
454
  const cacheData: CacheData = {
286
455
  entries: cache.entries,
287
456
  packages: cache.packages,
288
457
  references: cache.referenceManager.getAllReferences(),
289
- packageLockHash: packageLockHash || undefined
458
+ packageLockHash: packageLockHash || undefined,
290
459
  };
291
-
292
- const cachePath = path.join(cacheDir, 'index.json');
460
+
461
+ const cachePath = path.join(cacheDir, "index.json");
293
462
  await fs.writeFile(cachePath, JSON.stringify(cacheData, null, 2));
294
463
  };
295
464
 
296
- const loadCacheFromDisk = async (cacheDir: string): Promise<CacheData | null> => {
465
+ const loadCacheFromDisk = async (
466
+ cacheDir: string,
467
+ ): Promise<CacheData | null> => {
297
468
  try {
298
- const cachePath = path.join(cacheDir, 'index.json');
299
- const content = await fs.readFile(cachePath, 'utf-8');
469
+ const cachePath = path.join(cacheDir, "index.json");
470
+ const content = await fs.readFile(cachePath, "utf-8");
300
471
  return JSON.parse(content) as CacheData;
301
472
  } catch {
302
473
  return null;
@@ -304,29 +475,57 @@ const loadCacheFromDisk = async (cacheDir: string): Promise<CacheData | null> =>
304
475
  };
305
476
 
306
477
  // Package management functions
307
- const installPackages = async (packages: string[], workingDir: string, registry?: string): Promise<void> => {
478
+ const installPackages = async (
479
+ packages: string[],
480
+ workingDir: string,
481
+ registry?: string,
482
+ ): Promise<void> => {
308
483
  await ensureDir(workingDir);
309
-
484
+
310
485
  // Check if package.json exists
311
- const packageJsonPath = path.join(workingDir, 'package.json');
486
+ const packageJsonPath = path.join(workingDir, "package.json");
312
487
  if (!(await fileExists(packageJsonPath))) {
313
488
  // Create minimal package.json
314
489
  const minimalPackageJson = {
315
490
  name: "fhir-canonical-manager-workspace",
316
491
  version: "1.0.0",
317
492
  private: true,
318
- dependencies: {}
493
+ dependencies: {},
319
494
  };
320
- await fs.writeFile(packageJsonPath, JSON.stringify(minimalPackageJson, null, 2));
495
+ await fs.writeFile(
496
+ packageJsonPath,
497
+ JSON.stringify(minimalPackageJson, null, 2),
498
+ );
321
499
  }
322
-
500
+
501
+ // Detect available package manager
502
+ const packageManager = await detectPackageManager();
503
+ if (!packageManager) {
504
+ throw new Error("No package manager found. Please install npm or bun.");
505
+ }
506
+
323
507
  // Install packages
324
508
  for (const pkg of packages) {
325
509
  try {
326
- if (registry) {
327
- await $`cd ${workingDir} && npm add ${pkg} --registry ${registry}`;
510
+ if (packageManager === "bun") {
511
+ // Use bun with auth bypass trick for FHIR registry
512
+ const env = {
513
+ HOME: workingDir, // Prevent reading user's .npmrc
514
+ NPM_CONFIG_USERCONFIG: "/dev/null", // Extra safety
515
+ };
516
+
517
+ const cmd = registry
518
+ ? `cd ${workingDir} && bun add ${pkg} --registry ${registry}`
519
+ : `cd ${workingDir} && bun add ${pkg}`;
520
+
521
+ await $`${cmd}`.env(env);
328
522
  } else {
329
- await $`cd ${workingDir} && npm add ${pkg}`;
523
+ // Use npm (handles auth correctly)
524
+ const cmd = registry
525
+ ? `cd ${workingDir} && npm add ${pkg} --registry ${registry}`
526
+ : `cd ${workingDir} && npm add ${pkg}`;
527
+
528
+ await $`${cmd}`;
330
529
  }
331
530
  } catch (err) {
332
531
  console.error(`Failed to install package ${pkg}:`, err);
@@ -339,50 +538,50 @@ const installPackages = async (packages: string[], workingDir: string, registry?
339
538
  const processIndex = async (
340
539
  basePath: string,
341
540
  packageJson: PackageJson,
342
- cache: ReturnType<typeof createCache>
541
+ cache: ReturnType<typeof createCache>,
343
542
  ): Promise<void> => {
344
- const indexPath = path.join(basePath, '.index.json');
345
-
543
+ const indexPath = path.join(basePath, ".index.json");
544
+
346
545
  try {
347
- const indexContent = await fs.readFile(indexPath, 'utf-8');
546
+ const indexContent = await fs.readFile(indexPath, "utf-8");
348
547
  const index = parseIndex(indexContent, indexPath);
349
-
548
+
350
549
  if (!index) return;
351
-
550
+
352
551
  for (const file of index.files) {
353
552
  if (!file.url) continue;
354
-
553
+
355
554
  const filePath = path.join(basePath, file.filename);
356
-
555
+
357
556
  const id = cache.referenceManager.generateId({
358
557
  packageName: packageJson.name,
359
558
  packageVersion: packageJson.version,
360
- filePath
559
+ filePath,
361
560
  });
362
-
561
+
363
562
  cache.referenceManager.set(id, {
364
563
  packageName: packageJson.name,
365
564
  packageVersion: packageJson.version,
366
565
  filePath,
367
566
  resourceType: file.resourceType,
368
567
  url: file.url,
369
- version: file.version
568
+ version: file.version,
370
569
  });
371
-
570
+
372
571
  const entry: IndexEntry = {
373
572
  id,
374
573
  resourceType: file.resourceType,
375
- indexVersion: index['index-version'],
574
+ indexVersion: index["index-version"],
376
575
  url: file.url,
377
576
  version: file.version,
378
577
  kind: file.kind,
379
578
  type: file.type,
380
579
  package: {
381
580
  name: packageJson.name,
382
- version: packageJson.version
383
- }
581
+ version: packageJson.version,
582
+ },
384
583
  };
385
-
584
+
386
585
  if (!cache.entries[file.url]) {
387
586
  cache.entries[file.url] = [];
388
587
  }
@@ -398,25 +597,25 @@ const processIndex = async (
398
597
 
399
598
  const scanPackage = async (
400
599
  packagePath: string,
401
- cache: ReturnType<typeof createCache>
600
+ cache: ReturnType<typeof createCache>,
402
601
  ): Promise<void> => {
403
602
  try {
404
- const packageJsonPath = path.join(packagePath, 'package.json');
405
- const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
603
+ const packageJsonPath = path.join(packagePath, "package.json");
604
+ const packageJsonContent = await fs.readFile(packageJsonPath, "utf-8");
406
605
  const packageJson: PackageJson = JSON.parse(packageJsonContent);
407
-
606
+
408
607
  const packageInfo: PackageInfo = {
409
608
  id: { name: packageJson.name, version: packageJson.version },
410
609
  path: packagePath,
411
610
  canonical: packageJson.canonical,
412
- fhirVersions: packageJson.fhirVersions
611
+ fhirVersions: packageJson.fhirVersions,
413
612
  };
414
613
  cache.packages[packageJson.name] = packageInfo;
415
-
614
+
416
615
  await processIndex(packagePath, packageJson, cache);
417
-
418
- const examplesPath = path.join(packagePath, 'examples');
419
- if (await fileExists(path.join(examplesPath, '.index.json'))) {
616
+
617
+ const examplesPath = path.join(packagePath, "examples");
618
+ if (await fileExists(path.join(examplesPath, ".index.json"))) {
420
619
  await processIndex(examplesPath, packageJson, cache);
421
620
  }
422
621
  } catch {
@@ -426,21 +625,23 @@ const scanPackage = async (
426
625
 
427
626
  const scanDirectory = async (
428
627
  dirPath: string,
429
- cache: ReturnType<typeof createCache>
628
+ cache: ReturnType<typeof createCache>,
430
629
  ): Promise<void> => {
431
630
  try {
432
631
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
433
-
632
+
434
633
  for (const entry of entries) {
435
634
  if (!entry.isDirectory()) continue;
436
-
635
+
437
636
  const fullPath = path.join(dirPath, entry.name);
438
-
439
- if (entry.name.startsWith('@')) {
440
- const scopedEntries = await fs.readdir(fullPath, { withFileTypes: true });
637
+
638
+ if (entry.name.startsWith("@")) {
639
+ const scopedEntries = await fs.readdir(fullPath, {
640
+ withFileTypes: true,
641
+ });
441
642
  for (const scopedEntry of scopedEntries) {
442
643
  if (!scopedEntry.isDirectory()) continue;
443
-
644
+
444
645
  const scopedPath = path.join(fullPath, scopedEntry.name);
445
646
  if (await isFhirPackage(scopedPath)) {
446
647
  await scanPackage(scopedPath, cache);
@@ -460,13 +661,13 @@ const resolveWithContext = async (
460
661
  url: string,
461
662
  context: SourceContext,
462
663
  cache: ReturnType<typeof createCache>,
463
- resolveEntry: (url: string, options?: any) => Promise<IndexEntry>
664
+ resolveEntry: (url: string, options?: any) => Promise<IndexEntry>,
464
665
  ): Promise<IndexEntry | null> => {
465
666
  if (context.package) {
466
667
  try {
467
668
  return await resolveEntry(url, {
468
669
  package: context.package.name,
469
- version: context.package.version
670
+ version: context.package.version,
470
671
  });
471
672
  } catch {
472
673
  // Fall through to global resolution
@@ -476,41 +677,46 @@ const resolveWithContext = async (
476
677
  };
477
678
 
478
679
  // Default FHIR package registry
479
- const DEFAULT_REGISTRY = 'https://fs.get-ig.org/pkgs';
680
+ const DEFAULT_REGISTRY = "https://fs.get-ig.org/pkgs/";
480
681
 
481
682
  // Main implementation
482
683
  export const CanonicalManager = (config: Config): CanonicalManager => {
483
- const { packages, workingDir, registry = DEFAULT_REGISTRY } = config;
484
- const nodeModulesPath = path.join(workingDir, 'node_modules');
485
- const cacheDir = path.join(workingDir, '.fcm', 'cache');
486
-
684
+ const { packages, workingDir } = config;
685
+ // Ensure registry URL ends with /
686
+ const registry = config.registry
687
+ ? config.registry.endsWith('/') ? config.registry : `${config.registry}/`
688
+ : DEFAULT_REGISTRY;
689
+ const nodeModulesPath = path.join(workingDir, "node_modules");
690
+ const cacheDir = path.join(workingDir, ".fcm", "cache");
691
+
487
692
  let cache = createCache();
488
693
  let initialized = false;
489
694
 
490
695
  const ensureInitialized = (): void => {
491
696
  if (!initialized) {
492
- throw new Error('CanonicalManager not initialized. Call init() first.');
697
+ throw new Error("CanonicalManager not initialized. Call init() first.");
493
698
  }
494
699
  };
495
700
 
496
701
  const init = async (): Promise<void> => {
497
702
  if (initialized) return;
498
-
703
+
499
704
  // Ensure directories exist
500
705
  await ensureDir(workingDir);
501
706
  await ensureDir(cacheDir);
502
-
707
+
503
708
  // Get current package-lock.json hash
504
709
  const currentPackageLockHash = await computePackageLockHash(workingDir);
505
-
710
+
506
711
  // Try to load cache from disk
507
712
  const cachedData = await loadCacheFromDisk(cacheDir);
508
-
713
+
509
714
  // Check if cache is valid (exists and package-lock.json hasn't changed)
510
- const cacheValid = cachedData &&
715
+ const cacheValid =
716
+ cachedData &&
511
717
  cachedData.packageLockHash === currentPackageLockHash &&
512
718
  currentPackageLockHash !== null;
513
-
719
+
514
720
  if (cacheValid) {
515
721
  // Restore cache from disk
516
722
  cache.entries = cachedData.entries;
@@ -521,22 +727,22 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
521
727
  } else {
522
728
  // Cache is invalid or doesn't exist - rebuild it
523
729
  if (cachedData && cachedData.packageLockHash !== currentPackageLockHash) {
524
- console.log('Package dependencies have changed, rebuilding index...');
730
+ console.log("Package dependencies have changed, rebuilding index...");
525
731
  }
526
-
732
+
527
733
  // Install packages if needed
528
734
  await installPackages(packages, workingDir, registry);
529
-
735
+
530
736
  // Clear cache before scanning
531
737
  cache = createCache();
532
-
738
+
533
739
  // Scan installed packages
534
740
  await scanDirectory(nodeModulesPath, cache);
535
-
741
+
536
742
  // Save cache to disk with current package-lock hash
537
743
  await saveCacheToDisk(cache, cacheDir, workingDir);
538
744
  }
539
-
745
+
540
746
  initialized = true;
541
747
  };
542
748
 
@@ -549,7 +755,7 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
549
755
 
550
756
  const getPackages = async (): Promise<PackageId[]> => {
551
757
  ensureInitialized();
552
- return Object.values(cache.packages).map(p => p.id);
758
+ return Object.values(cache.packages).map((p) => p.id);
553
759
  };
554
760
 
555
761
  const resolveEntry = async (
@@ -558,7 +764,7 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
558
764
  package?: string;
559
765
  version?: string;
560
766
  sourceContext?: SourceContext;
561
- }
767
+ },
562
768
  ): Promise<IndexEntry> => {
563
769
  ensureInitialized();
564
770
 
@@ -567,7 +773,7 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
567
773
  canonicalUrl,
568
774
  options.sourceContext,
569
775
  cache,
570
- resolveEntry
776
+ resolveEntry,
571
777
  );
572
778
  if (contextResolved) {
573
779
  return contextResolved;
@@ -575,23 +781,25 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
575
781
  }
576
782
 
577
783
  const entries = cache.entries[canonicalUrl] || [];
578
-
784
+
579
785
  if (entries.length === 0) {
580
786
  throw new Error(`Cannot resolve canonical URL: ${canonicalUrl}`);
581
787
  }
582
788
 
583
789
  let filtered = [...entries];
584
-
790
+
585
791
  if (options?.package) {
586
- filtered = filtered.filter(e => e.package?.name === options.package);
792
+ filtered = filtered.filter((e) => e.package?.name === options.package);
587
793
  }
588
-
794
+
589
795
  if (options?.version) {
590
- filtered = filtered.filter(e => e.version === options.version);
796
+ filtered = filtered.filter((e) => e.version === options.version);
591
797
  }
592
798
 
593
799
  if (filtered.length === 0) {
594
- throw new Error(`No matching resource found for ${canonicalUrl} with given options`);
800
+ throw new Error(
801
+ `No matching resource found for ${canonicalUrl} with given options`,
802
+ );
595
803
  }
596
804
 
597
805
  return filtered[0]!;
@@ -603,7 +811,7 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
603
811
  package?: string;
604
812
  version?: string;
605
813
  sourceContext?: SourceContext;
606
- }
814
+ },
607
815
  ): Promise<Resource> => {
608
816
  const entry = await resolveEntry(canonicalUrl, options);
609
817
  return read(entry);
@@ -611,20 +819,20 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
611
819
 
612
820
  const read = async (reference: Reference): Promise<Resource> => {
613
821
  ensureInitialized();
614
-
822
+
615
823
  const metadata = cache.referenceManager.get(reference.id);
616
824
  if (!metadata) {
617
825
  throw new Error(`Invalid reference ID: ${reference.id}`);
618
826
  }
619
827
 
620
828
  try {
621
- const content = await fs.readFile(metadata.filePath, 'utf-8');
829
+ const content = await fs.readFile(metadata.filePath, "utf-8");
622
830
  const resource = JSON.parse(content);
623
-
831
+
624
832
  return {
625
833
  ...resource,
626
834
  id: reference.id,
627
- resourceType: reference.resourceType
835
+ resourceType: reference.resourceType,
628
836
  };
629
837
  } catch (err) {
630
838
  throw new Error(`Failed to read resource: ${err}`);
@@ -639,9 +847,9 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
639
847
  package?: PackageId;
640
848
  }): Promise<IndexEntry[]> => {
641
849
  ensureInitialized();
642
-
850
+
643
851
  let results: IndexEntry[] = [];
644
-
852
+
645
853
  if (params.url) {
646
854
  results = cache.entries[params.url] || [];
647
855
  } else {
@@ -649,27 +857,27 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
649
857
  results.push(...entries);
650
858
  }
651
859
  }
652
-
860
+
653
861
  if (params.kind !== undefined) {
654
- results = results.filter(e => e.kind === params.kind);
862
+ results = results.filter((e) => e.kind === params.kind);
655
863
  }
656
-
864
+
657
865
  if (params.type !== undefined) {
658
- results = results.filter(e => e.type === params.type);
866
+ results = results.filter((e) => e.type === params.type);
659
867
  }
660
-
868
+
661
869
  if (params.version !== undefined) {
662
- results = results.filter(e => e.version === params.version);
870
+ results = results.filter((e) => e.version === params.version);
663
871
  }
664
-
872
+
665
873
  if (params.package) {
666
874
  const pkg = params.package;
667
- results = results.filter(e =>
668
- e.package?.name === pkg.name &&
669
- e.package?.version === pkg.version
875
+ results = results.filter(
876
+ (e) =>
877
+ e.package?.name === pkg.name && e.package?.version === pkg.version,
670
878
  );
671
879
  }
672
-
880
+
673
881
  return results;
674
882
  };
675
883
 
@@ -681,10 +889,98 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
681
889
  package?: PackageId;
682
890
  }): Promise<Resource[]> => {
683
891
  const entries = await searchEntries(params);
684
- const resources = await Promise.all(entries.map(entry => read(entry)));
892
+ const resources = await Promise.all(entries.map((entry) => read(entry)));
685
893
  return resources;
686
894
  };
687
895
 
896
+ const smartSearch = async (
897
+ searchTerms: string[],
898
+ filters?: {
899
+ resourceType?: string;
900
+ type?: string;
901
+ kind?: string;
902
+ package?: PackageId;
903
+ }
904
+ ): Promise<IndexEntry[]> => {
905
+ ensureInitialized();
906
+
907
+ // Start with base search using filters
908
+ let results = await searchEntries({
909
+ kind: filters?.kind,
910
+ package: filters?.package,
911
+ });
912
+
913
+ // Apply resourceType filter
914
+ if (filters?.resourceType) {
915
+ results = results.filter(entry => entry.resourceType === filters.resourceType);
916
+ }
917
+
918
+ // Apply type filter
919
+ if (filters?.type) {
920
+ results = results.filter(entry => entry.type === filters.type);
921
+ }
922
+
923
+ // Apply smart search terms if provided
924
+ if (searchTerms.length > 0) {
925
+ const terms = searchTerms.map(t => t.toLowerCase());
926
+
927
+ results = results.filter(entry => {
928
+ if (!entry.url) return false;
929
+ const urlLower = entry.url.toLowerCase();
930
+
931
+ // Also check type and resourceType for matching
932
+ const fullText = [
933
+ urlLower,
934
+ entry.type?.toLowerCase() || '',
935
+ entry.resourceType?.toLowerCase() || ''
936
+ ].join(' ');
937
+
938
+ // Check if all search terms match
939
+ return terms.every(term => {
940
+ // Split the text into parts (by /, -, _, ., spaces)
941
+ const allParts = fullText.split(/[\/\-_\.\s]+/);
942
+
943
+ // Check if any part starts with the search term
944
+ const directMatch = allParts.some(part => part.startsWith(term));
945
+ if (directMatch) return true;
946
+
947
+ // Smart matching for common abbreviations
948
+ const expandedTerms: Record<string, string[]> = {
949
+ 'str': ['structure'],
950
+ 'struct': ['structure'],
951
+ 'def': ['definition'],
952
+ 'pati': ['patient'],
953
+ 'obs': ['observation'],
954
+ 'org': ['organization'],
955
+ 'pract': ['practitioner'],
956
+ 'med': ['medication', 'medicinal'],
957
+ 'req': ['request'],
958
+ 'resp': ['response'],
959
+ 'ref': ['reference'],
960
+ 'val': ['value'],
961
+ 'code': ['codesystem', 'code'],
962
+ 'cs': ['codesystem'],
963
+ 'vs': ['valueset'],
964
+ 'sd': ['structuredefinition'],
965
+ };
966
+
967
+ // Check if the term is an abbreviation
968
+ const expansions = expandedTerms[term] || [];
969
+ for (const expansion of expansions) {
970
+ if (allParts.some(part => part.startsWith(expansion))) {
971
+ return true;
972
+ }
973
+ }
974
+
975
+ // If still no match, check if the term appears anywhere (substring match)
976
+ return fullText.includes(term);
977
+ });
978
+ });
979
+ }
980
+
981
+ return results;
982
+ };
983
+
688
984
  return {
689
985
  init,
690
986
  destroy,
@@ -693,9 +989,10 @@ export const CanonicalManager = (config: Config): CanonicalManager => {
693
989
  resolve,
694
990
  read,
695
991
  searchEntries,
696
- search
992
+ search,
993
+ smartSearch,
697
994
  };
698
995
  };
699
996
 
700
997
  // Default export
701
- export default CanonicalManager;
998
+ export default CanonicalManager;