@enactprotocol/shared 2.1.23 → 2.1.28

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/resolver.ts CHANGED
@@ -10,7 +10,12 @@
10
10
 
11
11
  import { existsSync, readdirSync } from "node:fs";
12
12
  import { dirname, isAbsolute, join, resolve } from "node:path";
13
- import { findManifestFile, loadManifest } from "./manifest/loader";
13
+ import {
14
+ type LoadManifestOptions,
15
+ ManifestLoadError,
16
+ findManifestFile,
17
+ loadManifest,
18
+ } from "./manifest/loader";
14
19
  import { getCacheDir, getProjectEnactDir } from "./paths";
15
20
  import { getInstalledVersion, getToolCachePath } from "./registry";
16
21
  import type { ToolLocation, ToolResolution } from "./types/manifest";
@@ -29,6 +34,22 @@ export class ToolResolveError extends Error {
29
34
  }
30
35
  }
31
36
 
37
+ /**
38
+ * Result of trying to resolve a tool with error details
39
+ */
40
+ export interface TryResolveResult {
41
+ /** The resolved tool, or null if not found/invalid */
42
+ resolution: ToolResolution | null;
43
+ /** Error that occurred during resolution, if any */
44
+ error?: Error;
45
+ /** Locations that were searched */
46
+ searchedLocations: string[];
47
+ /** Whether a manifest was found but had errors */
48
+ manifestFound: boolean;
49
+ /** Path where manifest was found (if any) */
50
+ manifestPath?: string;
51
+ }
52
+
32
53
  /**
33
54
  * Options for tool resolution
34
55
  */
