@enactprotocol/cli 1.2.8 → 2.0.0

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.
Files changed (73) hide show
  1. package/README.md +88 -0
  2. package/package.json +34 -38
  3. package/src/commands/auth/index.ts +940 -0
  4. package/src/commands/cache/index.ts +361 -0
  5. package/src/commands/config/README.md +239 -0
  6. package/src/commands/config/index.ts +164 -0
  7. package/src/commands/env/README.md +197 -0
  8. package/src/commands/env/index.ts +392 -0
  9. package/src/commands/exec/README.md +110 -0
  10. package/src/commands/exec/index.ts +195 -0
  11. package/src/commands/get/index.ts +198 -0
  12. package/src/commands/index.ts +30 -0
  13. package/src/commands/inspect/index.ts +264 -0
  14. package/src/commands/install/README.md +146 -0
  15. package/src/commands/install/index.ts +682 -0
  16. package/src/commands/list/README.md +115 -0
  17. package/src/commands/list/index.ts +138 -0
  18. package/src/commands/publish/index.ts +350 -0
  19. package/src/commands/report/index.ts +366 -0
  20. package/src/commands/run/README.md +124 -0
  21. package/src/commands/run/index.ts +686 -0
  22. package/src/commands/search/index.ts +368 -0
  23. package/src/commands/setup/index.ts +274 -0
  24. package/src/commands/sign/index.ts +652 -0
  25. package/src/commands/trust/README.md +214 -0
  26. package/src/commands/trust/index.ts +453 -0
  27. package/src/commands/unyank/index.ts +107 -0
  28. package/src/commands/yank/index.ts +143 -0
  29. package/src/index.ts +96 -0
  30. package/src/types.ts +81 -0
  31. package/src/utils/errors.ts +409 -0
  32. package/src/utils/exit-codes.ts +159 -0
  33. package/src/utils/ignore.ts +147 -0
  34. package/src/utils/index.ts +107 -0
  35. package/src/utils/output.ts +242 -0
  36. package/src/utils/spinner.ts +214 -0
  37. package/tests/commands/auth.test.ts +217 -0
  38. package/tests/commands/cache.test.ts +286 -0
  39. package/tests/commands/config.test.ts +277 -0
  40. package/tests/commands/env.test.ts +293 -0
  41. package/tests/commands/exec.test.ts +112 -0
  42. package/tests/commands/get.test.ts +179 -0
  43. package/tests/commands/inspect.test.ts +201 -0
  44. package/tests/commands/install-integration.test.ts +343 -0
  45. package/tests/commands/install.test.ts +288 -0
  46. package/tests/commands/list.test.ts +160 -0
  47. package/tests/commands/publish.test.ts +186 -0
  48. package/tests/commands/report.test.ts +194 -0
  49. package/tests/commands/run.test.ts +231 -0
  50. package/tests/commands/search.test.ts +131 -0
  51. package/tests/commands/sign.test.ts +164 -0
  52. package/tests/commands/trust.test.ts +236 -0
  53. package/tests/commands/unyank.test.ts +114 -0
  54. package/tests/commands/yank.test.ts +154 -0
  55. package/tests/e2e.test.ts +554 -0
  56. package/tests/fixtures/calculator/enact.yaml +34 -0
  57. package/tests/fixtures/echo-tool/enact.md +31 -0
  58. package/tests/fixtures/env-tool/enact.yaml +19 -0
  59. package/tests/fixtures/greeter/enact.yaml +18 -0
  60. package/tests/fixtures/invalid-tool/enact.yaml +4 -0
  61. package/tests/index.test.ts +8 -0
  62. package/tests/types.test.ts +84 -0
  63. package/tests/utils/errors.test.ts +303 -0
  64. package/tests/utils/exit-codes.test.ts +189 -0
  65. package/tests/utils/ignore.test.ts +461 -0
  66. package/tests/utils/output.test.ts +126 -0
  67. package/tsconfig.json +17 -0
  68. package/tsconfig.tsbuildinfo +1 -0
  69. package/dist/index.js +0 -231410
  70. package/dist/index.js.bak +0 -231409
  71. package/dist/web/static/app.js +0 -663
  72. package/dist/web/static/index.html +0 -117
  73. package/dist/web/static/style.css +0 -291
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Integration tests for install command
3
+ *
4
+ * Tests actual installation flows using local tools.
5
+ * These tests create real files and directories to verify the full installation process.
6
+ */
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ // Test fixtures
13
+ const TEST_BASE = join(import.meta.dir, "..", "fixtures", "install-integration");
14
+ const TEST_PROJECT = join(TEST_BASE, "test-project");
15
+ const TEST_TOOL_SRC = join(TEST_BASE, "source-tool");
16
+ const TEST_GLOBAL_HOME = join(TEST_BASE, "fake-home");
17
+
18
+ // Create a sample tool manifest for testing
19
+ const SAMPLE_MANIFEST = `
20
+ name: test/sample-tool
21
+ version: 1.0.0
22
+ description: A sample tool for testing installation
23
+ command: echo "Hello from sample-tool!"
24
+ tags:
25
+ - test
26
+ - sample
27
+ `;
28
+
29
+ const SAMPLE_MANIFEST_V2 = `
30
+ name: test/sample-tool
31
+ version: 2.0.0
32
+ description: A sample tool for testing installation (v2)
33
+ command: echo "Hello from sample-tool v2!"
34
+ tags:
35
+ - test
36
+ - sample
37
+ `;
38
+
39
+ describe("install integration", () => {
40
+ beforeAll(() => {
41
+ // Create test directories
42
+ mkdirSync(TEST_BASE, { recursive: true });
43
+ mkdirSync(TEST_PROJECT, { recursive: true });
44
+ mkdirSync(join(TEST_PROJECT, ".enact"), { recursive: true });
45
+ mkdirSync(TEST_TOOL_SRC, { recursive: true });
46
+ mkdirSync(join(TEST_GLOBAL_HOME, ".enact", "cache"), { recursive: true });
47
+
48
+ // Create sample tool source
49
+ writeFileSync(join(TEST_TOOL_SRC, "enact.yaml"), SAMPLE_MANIFEST);
50
+ });
51
+
52
+ beforeEach(() => {
53
+ // Clean project tools before each test
54
+ const projectToolsDir = join(TEST_PROJECT, ".enact", "tools");
55
+ if (existsSync(projectToolsDir)) {
56
+ rmSync(projectToolsDir, { recursive: true, force: true });
57
+ }
58
+
59
+ // Clean fake global tools.json
60
+ const globalToolsJson = join(TEST_GLOBAL_HOME, ".enact", "tools.json");
61
+ if (existsSync(globalToolsJson)) {
62
+ rmSync(globalToolsJson);
63
+ }
64
+ });
65
+
66
+ afterAll(() => {
67
+ // Clean up test directories
68
+ if (existsSync(TEST_BASE)) {
69
+ rmSync(TEST_BASE, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ describe("local path installation", () => {
74
+ test("installs tool from local path to project .enact/tools/", async () => {
75
+ // This test simulates what happens when you run `enact install ./source-tool`
76
+
77
+ // Import the shared functions we need
78
+ const { loadManifestFromDir, toolNameToPath } = await import("@enactprotocol/shared");
79
+
80
+ // Verify source tool exists
81
+ const loaded = loadManifestFromDir(TEST_TOOL_SRC);
82
+ expect(loaded).not.toBeNull();
83
+ expect(loaded?.manifest.name).toBe("test/sample-tool");
84
+
85
+ // Simulate the copy operation the install command performs
86
+ const destPath = join(TEST_PROJECT, ".enact", "tools", toolNameToPath("test/sample-tool"));
87
+ mkdirSync(destPath, { recursive: true });
88
+
89
+ // Copy the manifest
90
+ const { cpSync } = await import("node:fs");
91
+ cpSync(TEST_TOOL_SRC, destPath, { recursive: true });
92
+
93
+ // Verify installation
94
+ expect(existsSync(destPath)).toBe(true);
95
+ expect(existsSync(join(destPath, "enact.yaml"))).toBe(true);
96
+
97
+ // Verify manifest can be loaded from destination
98
+ const installedManifest = loadManifestFromDir(destPath);
99
+ expect(installedManifest).not.toBeNull();
100
+ expect(installedManifest?.manifest.name).toBe("test/sample-tool");
101
+ expect(installedManifest?.manifest.version).toBe("1.0.0");
102
+ });
103
+
104
+ test("overwrites existing project tool when force is used", async () => {
105
+ const { loadManifestFromDir, toolNameToPath } = await import("@enactprotocol/shared");
106
+ const { cpSync } = await import("node:fs");
107
+
108
+ const destPath = join(TEST_PROJECT, ".enact", "tools", toolNameToPath("test/sample-tool"));
109
+
110
+ // First installation
111
+ mkdirSync(destPath, { recursive: true });
112
+ cpSync(TEST_TOOL_SRC, destPath, { recursive: true });
113
+
114
+ // Verify v1 is installed
115
+ let installed = loadManifestFromDir(destPath);
116
+ expect(installed?.manifest.version).toBe("1.0.0");
117
+
118
+ // Create v2 source
119
+ const v2Source = join(TEST_BASE, "source-tool-v2");
120
+ mkdirSync(v2Source, { recursive: true });
121
+ writeFileSync(join(v2Source, "enact.yaml"), SAMPLE_MANIFEST_V2);
122
+
123
+ // Simulate force overwrite
124
+ rmSync(destPath, { recursive: true, force: true });
125
+ mkdirSync(destPath, { recursive: true });
126
+ cpSync(v2Source, destPath, { recursive: true });
127
+
128
+ // Verify v2 is now installed
129
+ installed = loadManifestFromDir(destPath);
130
+ expect(installed?.manifest.version).toBe("2.0.0");
131
+ });
132
+ });
133
+
134
+ describe("global installation via tools.json", () => {
135
+ test("global install updates tools.json registry", async () => {
136
+ const { addToolToRegistry, getInstalledVersion, isToolInstalled, loadToolsRegistry } =
137
+ await import("@enactprotocol/shared");
138
+
139
+ // Simulate global installation by adding to registry
140
+ // Note: In real usage, this writes to ~/.enact/tools.json
141
+ // For testing, we verify the registry functions work correctly
142
+
143
+ // Add tool to registry (simulating what install command does)
144
+ addToolToRegistry("test/sample-tool", "1.0.0", "project", TEST_PROJECT);
145
+
146
+ // Verify it's registered
147
+ expect(isToolInstalled("test/sample-tool", "project", TEST_PROJECT)).toBe(true);
148
+ expect(getInstalledVersion("test/sample-tool", "project", TEST_PROJECT)).toBe("1.0.0");
149
+
150
+ // Load and verify registry directly
151
+ const registry = loadToolsRegistry("project", TEST_PROJECT);
152
+ expect(registry.tools["test/sample-tool"]).toBe("1.0.0");
153
+ });
154
+
155
+ test("global install updates version when reinstalling", async () => {
156
+ const { addToolToRegistry, getInstalledVersion } = await import("@enactprotocol/shared");
157
+
158
+ // Install v1
159
+ addToolToRegistry("test/sample-tool", "1.0.0", "project", TEST_PROJECT);
160
+ expect(getInstalledVersion("test/sample-tool", "project", TEST_PROJECT)).toBe("1.0.0");
161
+
162
+ // "Upgrade" to v2
163
+ addToolToRegistry("test/sample-tool", "2.0.0", "project", TEST_PROJECT);
164
+ expect(getInstalledVersion("test/sample-tool", "project", TEST_PROJECT)).toBe("2.0.0");
165
+ });
166
+
167
+ test("global install extracts to cache path", async () => {
168
+ const { getToolCachePath } = await import("@enactprotocol/shared");
169
+
170
+ // Verify cache path structure
171
+ const cachePath = getToolCachePath("test/sample-tool", "1.0.0");
172
+ expect(cachePath).toContain(".enact");
173
+ expect(cachePath).toContain("cache");
174
+ expect(cachePath).toContain("test/sample-tool");
175
+ expect(cachePath).toContain("v1.0.0");
176
+ });
177
+
178
+ test("listInstalledTools returns installed tools from registry", async () => {
179
+ const { addToolToRegistry, listInstalledTools, saveToolsRegistry } = await import(
180
+ "@enactprotocol/shared"
181
+ );
182
+
183
+ // Clear any existing tools first
184
+ saveToolsRegistry({ tools: {} }, "project", TEST_PROJECT);
185
+
186
+ // Add multiple tools
187
+ addToolToRegistry("alice/tool-a", "1.0.0", "project", TEST_PROJECT);
188
+ addToolToRegistry("bob/tool-b", "2.0.0", "project", TEST_PROJECT);
189
+ addToolToRegistry("charlie/tool-c", "3.0.0", "project", TEST_PROJECT);
190
+
191
+ // List all installed
192
+ const tools = listInstalledTools("project", TEST_PROJECT);
193
+
194
+ expect(tools.length).toBe(3);
195
+ expect(tools.find((t) => t.name === "alice/tool-a")).toBeTruthy();
196
+ expect(tools.find((t) => t.name === "bob/tool-b")).toBeTruthy();
197
+ expect(tools.find((t) => t.name === "charlie/tool-c")).toBeTruthy();
198
+
199
+ // Verify versions
200
+ expect(tools.find((t) => t.name === "alice/tool-a")?.version).toBe("1.0.0");
201
+ expect(tools.find((t) => t.name === "bob/tool-b")?.version).toBe("2.0.0");
202
+ });
203
+ });
204
+
205
+ describe("registry integration scenarios", () => {
206
+ test("removeToolFromRegistry removes tool from tools.json", async () => {
207
+ const { addToolToRegistry, isToolInstalled, removeToolFromRegistry } = await import(
208
+ "@enactprotocol/shared"
209
+ );
210
+
211
+ // Add then remove
212
+ addToolToRegistry("test/to-remove", "1.0.0", "project", TEST_PROJECT);
213
+ expect(isToolInstalled("test/to-remove", "project", TEST_PROJECT)).toBe(true);
214
+
215
+ const removed = removeToolFromRegistry("test/to-remove", "project", TEST_PROJECT);
216
+ expect(removed).toBe(true);
217
+ expect(isToolInstalled("test/to-remove", "project", TEST_PROJECT)).toBe(false);
218
+ });
219
+
220
+ test("tools.json file format is correct", async () => {
221
+ const { addToolToRegistry, getToolsJsonPath } = await import("@enactprotocol/shared");
222
+
223
+ addToolToRegistry("org/namespace/tool", "1.2.3", "project", TEST_PROJECT);
224
+
225
+ const jsonPath = getToolsJsonPath("project", TEST_PROJECT);
226
+ expect(jsonPath).not.toBeNull();
227
+
228
+ const content = readFileSync(jsonPath!, "utf-8");
229
+ const parsed = JSON.parse(content);
230
+
231
+ // Verify structure
232
+ expect(parsed).toHaveProperty("tools");
233
+ expect(typeof parsed.tools).toBe("object");
234
+ expect(parsed.tools["org/namespace/tool"]).toBe("1.2.3");
235
+ });
236
+ });
237
+
238
+ describe("tool resolution", () => {
239
+ test("resolver finds tools from registry", async () => {
240
+ // Setup: Create a cached tool with manifest
241
+ const cachePath = join(TEST_GLOBAL_HOME, ".enact", "cache", "test", "cached-tool", "v1.0.0");
242
+ mkdirSync(cachePath, { recursive: true });
243
+ writeFileSync(
244
+ join(cachePath, "enact.yaml"),
245
+ `
246
+ name: test/cached-tool
247
+ version: 1.0.0
248
+ description: A cached tool
249
+ command: echo "cached"
250
+ `
251
+ );
252
+
253
+ // The resolver should be able to find this tool once it's registered
254
+ // Note: Full resolver testing is in resolver.test.ts
255
+ expect(existsSync(join(cachePath, "enact.yaml"))).toBe(true);
256
+ });
257
+ });
258
+ });
259
+
260
+ describe("tools.json edge cases", () => {
261
+ const EDGE_TEST_DIR = join(TEST_BASE, "edge-cases");
262
+
263
+ beforeAll(() => {
264
+ mkdirSync(join(EDGE_TEST_DIR, ".enact"), { recursive: true });
265
+ });
266
+
267
+ afterAll(() => {
268
+ if (existsSync(EDGE_TEST_DIR)) {
269
+ rmSync(EDGE_TEST_DIR, { recursive: true, force: true });
270
+ }
271
+ });
272
+
273
+ beforeEach(() => {
274
+ // Ensure .enact directory exists
275
+ mkdirSync(join(EDGE_TEST_DIR, ".enact"), { recursive: true });
276
+
277
+ const jsonPath = join(EDGE_TEST_DIR, ".enact", "tools.json");
278
+ if (existsSync(jsonPath)) {
279
+ rmSync(jsonPath);
280
+ }
281
+ });
282
+
283
+ test("handles tool names with special characters", async () => {
284
+ const { addToolToRegistry, getInstalledVersion } = await import("@enactprotocol/shared");
285
+
286
+ // Various tool name formats
287
+ addToolToRegistry("org/ns/my-tool", "1.0.0", "project", EDGE_TEST_DIR);
288
+ addToolToRegistry("org/ns/my_tool", "2.0.0", "project", EDGE_TEST_DIR);
289
+ addToolToRegistry("org123/ns456/tool789", "3.0.0", "project", EDGE_TEST_DIR);
290
+
291
+ expect(getInstalledVersion("org/ns/my-tool", "project", EDGE_TEST_DIR)).toBe("1.0.0");
292
+ expect(getInstalledVersion("org/ns/my_tool", "project", EDGE_TEST_DIR)).toBe("2.0.0");
293
+ expect(getInstalledVersion("org123/ns456/tool789", "project", EDGE_TEST_DIR)).toBe("3.0.0");
294
+ });
295
+
296
+ test("handles version formats correctly", async () => {
297
+ const { addToolToRegistry, getInstalledVersion } = await import("@enactprotocol/shared");
298
+
299
+ // Various version formats
300
+ addToolToRegistry("test/semver", "1.2.3", "project", EDGE_TEST_DIR);
301
+ addToolToRegistry("test/prerelease", "2.0.0-beta.1", "project", EDGE_TEST_DIR);
302
+ addToolToRegistry("test/build", "3.0.0+build.123", "project", EDGE_TEST_DIR);
303
+
304
+ expect(getInstalledVersion("test/semver", "project", EDGE_TEST_DIR)).toBe("1.2.3");
305
+ expect(getInstalledVersion("test/prerelease", "project", EDGE_TEST_DIR)).toBe("2.0.0-beta.1");
306
+ expect(getInstalledVersion("test/build", "project", EDGE_TEST_DIR)).toBe("3.0.0+build.123");
307
+ });
308
+
309
+ test("handles concurrent writes gracefully", async () => {
310
+ const { addToolToRegistry, loadToolsRegistry } = await import("@enactprotocol/shared");
311
+
312
+ // Simulate rapid concurrent writes
313
+ const promises = [];
314
+ for (let i = 0; i < 10; i++) {
315
+ promises.push(
316
+ Promise.resolve().then(() => {
317
+ addToolToRegistry(`test/concurrent-${i}`, `${i}.0.0`, "project", EDGE_TEST_DIR);
318
+ })
319
+ );
320
+ }
321
+
322
+ await Promise.all(promises);
323
+
324
+ // All tools should be registered (though order may vary)
325
+ const registry = loadToolsRegistry("project", EDGE_TEST_DIR);
326
+ const keys = Object.keys(registry.tools);
327
+
328
+ // Due to race conditions, we may not have all 10
329
+ // but we should have at least some and they should be valid
330
+ expect(keys.length).toBeGreaterThan(0);
331
+ for (const key of keys) {
332
+ expect(key).toMatch(/^test\/concurrent-\d$/);
333
+ }
334
+ });
335
+
336
+ test("empty registry returns empty tools object", async () => {
337
+ const { loadToolsRegistry } = await import("@enactprotocol/shared");
338
+
339
+ // No tools.json exists
340
+ const registry = loadToolsRegistry("project", EDGE_TEST_DIR);
341
+ expect(registry.tools).toEqual({});
342
+ });
343
+ });
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Tests for the install command
3
+ */
4
+
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { Command } from "commander";
9
+ import { configureInstallCommand } from "../../src/commands/install";
10
+
11
+ // Test fixtures directory
12
+ const FIXTURES_DIR = join(import.meta.dir, "..", "fixtures", "install-cmd");
13
+
14
+ describe("install command", () => {
15
+ beforeAll(() => {
16
+ mkdirSync(FIXTURES_DIR, { recursive: true });
17
+ });
18
+
19
+ beforeEach(() => {
20
+ // Clean fixtures for each test
21
+ if (existsSync(FIXTURES_DIR)) {
22
+ rmSync(FIXTURES_DIR, { recursive: true, force: true });
23
+ mkdirSync(FIXTURES_DIR, { recursive: true });
24
+ }
25
+ });
26
+
27
+ afterAll(() => {
28
+ if (existsSync(FIXTURES_DIR)) {
29
+ rmSync(FIXTURES_DIR, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ describe("command configuration", () => {
34
+ test("configures install command on program", () => {
35
+ const program = new Command();
36
+ configureInstallCommand(program);
37
+
38
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
39
+ expect(installCmd).toBeDefined();
40
+ });
41
+
42
+ test("has correct description", () => {
43
+ const program = new Command();
44
+ configureInstallCommand(program);
45
+
46
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
47
+ expect(installCmd?.description()).toBe("Install a tool to the project or globally");
48
+ });
49
+
50
+ test("has 'i' as alias", () => {
51
+ const program = new Command();
52
+ configureInstallCommand(program);
53
+
54
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
55
+ const aliases = installCmd?.aliases() ?? [];
56
+ expect(aliases).toContain("i");
57
+ });
58
+
59
+ test("tool argument is optional", () => {
60
+ const program = new Command();
61
+ configureInstallCommand(program);
62
+
63
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
64
+ const args = installCmd?.registeredArguments ?? [];
65
+ // Optional argument is wrapped in brackets in Commander
66
+ expect(args.length).toBe(1);
67
+ expect(args[0]?.name()).toBe("tool");
68
+ expect(args[0]?.required).toBe(false);
69
+ });
70
+
71
+ test("has --global option", () => {
72
+ const program = new Command();
73
+ configureInstallCommand(program);
74
+
75
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
76
+ const opts = installCmd?.options ?? [];
77
+ const globalOpt = opts.find((o) => o.long === "--global");
78
+ expect(globalOpt).toBeDefined();
79
+ });
80
+
81
+ test("has -g short option for global", () => {
82
+ const program = new Command();
83
+ configureInstallCommand(program);
84
+
85
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
86
+ const opts = installCmd?.options ?? [];
87
+ const globalOpt = opts.find((o) => o.short === "-g");
88
+ expect(globalOpt).toBeDefined();
89
+ });
90
+
91
+ test("has --force option", () => {
92
+ const program = new Command();
93
+ configureInstallCommand(program);
94
+
95
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
96
+ const opts = installCmd?.options ?? [];
97
+ const forceOpt = opts.find((o) => o.long === "--force");
98
+ expect(forceOpt).toBeDefined();
99
+ });
100
+
101
+ test("has --verbose option", () => {
102
+ const program = new Command();
103
+ configureInstallCommand(program);
104
+
105
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
106
+ const opts = installCmd?.options ?? [];
107
+ const verboseOpt = opts.find((o) => o.long === "--verbose");
108
+ expect(verboseOpt).toBeDefined();
109
+ });
110
+
111
+ test("has --json option", () => {
112
+ const program = new Command();
113
+ configureInstallCommand(program);
114
+
115
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
116
+ const opts = installCmd?.options ?? [];
117
+ const jsonOpt = opts.find((o) => o.long === "--json");
118
+ expect(jsonOpt).toBeDefined();
119
+ });
120
+
121
+ test("has --verify option", () => {
122
+ const program = new Command();
123
+ configureInstallCommand(program);
124
+
125
+ const installCmd = program.commands.find((cmd) => cmd.name() === "install");
126
+ const opts = installCmd?.options ?? [];
127
+ const verifyOpt = opts.find((o) => o.long === "--verify");
128
+ // --verify is no longer optional, verification is always required
129
+ expect(verifyOpt).toBeUndefined();
130
+ });
131
+ });
132
+
133
+ describe("path detection", () => {
134
+ // Helper function that mirrors the command's path detection logic
135
+ const isToolPath = (tool: string): boolean => {
136
+ return tool === "." || tool.startsWith("./") || tool.startsWith("/") || tool.startsWith("..");
137
+ };
138
+
139
+ test("recognizes current directory (.)", () => {
140
+ expect(isToolPath(".")).toBe(true);
141
+ });
142
+
143
+ test("recognizes relative path (./)", () => {
144
+ expect(isToolPath("./my-tool")).toBe(true);
145
+ });
146
+
147
+ test("recognizes absolute path (/)", () => {
148
+ expect(isToolPath("/Users/test/my-tool")).toBe(true);
149
+ });
150
+
151
+ test("recognizes parent relative path (..)", () => {
152
+ expect(isToolPath("../my-tool")).toBe(true);
153
+ });
154
+
155
+ test("does not recognize tool name as path", () => {
156
+ expect(isToolPath("alice/utils/greeter")).toBe(false);
157
+ });
158
+
159
+ test("does not recognize simple name as path", () => {
160
+ expect(isToolPath("my-tool")).toBe(false);
161
+ });
162
+ });
163
+
164
+ describe("registry tool detection", () => {
165
+ // Helper function that mirrors the command's registry tool detection
166
+ const isRegistryTool = (toolName: string): boolean => {
167
+ const parts = toolName.split("/");
168
+ return parts.length >= 3 && !toolName.startsWith(".") && !toolName.startsWith("/");
169
+ };
170
+
171
+ test("recognizes registry tool format (owner/namespace/tool)", () => {
172
+ expect(isRegistryTool("alice/utils/greeter")).toBe(true);
173
+ });
174
+
175
+ test("recognizes registry tool with more parts", () => {
176
+ expect(isRegistryTool("acme/internal/tools/formatter")).toBe(true);
177
+ });
178
+
179
+ test("rejects simple tool name", () => {
180
+ expect(isRegistryTool("greeter")).toBe(false);
181
+ });
182
+
183
+ test("rejects owner/tool format (needs namespace)", () => {
184
+ expect(isRegistryTool("alice/greeter")).toBe(false);
185
+ });
186
+
187
+ test("rejects path-like patterns", () => {
188
+ expect(isRegistryTool("./alice/utils/greeter")).toBe(false);
189
+ expect(isRegistryTool("/alice/utils/greeter")).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("tool spec parsing", () => {
194
+ // Helper function that mirrors the command's parseToolSpec
195
+ const parseToolSpec = (spec: string): { name: string; version: string | undefined } => {
196
+ const match = spec.match(/^(@[^@/]+\/[^@]+|[^@]+)(?:@(.+))?$/);
197
+ if (match?.[1]) {
198
+ return { name: match[1], version: match[2] };
199
+ }
200
+ return { name: spec, version: undefined };
201
+ };
202
+
203
+ test("parses tool name without version", () => {
204
+ const result = parseToolSpec("alice/utils/greeter");
205
+ expect(result.name).toBe("alice/utils/greeter");
206
+ expect(result.version).toBeUndefined();
207
+ });
208
+
209
+ test("parses tool name with version", () => {
210
+ const result = parseToolSpec("alice/utils/greeter@1.0.0");
211
+ expect(result.name).toBe("alice/utils/greeter");
212
+ expect(result.version).toBe("1.0.0");
213
+ });
214
+
215
+ test("parses tool name with semver prerelease", () => {
216
+ const result = parseToolSpec("alice/utils/greeter@2.0.0-beta.1");
217
+ expect(result.name).toBe("alice/utils/greeter");
218
+ expect(result.version).toBe("2.0.0-beta.1");
219
+ });
220
+
221
+ test("parses scoped package without version", () => {
222
+ const result = parseToolSpec("@scope/package");
223
+ expect(result.name).toBe("@scope/package");
224
+ expect(result.version).toBeUndefined();
225
+ });
226
+
227
+ test("parses scoped package with version", () => {
228
+ const result = parseToolSpec("@scope/package@3.0.0");
229
+ expect(result.name).toBe("@scope/package");
230
+ expect(result.version).toBe("3.0.0");
231
+ });
232
+
233
+ test("handles latest tag as version", () => {
234
+ const result = parseToolSpec("alice/utils/greeter@latest");
235
+ expect(result.name).toBe("alice/utils/greeter");
236
+ expect(result.version).toBe("latest");
237
+ });
238
+ });
239
+
240
+ describe("installation scenarios", () => {
241
+ test("project install should use .enact/tools/", () => {
242
+ const cwd = "/project";
243
+ const toolName = "alice/utils/greeter";
244
+ const expectedPath = join(cwd, ".enact", "tools", toolName.replace(/\//g, "/"));
245
+ expect(expectedPath).toContain(".enact/tools");
246
+ });
247
+
248
+ test("global install should use ~/.enact/tools/", () => {
249
+ const home = process.env.HOME ?? "/home/user";
250
+ const toolName = "alice/utils/greeter";
251
+ const expectedPath = join(home, ".enact", "tools", toolName.replace(/\//g, "/"));
252
+ expect(expectedPath).toContain(".enact/tools");
253
+ });
254
+ });
255
+
256
+ describe("bytes formatting", () => {
257
+ // Helper function that mirrors the command's formatBytes
258
+ const formatBytes = (bytes: number): string => {
259
+ if (bytes === 0) return "0 B";
260
+ const k = 1024;
261
+ const sizes = ["B", "KB", "MB", "GB"];
262
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
263
+ return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
264
+ };
265
+
266
+ test("formats zero bytes", () => {
267
+ expect(formatBytes(0)).toBe("0 B");
268
+ });
269
+
270
+ test("formats bytes", () => {
271
+ expect(formatBytes(500)).toBe("500.0 B");
272
+ });
273
+
274
+ test("formats kilobytes", () => {
275
+ expect(formatBytes(1024)).toBe("1.0 KB");
276
+ expect(formatBytes(2048)).toBe("2.0 KB");
277
+ });
278
+
279
+ test("formats megabytes", () => {
280
+ expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
281
+ expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
282
+ });
283
+
284
+ test("formats gigabytes", () => {
285
+ expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
286
+ });
287
+ });
288
+ });