@enactprotocol/shared 2.1.22 → 2.1.24
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/dist/execution/command.d.ts +38 -4
- package/dist/execution/command.d.ts.map +1 -1
- package/dist/execution/command.js +42 -8
- package/dist/execution/command.js.map +1 -1
- package/dist/execution/index.d.ts +1 -1
- package/dist/execution/index.d.ts.map +1 -1
- package/dist/execution/index.js.map +1 -1
- package/dist/execution/types.d.ts +6 -0
- package/dist/execution/types.d.ts.map +1 -1
- package/dist/manifest/index.d.ts +2 -2
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/index.js +1 -1
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/loader.d.ts +14 -4
- package/dist/manifest/loader.d.ts.map +1 -1
- package/dist/manifest/loader.js +12 -8
- package/dist/manifest/loader.js.map +1 -1
- package/dist/manifest/validator.d.ts +21 -4
- package/dist/manifest/validator.d.ts.map +1 -1
- package/dist/manifest/validator.js +72 -47
- package/dist/manifest/validator.js.map +1 -1
- package/dist/resolver.d.ts +3 -0
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +12 -5
- package/dist/resolver.js.map +1 -1
- package/package.json +2 -2
- package/src/execution/command.ts +58 -7
- package/src/execution/index.ts +1 -0
- package/src/execution/types.ts +6 -0
- package/src/manifest/index.ts +3 -0
- package/src/manifest/loader.ts +29 -9
- package/src/manifest/validator.ts +102 -57
- package/src/resolver.ts +18 -6
- package/tests/execution/command.test.ts +231 -0
- package/tests/manifest/validator.test.ts +77 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -83,68 +83,85 @@ const SEMVER_REGEX =
|
|
|
83
83
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Tool name regex - hierarchical path format
|
|
86
|
+
* Tool name regex - hierarchical path format (required for publishing)
|
|
87
87
|
*/
|
|
88
88
|
const TOOL_NAME_REGEX = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)+$/;
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Tool name regex - simple format (allowed for local tools)
|
|
92
|
+
* Allows both hierarchical (org/tool) and simple (my-tool) names
|
|
93
|
+
*/
|
|
94
|
+
const TOOL_NAME_REGEX_LOCAL = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
|
|
95
|
+
|
|
90
96
|
/**
|
|
91
97
|
* Go duration regex (used for timeout)
|
|
92
98
|
*/
|
|
93
99
|
const GO_DURATION_REGEX = /^(\d+)(ns|us|µs|ms|s|m|h)$/;
|
|
94
100
|
|
|
95
101
|
/**
|
|
96
|
-
*
|
|
102
|
+
* Create a tool manifest schema with configurable name validation
|
|
97
103
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
name
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
),
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
104
|
+
function createToolManifestSchema(allowSimpleNames: boolean) {
|
|
105
|
+
const nameRegex = allowSimpleNames ? TOOL_NAME_REGEX_LOCAL : TOOL_NAME_REGEX;
|
|
106
|
+
const nameMessage = allowSimpleNames
|
|
107
|
+
? "Tool name must contain only lowercase letters, numbers, hyphens, and underscores"
|
|
108
|
+
: "Tool name must be hierarchical path format (e.g., 'org/tool' or 'org/category/tool')";
|
|
109
|
+
|
|
110
|
+
return z
|
|
111
|
+
.object({
|
|
112
|
+
// Required fields
|
|
113
|
+
name: z.string().min(1, "Tool name is required").regex(nameRegex, nameMessage),
|
|
114
|
+
|
|
115
|
+
description: z
|
|
116
|
+
.string()
|
|
117
|
+
.min(1, "Description is required")
|
|
118
|
+
.max(500, "Description should be 500 characters or less"),
|
|
119
|
+
|
|
120
|
+
// Recommended fields
|
|
121
|
+
enact: z.string().optional(),
|
|
122
|
+
version: z
|
|
123
|
+
.string()
|
|
124
|
+
.regex(SEMVER_REGEX, "Version must be valid semver (e.g., '1.0.0')")
|
|
125
|
+
.optional(),
|
|
126
|
+
from: z.string().optional(),
|
|
127
|
+
command: z.string().optional(),
|
|
128
|
+
timeout: z
|
|
129
|
+
.string()
|
|
130
|
+
.regex(GO_DURATION_REGEX, "Timeout must be Go duration format (e.g., '30s', '5m', '1h')")
|
|
131
|
+
.optional(),
|
|
132
|
+
license: z.string().optional(),
|
|
133
|
+
tags: z.array(z.string()).optional(),
|
|
134
|
+
|
|
135
|
+
// Schema fields
|
|
136
|
+
inputSchema: JsonSchemaSchema.optional(),
|
|
137
|
+
outputSchema: JsonSchemaSchema.optional(),
|
|
138
|
+
|
|
139
|
+
// Environment variables
|
|
140
|
+
env: z.record(z.string(), EnvVariableSchema).optional(),
|
|
141
|
+
|
|
142
|
+
// Behavior & Resources
|
|
143
|
+
annotations: ToolAnnotationsSchema.optional(),
|
|
144
|
+
resources: ResourceRequirementsSchema.optional(),
|
|
145
|
+
|
|
146
|
+
// Documentation
|
|
147
|
+
doc: z.string().optional(),
|
|
148
|
+
authors: z.array(AuthorSchema).optional(),
|
|
149
|
+
|
|
150
|
+
// Testing
|
|
151
|
+
examples: z.array(ToolExampleSchema).optional(),
|
|
152
|
+
})
|
|
153
|
+
.passthrough(); // Allow x-* custom fields
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Complete tool manifest schema (strict - requires hierarchical names)
|
|
158
|
+
*/
|
|
159
|
+
const ToolManifestSchema = createToolManifestSchema(false);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Local tool manifest schema (relaxed - allows simple names)
|
|
163
|
+
*/
|
|
164
|
+
const ToolManifestSchemaLocal = createToolManifestSchema(true);
|
|
148
165
|
|
|
149
166
|
// ==================== Validation Functions ====================
|
|
150
167
|
|
|
@@ -239,14 +256,31 @@ function generateWarnings(manifest: ToolManifest): ValidationWarning[] {
|
|
|
239
256
|
return warnings;
|
|
240
257
|
}
|
|
241
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Options for manifest validation
|
|
261
|
+
*/
|
|
262
|
+
export interface ValidateManifestOptions {
|
|
263
|
+
/**
|
|
264
|
+
* Allow simple tool names without hierarchy (e.g., "my-tool" instead of "org/my-tool").
|
|
265
|
+
* Use this for local tools that won't be published.
|
|
266
|
+
* @default false
|
|
267
|
+
*/
|
|
268
|
+
allowSimpleNames?: boolean;
|
|
269
|
+
}
|
|
270
|
+
|
|
242
271
|
/**
|
|
243
272
|
* Validate a tool manifest
|
|
244
273
|
*
|
|
245
274
|
* @param manifest - The manifest to validate (parsed but unvalidated)
|
|
275
|
+
* @param options - Validation options
|
|
246
276
|
* @returns ValidationResult with valid flag, errors, and warnings
|
|
247
277
|
*/
|
|
248
|
-
export function validateManifest(
|
|
249
|
-
|
|
278
|
+
export function validateManifest(
|
|
279
|
+
manifest: unknown,
|
|
280
|
+
options: ValidateManifestOptions = {}
|
|
281
|
+
): ValidationResult {
|
|
282
|
+
const schema = options.allowSimpleNames ? ToolManifestSchemaLocal : ToolManifestSchema;
|
|
283
|
+
const result = schema.safeParse(manifest);
|
|
250
284
|
|
|
251
285
|
if (!result.success) {
|
|
252
286
|
return {
|
|
@@ -270,11 +304,15 @@ export function validateManifest(manifest: unknown): ValidationResult {
|
|
|
270
304
|
* Throws if validation fails
|
|
271
305
|
*
|
|
272
306
|
* @param manifest - The manifest to validate
|
|
307
|
+
* @param options - Validation options
|
|
273
308
|
* @returns The validated ToolManifest
|
|
274
309
|
* @throws Error if validation fails
|
|
275
310
|
*/
|
|
276
|
-
export function validateManifestStrict(
|
|
277
|
-
|
|
311
|
+
export function validateManifestStrict(
|
|
312
|
+
manifest: unknown,
|
|
313
|
+
options: ValidateManifestOptions = {}
|
|
314
|
+
): ToolManifest {
|
|
315
|
+
const result = validateManifest(manifest, options);
|
|
278
316
|
|
|
279
317
|
if (!result.valid) {
|
|
280
318
|
const errorMessages = result.errors?.map((e) => `${e.path}: ${e.message}`).join(", ");
|
|
@@ -285,12 +323,19 @@ export function validateManifestStrict(manifest: unknown): ToolManifest {
|
|
|
285
323
|
}
|
|
286
324
|
|
|
287
325
|
/**
|
|
288
|
-
* Check if a string is a valid tool name
|
|
326
|
+
* Check if a string is a valid tool name (hierarchical format for publishing)
|
|
289
327
|
*/
|
|
290
328
|
export function isValidToolName(name: string): boolean {
|
|
291
329
|
return TOOL_NAME_REGEX.test(name);
|
|
292
330
|
}
|
|
293
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Check if a string is a valid local tool name (allows simple names)
|
|
334
|
+
*/
|
|
335
|
+
export function isValidLocalToolName(name: string): boolean {
|
|
336
|
+
return TOOL_NAME_REGEX_LOCAL.test(name);
|
|
337
|
+
}
|
|
338
|
+
|
|
294
339
|
/**
|
|
295
340
|
* Check if a string is a valid semver version
|
|
296
341
|
*/
|
package/src/resolver.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
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 { type LoadManifestOptions, findManifestFile, loadManifest } from "./manifest/loader";
|
|
14
14
|
import { getCacheDir, getProjectEnactDir } from "./paths";
|
|
15
15
|
import { getInstalledVersion, getToolCachePath } from "./registry";
|
|
16
16
|
import type { ToolLocation, ToolResolution } from "./types/manifest";
|
|
@@ -73,9 +73,14 @@ export function getToolPath(toolsDir: string, toolName: string): string {
|
|
|
73
73
|
*
|
|
74
74
|
* @param dir - Directory to check
|
|
75
75
|
* @param location - The location type for metadata
|
|
76
|
+
* @param options - Options for loading the manifest
|
|
76
77
|
* @returns ToolResolution or null if not found/invalid
|
|
77
78
|
*/
|
|
78
|
-
function tryLoadFromDir(
|
|
79
|
+
function tryLoadFromDir(
|
|
80
|
+
dir: string,
|
|
81
|
+
location: ToolLocation,
|
|
82
|
+
options: LoadManifestOptions = {}
|
|
83
|
+
): ToolResolution | null {
|
|
79
84
|
if (!existsSync(dir)) {
|
|
80
85
|
return null;
|
|
81
86
|
}
|
|
@@ -86,7 +91,7 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
|
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
try {
|
|
89
|
-
const loaded = loadManifest(manifestPath);
|
|
94
|
+
const loaded = loadManifest(manifestPath, options);
|
|
90
95
|
return {
|
|
91
96
|
manifest: loaded.manifest,
|
|
92
97
|
sourceDir: dir,
|
|
@@ -103,6 +108,9 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
|
|
|
103
108
|
/**
|
|
104
109
|
* Resolve a tool from a file path
|
|
105
110
|
*
|
|
111
|
+
* Local/file tools are allowed to have simple names (without hierarchy)
|
|
112
|
+
* since they don't need to be published.
|
|
113
|
+
*
|
|
106
114
|
* @param filePath - Path to manifest file or directory containing manifest
|
|
107
115
|
* @returns ToolResolution
|
|
108
116
|
* @throws ToolResolveError if not found
|
|
@@ -110,6 +118,9 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
|
|
|
110
118
|
export function resolveToolFromPath(filePath: string): ToolResolution {
|
|
111
119
|
const absolutePath = isAbsolute(filePath) ? filePath : resolve(filePath);
|
|
112
120
|
|
|
121
|
+
// Local tools can have simple names (no hierarchy required)
|
|
122
|
+
const localOptions: LoadManifestOptions = { allowSimpleNames: true };
|
|
123
|
+
|
|
113
124
|
// Check if it's a manifest file directly
|
|
114
125
|
if (
|
|
115
126
|
absolutePath.endsWith(".yaml") ||
|
|
@@ -120,7 +131,7 @@ export function resolveToolFromPath(filePath: string): ToolResolution {
|
|
|
120
131
|
throw new ToolResolveError(`Manifest file not found: ${absolutePath}`, filePath);
|
|
121
132
|
}
|
|
122
133
|
|
|
123
|
-
const loaded = loadManifest(absolutePath);
|
|
134
|
+
const loaded = loadManifest(absolutePath, localOptions);
|
|
124
135
|
return {
|
|
125
136
|
manifest: loaded.manifest,
|
|
126
137
|
sourceDir: dirname(absolutePath),
|
|
@@ -131,7 +142,7 @@ export function resolveToolFromPath(filePath: string): ToolResolution {
|
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
// Treat as directory
|
|
134
|
-
const result = tryLoadFromDir(absolutePath, "file");
|
|
145
|
+
const result = tryLoadFromDir(absolutePath, "file", localOptions);
|
|
135
146
|
if (result) {
|
|
136
147
|
return result;
|
|
137
148
|
}
|
|
@@ -298,7 +309,8 @@ export function resolveToolAuto(
|
|
|
298
309
|
|
|
299
310
|
// Check if the path exists as-is (could be a relative directory without ./)
|
|
300
311
|
if (existsSync(toolNameOrPath)) {
|
|
301
|
-
|
|
312
|
+
// Local tools can have simple names (no hierarchy required)
|
|
313
|
+
const result = tryLoadFromDir(resolve(toolNameOrPath), "file", { allowSimpleNames: true });
|
|
302
314
|
if (result) {
|
|
303
315
|
return result;
|
|
304
316
|
}
|
|
@@ -431,6 +431,237 @@ describe("Command Interpolation", () => {
|
|
|
431
431
|
});
|
|
432
432
|
});
|
|
433
433
|
|
|
434
|
+
describe("knownParameters filtering", () => {
|
|
435
|
+
describe("parseCommand with knownParameters", () => {
|
|
436
|
+
test("only treats known parameters as parameters", () => {
|
|
437
|
+
const result = parseCommand("echo ${name} and ${unknown}", {
|
|
438
|
+
knownParameters: new Set(["name"]),
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(result.parameters).toEqual(["name"]);
|
|
442
|
+
expect(result.tokens).toHaveLength(3);
|
|
443
|
+
expect(result.tokens[0]).toEqual({ type: "literal", value: "echo " });
|
|
444
|
+
expect(result.tokens[1]).toEqual({ type: "parameter", name: "name" });
|
|
445
|
+
expect(result.tokens[2]).toEqual({ type: "literal", value: " and ${unknown}" });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("preserves bash array syntax ${#array[@]}", () => {
|
|
449
|
+
const result = parseCommand("echo ${#compliments[@]}", {
|
|
450
|
+
knownParameters: new Set(["name"]),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// The entire string should be a literal since #compliments[@] is not a known param
|
|
454
|
+
expect(result.parameters).toEqual([]);
|
|
455
|
+
expect(result.tokens).toHaveLength(1);
|
|
456
|
+
expect(result.tokens[0]).toEqual({
|
|
457
|
+
type: "literal",
|
|
458
|
+
value: "echo ${#compliments[@]}",
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("preserves bash array indexing ${array[$i]}", () => {
|
|
463
|
+
const result = parseCommand('echo "${compliments[$random_index]}"', {
|
|
464
|
+
knownParameters: new Set(["name"]),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(result.parameters).toEqual([]);
|
|
468
|
+
expect(result.tokens).toHaveLength(1);
|
|
469
|
+
expect(result.tokens[0]).toEqual({
|
|
470
|
+
type: "literal",
|
|
471
|
+
value: 'echo "${compliments[$random_index]}"',
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("handles mix of known params and bash syntax", () => {
|
|
476
|
+
const cmd = 'echo "${name}" and ${#arr[@]} and ${arr[$i]} and ${OTHER_VAR}';
|
|
477
|
+
const result = parseCommand(cmd, {
|
|
478
|
+
knownParameters: new Set(["name"]),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
expect(result.parameters).toEqual(["name"]);
|
|
482
|
+
// Should have: literal, param, literal (containing all the bash stuff)
|
|
483
|
+
expect(result.tokens).toHaveLength(3);
|
|
484
|
+
expect(result.tokens[0]).toEqual({ type: "literal", value: "echo " });
|
|
485
|
+
expect(result.tokens[1]).toMatchObject({ type: "parameter", name: "name" });
|
|
486
|
+
expect(result.tokens[2]).toEqual({
|
|
487
|
+
type: "literal",
|
|
488
|
+
value: " and ${#arr[@]} and ${arr[$i]} and ${OTHER_VAR}",
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("legacy behavior when knownParameters not provided", () => {
|
|
493
|
+
const result = parseCommand("echo ${name} ${unknown}");
|
|
494
|
+
|
|
495
|
+
// Without knownParameters, all ${...} are treated as params
|
|
496
|
+
expect(result.parameters).toEqual(["name", "unknown"]);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("empty knownParameters set treats nothing as parameter", () => {
|
|
500
|
+
const result = parseCommand("echo ${name} ${other}", {
|
|
501
|
+
knownParameters: new Set(),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(result.parameters).toEqual([]);
|
|
505
|
+
expect(result.tokens).toHaveLength(1);
|
|
506
|
+
expect(result.tokens[0]).toEqual({
|
|
507
|
+
type: "literal",
|
|
508
|
+
value: "echo ${name} ${other}",
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe("interpolateCommand with knownParameters", () => {
|
|
514
|
+
test("only substitutes known parameters", () => {
|
|
515
|
+
const result = interpolateCommand(
|
|
516
|
+
"echo ${name} and ${MY_VAR}",
|
|
517
|
+
{ name: "Keith" },
|
|
518
|
+
{ knownParameters: new Set(["name"]) }
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
expect(result).toBe("echo Keith and ${MY_VAR}");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("preserves bash array operations", () => {
|
|
525
|
+
const cmd = 'arr=("a" "b"); echo ${#arr[@]} items: ${arr[0]}';
|
|
526
|
+
const result = interpolateCommand(
|
|
527
|
+
cmd,
|
|
528
|
+
{},
|
|
529
|
+
{
|
|
530
|
+
knownParameters: new Set(["name"]),
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// Nothing should be substituted
|
|
535
|
+
expect(result).toBe(cmd);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("substitutes only schema-defined params in complex command", () => {
|
|
539
|
+
const cmd = `
|
|
540
|
+
NAME="\${name}"
|
|
541
|
+
compliments=("Hello, $NAME!")
|
|
542
|
+
echo "\${compliments[0]}"
|
|
543
|
+
`;
|
|
544
|
+
const result = interpolateCommand(
|
|
545
|
+
cmd,
|
|
546
|
+
{ name: "Alice" },
|
|
547
|
+
{ knownParameters: new Set(["name"]) }
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
expect(result).toContain("Alice");
|
|
551
|
+
expect(result).toContain("${compliments[0]}");
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
describe("prepareCommand with knownParameters", () => {
|
|
556
|
+
test("passes through bash syntax while substituting known params", () => {
|
|
557
|
+
const result = prepareCommand(
|
|
558
|
+
"echo ${name} ${RANDOM}",
|
|
559
|
+
{ name: "test" },
|
|
560
|
+
{ knownParameters: new Set(["name"]) }
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// Contains ${RANDOM} which has $, so needs shell wrap
|
|
564
|
+
expect(result).toEqual(["sh", "-c", "echo test ${RANDOM}"]);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("handles full bash script with arrays", () => {
|
|
568
|
+
const cmd = `
|
|
569
|
+
arr=("\${name}" "b" "c")
|
|
570
|
+
echo \${#arr[@]}
|
|
571
|
+
echo \${arr[0]}
|
|
572
|
+
`;
|
|
573
|
+
const result = prepareCommand(
|
|
574
|
+
cmd,
|
|
575
|
+
{ name: "Keith" },
|
|
576
|
+
{
|
|
577
|
+
knownParameters: new Set(["name"]),
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
// Should be shell wrapped due to special chars
|
|
582
|
+
expect(result[0]).toBe("sh");
|
|
583
|
+
expect(result[1]).toBe("-c");
|
|
584
|
+
// The name should be substituted but array syntax preserved
|
|
585
|
+
expect(result[2]).toContain("Keith");
|
|
586
|
+
expect(result[2]).toContain("${#arr[@]}");
|
|
587
|
+
expect(result[2]).toContain("${arr[0]}");
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
describe("getMissingParams with knownParameters", () => {
|
|
592
|
+
test("only checks known parameters", () => {
|
|
593
|
+
const result = getMissingParams(
|
|
594
|
+
"echo ${name} ${unknown}",
|
|
595
|
+
{},
|
|
596
|
+
{ knownParameters: new Set(["name"]) }
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
// Only "name" is a known param, and it's missing
|
|
600
|
+
expect(result).toEqual(["name"]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("ignores unknown ${...} patterns", () => {
|
|
604
|
+
const result = getMissingParams(
|
|
605
|
+
"echo ${name} ${#arr[@]} ${arr[$i]}",
|
|
606
|
+
{ name: "Keith" },
|
|
607
|
+
{ knownParameters: new Set(["name"]) }
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
// name is provided, others are not params
|
|
611
|
+
expect(result).toEqual([]);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("real-world bash examples", () => {
|
|
616
|
+
test("compliment generator with bash arrays", () => {
|
|
617
|
+
const cmd = `
|
|
618
|
+
compliments=(
|
|
619
|
+
"You're great, \${name}!"
|
|
620
|
+
"Keep it up, \${name}!"
|
|
621
|
+
)
|
|
622
|
+
random_index=$((RANDOM % \${#compliments[@]}))
|
|
623
|
+
echo "\${compliments[$random_index]}"
|
|
624
|
+
`;
|
|
625
|
+
const result = interpolateCommand(
|
|
626
|
+
cmd,
|
|
627
|
+
{ name: "Keith" },
|
|
628
|
+
{ knownParameters: new Set(["name"]) }
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
// ${name} should be substituted
|
|
632
|
+
expect(result).toContain("You're great, Keith!");
|
|
633
|
+
expect(result).toContain("Keep it up, Keith!");
|
|
634
|
+
// Bash syntax should be preserved
|
|
635
|
+
expect(result).toContain("${#compliments[@]}");
|
|
636
|
+
expect(result).toContain("${compliments[$random_index]}");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("script with environment variables", () => {
|
|
640
|
+
const cmd = 'echo "Hello ${name}, your home is ${HOME}"';
|
|
641
|
+
const result = interpolateCommand(
|
|
642
|
+
cmd,
|
|
643
|
+
{ name: "Alice" },
|
|
644
|
+
{ knownParameters: new Set(["name"]) }
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
expect(result).toBe('echo "Hello Alice, your home is ${HOME}"');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("for loop with index variable", () => {
|
|
651
|
+
const cmd = 'for i in 1 2 3; do echo "${prefix}$i"; done';
|
|
652
|
+
const result = interpolateCommand(
|
|
653
|
+
cmd,
|
|
654
|
+
{ prefix: "item-" },
|
|
655
|
+
{ knownParameters: new Set(["prefix"]) }
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
// prefix substituted, $i preserved (though not in ${} form)
|
|
659
|
+
expect(result).toContain("item-");
|
|
660
|
+
expect(result).toContain("$i");
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
434
665
|
describe("getMissingParams", () => {
|
|
435
666
|
test("returns empty array when all params present", () => {
|
|
436
667
|
const result = getMissingParams("echo ${a} ${b}", { a: "1", b: "2" });
|
|
@@ -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);
|