@@ -73,9 +94,14 @@ export function getToolPath(toolsDir: string, toolName: string): string {
73
94
  *
74
95
  * @param dir - Directory to check
75
96
  * @param location - The location type for metadata
97
+ * @param options - Options for loading the manifest
76
98
  * @returns ToolResolution or null if not found/invalid
77
99
  */
78
- function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | null {
100
+ function tryLoadFromDir(
101
+ dir: string,
102
+ location: ToolLocation,
103
+ options: LoadManifestOptions = {}
104
+ ): ToolResolution | null {
79
105
  if (!existsSync(dir)) {
80
106
  return null;
81
107
  }
@@ -86,7 +112,7 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
86
112
  }
87
113
 
88
114
  try {
89
- const loaded = loadManifest(manifestPath);
115
+ const loaded = loadManifest(manifestPath, options);
90
116
  return {
91
117
  manifest: loaded.manifest,
92
118
  sourceDir: dir,
@@ -103,6 +129,9 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
103
129
  /**
104
130
  * Resolve a tool from a file path
105
131
  *
132
+ * Local/file tools are allowed to have simple names (without hierarchy)
133
+ * since they don't need to be published.
134
+ *
106
135
  * @param filePath - Path to manifest file or directory containing manifest
107
136
  * @returns ToolResolution
108
137
  * @throws ToolResolveError if not found
@@ -110,6 +139,9 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
110
139
  export function resolveToolFromPath(filePath: string): ToolResolution {
111
140
  const absolutePath = isAbsolute(filePath) ? filePath : resolve(filePath);
112
141
 
142
+ // Local tools can have simple names (no hierarchy required)
143
+ const localOptions: LoadManifestOptions = { allowSimpleNames: true };
144
+
113
145
  // Check if it's a manifest file directly
114
146
  if (
115
147
  absolutePath.endsWith(".yaml") ||
@@ -120,7 +152,7 @@ export function resolveToolFromPath(filePath: string): ToolResolution {
120
152
  throw new ToolResolveError(`Manifest file not found: ${absolutePath}`, filePath);
121
153
  }
122
154
 
123
- const loaded = loadManifest(absolutePath);
155
+ const loaded = loadManifest(absolutePath, localOptions);
124
156
  return {
125
157
  manifest: loaded.manifest,
126
158
  sourceDir: dirname(absolutePath),
@@ -131,7 +163,7 @@ export function resolveToolFromPath(filePath: string): ToolResolution {
131
163
  }
132
164
 
133
165
  // Treat as directory
134
- const result = tryLoadFromDir(absolutePath, "file");
166
+ const result = tryLoadFromDir(absolutePath, "file", localOptions);
135
167
  if (result) {
136
168
  return result;
137
169
  }
@@ -256,21 +288,123 @@ export function tryResolveTool(
256
288
  toolNameOrPath: string,
257
289
  options: ResolveOptions = {}
258
290
  ): ToolResolution | null {
291
+ const result = tryResolveToolDetailed(toolNameOrPath, options);
292
+ return result.resolution;
293
+ }
294
+
295
+ /**
296
+ * Try to resolve a tool with detailed error information
297
+ *
298
+ * Unlike tryResolveTool, this function returns information about why
299
+ * resolution failed, allowing callers to provide better error messages.
300
+ *
301
+ * @param toolNameOrPath - Tool name or path
302
+ * @param options - Resolution options
303
+ * @returns TryResolveResult with resolution or error details
304
+ */
305
+ export function tryResolveToolDetailed(
306
+ toolNameOrPath: string,
307
+ options: ResolveOptions = {}
308
+ ): TryResolveResult {
309
+ const searchedLocations: string[] = [];
310
+
311
+ // Check if it looks like a path
312
+ const isPath =
313
+ toolNameOrPath.startsWith("/") ||
314
+ toolNameOrPath.startsWith("./") ||
315
+ toolNameOrPath.startsWith("../") ||
316
+ toolNameOrPath.includes("\\") ||
317
+ existsSync(toolNameOrPath);
318
+
319
+ if (isPath) {
320
+ // Resolve from path
321
+ const absolutePath = isAbsolute(toolNameOrPath) ? toolNameOrPath : resolve(toolNameOrPath);
322
+ searchedLocations.push(absolutePath);
323
+
324
+ // Check if path exists
325
+ if (!existsSync(absolutePath)) {
326
+ return {
327
+ resolution: null,
328
+ searchedLocations,
329
+ manifestFound: false,
330
+ };
331
+ }
332
+
333
+ // Find manifest file
334
+ const manifestPath =
335
+ absolutePath.endsWith(".yaml") ||
336
+ absolutePath.endsWith(".yml") ||
337
+ absolutePath.endsWith(".md")
338
+ ? absolutePath
339
+ : findManifestFile(absolutePath);
340
+
341
+ if (!manifestPath) {
342
+ return {
343
+ resolution: null,
344
+ searchedLocations,
345
+ manifestFound: false,
346
+ };
347
+ }
348
+
349
+ // Try to load the manifest
350
+ try {
351
+ const resolution = resolveToolFromPath(toolNameOrPath);
352
+ return {
353
+ resolution,
354
+ searchedLocations,
355
+ manifestFound: true,
356
+ manifestPath,
357
+ };
358
+ } catch (error) {
359
+ // Manifest found but invalid
360
+ return {
361
+ resolution: null,
362
+ error: error instanceof Error ? error : new Error(String(error)),
363
+ searchedLocations,
364
+ manifestFound: true,
365
+ manifestPath,
366
+ };
367
+ }
368
+ }
369
+
370
+ // Resolve by name
259
371
  try {
260
- // Check if it looks like a path
261
- if (
262
- toolNameOrPath.startsWith("/") ||
263
- toolNameOrPath.startsWith("./") ||
264
- toolNameOrPath.startsWith("../") ||
265
- toolNameOrPath.includes("\\") ||
266
- existsSync(toolNameOrPath)
267
- ) {
268
- return resolveToolFromPath(toolNameOrPath);
372
+ const resolution = resolveTool(toolNameOrPath, options);
373
+ return {
374
+ resolution,
375
+ searchedLocations: getToolSearchPaths(toolNameOrPath, options),
376
+ manifestFound: true,
377
+ manifestPath: resolution.manifestPath,
378
+ };
379
+ } catch (error) {
380
+ // Check if error is due to manifest validation vs not found
381
+ if (error instanceof ToolResolveError) {
382
+ return {
383
+ resolution: null,
384
+ error,
385
+ searchedLocations: error.searchedLocations ?? [],
386
+ manifestFound: false,
387
+ };
269
388
  }
270
389
 
271
- return resolveTool(toolNameOrPath, options);
272
- } catch {
273
- return null;
390
+ // ManifestLoadError means manifest was found but invalid
391
+ if (error instanceof ManifestLoadError) {
392
+ return {
393
+ resolution: null,
394
+ error,
395
+ searchedLocations: getToolSearchPaths(toolNameOrPath, options),
396
+ manifestFound: true,
397
+ manifestPath: error.filePath,
398
+ };
399
+ }
400
+
401
+ // Other error
402
+ return {
403
+ resolution: null,
404
+ error: error instanceof Error ? error : new Error(String(error)),
405
+ searchedLocations: getToolSearchPaths(toolNameOrPath, options),
406
+ manifestFound: false,
407
+ };
274
408
  }
275
409
  }
276
410
 
@@ -298,7 +432,8 @@ export function resolveToolAuto(
298
432
 
299
433
  // Check if the path exists as-is (could be a relative directory without ./)
300
434
  if (existsSync(toolNameOrPath)) {
301
- const result = tryLoadFromDir(resolve(toolNameOrPath), "file");
435
+ // Local tools can have simple names (no hierarchy required)
436
+ const result = tryLoadFromDir(resolve(toolNameOrPath), "file", { allowSimpleNames: true });
302
437
  if (result) {
303
438
  return result;
304
439
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ isValidLocalToolName,
3
4
  isValidTimeout,
4
5
  isValidToolName,
5
6
  isValidVersion,
@@ -237,6 +238,63 @@ describe("manifest validator", () => {
237
238
  });
238
239
  });
239
240
 
241
+ describe("allowSimpleNames option", () => {
242
+ test("rejects simple names by default", () => {
243
+ const manifest = {
244
+ name: "my-tool", // No slash
245
+ description: "A local tool",
246
+ };
247
+
248
+ const result = validateManifest(manifest);
249
+ expect(result.valid).toBe(false);
250
+ expect(result.errors?.some((e) => e.path === "name")).toBe(true);
251
+ });
252
+
253
+ test("accepts simple names with allowSimpleNames option", () => {
254
+ const manifest = {
255
+ name: "my-tool",
256
+ description: "A local tool",
257
+ };
258
+
259
+ const result = validateManifest(manifest, { allowSimpleNames: true });
260
+ expect(result.valid).toBe(true);
261
+ });
262
+
263
+ test("accepts hierarchical names with allowSimpleNames option", () => {
264
+ const manifest = {
265
+ name: "org/tool",
266
+ description: "A published tool",
267
+ };
268
+
269
+ const result = validateManifest(manifest, { allowSimpleNames: true });
270
+ expect(result.valid).toBe(true);
271
+ });
272
+
273
+ test("still rejects invalid characters with allowSimpleNames", () => {
274
+ const manifest = {
275
+ name: "My-Tool", // Uppercase not allowed
276
+ description: "Invalid tool name",
277
+ };
278
+
279
+ const result = validateManifest(manifest, { allowSimpleNames: true });
280
+ expect(result.valid).toBe(false);
281
+ });
282
+
283
+ test("validateManifestStrict respects allowSimpleNames option", () => {
284
+ const manifest = {
285
+ name: "local-tool",
286
+ description: "A local tool",
287
+ };
288
+
289
+ // Should throw without option
290
+ expect(() => validateManifestStrict(manifest)).toThrow();
291
+
292
+ // Should succeed with option
293
+ const result = validateManifestStrict(manifest, { allowSimpleNames: true });
294
+ expect(result.name).toBe("local-tool");
295
+ });
296
+ });
297
+
240
298
  describe("warnings", () => {
241
299
  test("warns about missing recommended fields", () => {
242
300
  const manifest = {
@@ -360,6 +418,25 @@ describe("manifest validator", () => {
360
418
  });
361
419
  });
362
420
 
421
+ describe("isValidLocalToolName", () => {
422
+ test("returns true for simple names (no hierarchy)", () => {
423
+ expect(isValidLocalToolName("my-tool")).toBe(true);
424
+ expect(isValidLocalToolName("tool_name")).toBe(true);
425
+ expect(isValidLocalToolName("simple")).toBe(true);
426
+ });
427
+
428
+ test("returns true for hierarchical names", () => {
429
+ expect(isValidLocalToolName("org/tool")).toBe(true);
430
+ expect(isValidLocalToolName("acme/utils/greeter")).toBe(true);
431
+ });
432
+
433
+ test("returns false for invalid names", () => {
434
+ expect(isValidLocalToolName("My-Tool")).toBe(false); // Uppercase
435
+ expect(isValidLocalToolName("tool name")).toBe(false); // Space
436
+ expect(isValidLocalToolName("")).toBe(false);
437
+ });
438
+ });
439
+
363
440
  describe("isValidVersion", () => {
364
441
  test("returns true for valid semver", () => {
365
442
  expect(isValidVersion("1.0.0")).toBe(true);