@enactprotocol/shared 2.2.2 → 2.3.1

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 (111) hide show
  1. package/README.md +1 -18
  2. package/dist/config.d.ts +12 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +32 -6
  5. package/dist/config.js.map +1 -1
  6. package/dist/execution/action-command.d.ts +131 -0
  7. package/dist/execution/action-command.d.ts.map +1 -0
  8. package/dist/execution/action-command.js +300 -0
  9. package/dist/execution/action-command.js.map +1 -0
  10. package/dist/execution/command.d.ts +8 -8
  11. package/dist/execution/command.js +6 -6
  12. package/dist/execution/index.d.ts +1 -0
  13. package/dist/execution/index.d.ts.map +1 -1
  14. package/dist/execution/index.js +2 -0
  15. package/dist/execution/index.js.map +1 -1
  16. package/dist/execution/types.d.ts +5 -2
  17. package/dist/execution/types.d.ts.map +1 -1
  18. package/dist/execution/types.js.map +1 -1
  19. package/dist/index.d.ts +9 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +14 -5
  22. package/dist/index.js.map +1 -1
  23. package/dist/manifest/actions-loader.d.ts +29 -0
  24. package/dist/manifest/actions-loader.d.ts.map +1 -0
  25. package/dist/manifest/actions-loader.js +34 -0
  26. package/dist/manifest/actions-loader.js.map +1 -0
  27. package/dist/manifest/actions-parser.d.ts +69 -0
  28. package/dist/manifest/actions-parser.d.ts.map +1 -0
  29. package/dist/manifest/actions-parser.js +265 -0
  30. package/dist/manifest/actions-parser.js.map +1 -0
  31. package/dist/manifest/index.d.ts +2 -0
  32. package/dist/manifest/index.d.ts.map +1 -1
  33. package/dist/manifest/index.js +4 -0
  34. package/dist/manifest/index.js.map +1 -1
  35. package/dist/manifest/loader.d.ts +7 -2
  36. package/dist/manifest/loader.d.ts.map +1 -1
  37. package/dist/manifest/loader.js +71 -4
  38. package/dist/manifest/loader.js.map +1 -1
  39. package/dist/manifest/parser.d.ts +1 -0
  40. package/dist/manifest/parser.d.ts.map +1 -1
  41. package/dist/manifest/parser.js +1 -0
  42. package/dist/manifest/parser.js.map +1 -1
  43. package/dist/manifest/scripts.d.ts +19 -0
  44. package/dist/manifest/scripts.d.ts.map +1 -0
  45. package/dist/manifest/scripts.js +102 -0
  46. package/dist/manifest/scripts.js.map +1 -0
  47. package/dist/manifest/validator.d.ts +1 -8
  48. package/dist/manifest/validator.d.ts.map +1 -1
  49. package/dist/manifest/validator.js +14 -13
  50. package/dist/manifest/validator.js.map +1 -1
  51. package/dist/mcp-registry.js +5 -5
  52. package/dist/mcp-registry.js.map +1 -1
  53. package/dist/paths.d.ts +9 -2
  54. package/dist/paths.d.ts.map +1 -1
  55. package/dist/paths.js +12 -3
  56. package/dist/paths.js.map +1 -1
  57. package/dist/registry.d.ts +47 -2
  58. package/dist/registry.d.ts.map +1 -1
  59. package/dist/registry.js +100 -7
  60. package/dist/registry.js.map +1 -1
  61. package/dist/resolver.d.ts +55 -4
  62. package/dist/resolver.d.ts.map +1 -1
  63. package/dist/resolver.js +144 -77
  64. package/dist/resolver.js.map +1 -1
  65. package/dist/types/actions.d.ts +194 -0
  66. package/dist/types/actions.d.ts.map +1 -0
  67. package/dist/types/actions.js +32 -0
  68. package/dist/types/actions.js.map +1 -0
  69. package/dist/types/index.d.ts +3 -1
  70. package/dist/types/index.d.ts.map +1 -1
  71. package/dist/types/index.js +1 -0
  72. package/dist/types/index.js.map +1 -1
  73. package/dist/types/manifest.d.ts +50 -5
  74. package/dist/types/manifest.d.ts.map +1 -1
  75. package/dist/types/manifest.js +10 -2
  76. package/dist/types/manifest.js.map +1 -1
  77. package/package.json +2 -2
  78. package/src/config.ts +48 -6
  79. package/src/execution/action-command.ts +417 -0
  80. package/src/execution/command.ts +8 -8
  81. package/src/execution/index.ts +17 -0
  82. package/src/execution/types.ts +13 -2
  83. package/src/index.ts +43 -0
  84. package/src/manifest/actions-loader.ts +49 -0
  85. package/src/manifest/index.ts +12 -0
  86. package/src/manifest/loader.ts +77 -4
  87. package/src/manifest/parser.ts +1 -0
  88. package/src/manifest/scripts.ts +116 -0
  89. package/src/manifest/validator.ts +15 -14
  90. package/src/mcp-registry.ts +5 -5
  91. package/src/paths.ts +13 -3
  92. package/src/registry.ts +136 -7
  93. package/src/resolver.ts +185 -79
  94. package/src/types/actions.ts +223 -0
  95. package/src/types/index.ts +11 -0
  96. package/src/types/manifest.ts +67 -6
  97. package/tests/action-command.test.ts +249 -0
  98. package/tests/config-normalization.test.ts +279 -0
  99. package/tests/config.test.ts +4 -1
  100. package/tests/effective-input-schema.test.ts +86 -0
  101. package/tests/fixtures/valid-tool.md +5 -12
  102. package/tests/fixtures/valid-tool.yaml +3 -10
  103. package/tests/hooks.test.ts +177 -0
  104. package/tests/manifest/loader.test.ts +34 -20
  105. package/tests/manifest/parser.test.ts +11 -15
  106. package/tests/manifest/validator.test.ts +7 -17
  107. package/tests/manifest-types.test.ts +9 -11
  108. package/tests/paths.test.ts +11 -4
  109. package/tests/registry.test.ts +204 -8
  110. package/tests/resolver.test.ts +90 -6
  111. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tests for config normalization — alias fields added in the README update.
3
+ *
4
+ * TrustConfig aliases: require_signatures → policy, trusted_publishers → auditors
5
+ * ExecutionConfig routing: default, fallback, trusted_scopes
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import yaml from "js-yaml";
13
+ import {
14
+ DEFAULT_CONFIG,
15
+ getTrustPolicy,
16
+ getTrustedIdentities,
17
+ loadConfig,
18
+ saveConfig,
19
+ } from "../src/config";
20
+
21
+ describe("config normalization", () => {
22
+ describe("TrustConfig aliases", () => {
23
+ test("require_signatures: true sets policy to require_attestation", () => {
24
+ const configPath = join(homedir(), ".enact", "config.yaml");
25
+ mkdirSync(join(homedir(), ".enact"), { recursive: true });
26
+ writeFileSync(
27
+ configPath,
28
+ yaml.dump({
29
+ trust: {
30
+ require_signatures: true,
31
+ },
32
+ }),
33
+ "utf-8"
34
+ );
35
+
36
+ const config = loadConfig();
37
+ expect(config.trust?.policy).toBe("require_attestation");
38
+ });
39
+
40
+ test("require_signatures: false sets policy to allow", () => {
41
+ const configPath = join(homedir(), ".enact", "config.yaml");
42
+ writeFileSync(
43
+ configPath,
44
+ yaml.dump({
45
+ trust: {
46
+ require_signatures: false,
47
+ },
48
+ }),
49
+ "utf-8"
50
+ );
51
+
52
+ const config = loadConfig();
53
+ expect(config.trust?.policy).toBe("allow");
54
+ });
55
+
56
+ test("explicit policy takes precedence over require_signatures", () => {
57
+ const configPath = join(homedir(), ".enact", "config.yaml");
58
+ writeFileSync(
59
+ configPath,
60
+ yaml.dump({
61
+ trust: {
62
+ policy: "prompt",
63
+ require_signatures: true,
64
+ },
65
+ }),
66
+ "utf-8"
67
+ );
68
+
69
+ const config = loadConfig();
70
+ // When policy is explicitly set, require_signatures should not override it
71
+ expect(config.trust?.policy).toBe("prompt");
72
+ });
73
+
74
+ test("trusted_publishers merges into auditors", () => {
75
+ const configPath = join(homedir(), ".enact", "config.yaml");
76
+ writeFileSync(
77
+ configPath,
78
+ yaml.dump({
79
+ trust: {
80
+ auditors: ["github:existing-user"],
81
+ trusted_publishers: ["@my-org"],
82
+ },
83
+ }),
84
+ "utf-8"
85
+ );
86
+
87
+ const config = loadConfig();
88
+ expect(config.trust?.auditors).toContain("github:existing-user");
89
+ expect(config.trust?.auditors).toContain("@my-org");
90
+ });
91
+
92
+ test("trusted_publishers does not create duplicates", () => {
93
+ const configPath = join(homedir(), ".enact", "config.yaml");
94
+ writeFileSync(
95
+ configPath,
96
+ yaml.dump({
97
+ trust: {
98
+ auditors: ["@my-org", "github:alice"],
99
+ trusted_publishers: ["@my-org"],
100
+ },
101
+ }),
102
+ "utf-8"
103
+ );
104
+
105
+ const config = loadConfig();
106
+ const orgCount = config.trust?.auditors?.filter((a) => a === "@my-org").length;
107
+ expect(orgCount).toBe(1);
108
+ });
109
+
110
+ test("trusted_publishers works without existing auditors", () => {
111
+ const configPath = join(homedir(), ".enact", "config.yaml");
112
+ writeFileSync(
113
+ configPath,
114
+ yaml.dump({
115
+ trust: {
116
+ trusted_publishers: ["@my-org", "@other-org"],
117
+ },
118
+ }),
119
+ "utf-8"
120
+ );
121
+
122
+ const config = loadConfig();
123
+ // Should merge with default auditors
124
+ expect(config.trust?.auditors).toContain("@my-org");
125
+ expect(config.trust?.auditors).toContain("@other-org");
126
+ });
127
+
128
+ test("getTrustPolicy respects require_signatures alias", () => {
129
+ const configPath = join(homedir(), ".enact", "config.yaml");
130
+ writeFileSync(
131
+ configPath,
132
+ yaml.dump({
133
+ trust: {
134
+ require_signatures: true,
135
+ },
136
+ }),
137
+ "utf-8"
138
+ );
139
+
140
+ expect(getTrustPolicy()).toBe("require_attestation");
141
+ });
142
+
143
+ test("getTrustedIdentities includes trusted_publishers", () => {
144
+ const configPath = join(homedir(), ".enact", "config.yaml");
145
+ writeFileSync(
146
+ configPath,
147
+ yaml.dump({
148
+ trust: {
149
+ auditors: ["github:alice"],
150
+ trusted_publishers: ["@acme"],
151
+ },
152
+ }),
153
+ "utf-8"
154
+ );
155
+
156
+ const identities = getTrustedIdentities();
157
+ expect(identities).toContain("github:alice");
158
+ expect(identities).toContain("@acme");
159
+ });
160
+ });
161
+
162
+ describe("ExecutionConfig routing fields", () => {
163
+ test("default execution backend is preserved", () => {
164
+ const configPath = join(homedir(), ".enact", "config.yaml");
165
+ writeFileSync(
166
+ configPath,
167
+ yaml.dump({
168
+ execution: {
169
+ default: "docker",
170
+ },
171
+ }),
172
+ "utf-8"
173
+ );
174
+
175
+ const config = loadConfig();
176
+ expect(config.execution?.default).toBe("docker");
177
+ });
178
+
179
+ test("fallback execution backend is preserved", () => {
180
+ const configPath = join(homedir(), ".enact", "config.yaml");
181
+ writeFileSync(
182
+ configPath,
183
+ yaml.dump({
184
+ execution: {
185
+ default: "container",
186
+ fallback: "remote",
187
+ },
188
+ }),
189
+ "utf-8"
190
+ );
191
+
192
+ const config = loadConfig();
193
+ expect(config.execution?.default).toBe("container");
194
+ expect(config.execution?.fallback).toBe("remote");
195
+ });
196
+
197
+ test("trusted_scopes is preserved as array", () => {
198
+ const configPath = join(homedir(), ".enact", "config.yaml");
199
+ writeFileSync(
200
+ configPath,
201
+ yaml.dump({
202
+ execution: {
203
+ default: "container",
204
+ fallback: "remote",
205
+ trusted_scopes: ["@my-org/*", "@internal/*"],
206
+ },
207
+ }),
208
+ "utf-8"
209
+ );
210
+
211
+ const config = loadConfig();
212
+ expect(config.execution?.trusted_scopes).toEqual(["@my-org/*", "@internal/*"]);
213
+ });
214
+
215
+ test("routing fields coexist with existing fields", () => {
216
+ const configPath = join(homedir(), ".enact", "config.yaml");
217
+ writeFileSync(
218
+ configPath,
219
+ yaml.dump({
220
+ execution: {
221
+ defaultTimeout: "1m",
222
+ verbose: true,
223
+ default: "docker",
224
+ fallback: "local",
225
+ trusted_scopes: ["@my-org/*"],
226
+ },
227
+ }),
228
+ "utf-8"
229
+ );
230
+
231
+ const config = loadConfig();
232
+ expect(config.execution?.defaultTimeout).toBe("1m");
233
+ expect(config.execution?.verbose).toBe(true);
234
+ expect(config.execution?.default).toBe("docker");
235
+ expect(config.execution?.fallback).toBe("local");
236
+ expect(config.execution?.trusted_scopes).toEqual(["@my-org/*"]);
237
+ });
238
+
239
+ test("missing routing fields default to undefined", () => {
240
+ saveConfig({ ...DEFAULT_CONFIG });
241
+ const config = loadConfig();
242
+ expect(config.execution?.default).toBeUndefined();
243
+ expect(config.execution?.fallback).toBeUndefined();
244
+ expect(config.execution?.trusted_scopes).toBeUndefined();
245
+ });
246
+ });
247
+
248
+ describe("full README-style config", () => {
249
+ test("parses complete README example correctly", () => {
250
+ const configPath = join(homedir(), ".enact", "config.yaml");
251
+ writeFileSync(
252
+ configPath,
253
+ yaml.dump({
254
+ trust: {
255
+ require_signatures: true,
256
+ trusted_publishers: ["@my-org"],
257
+ },
258
+ execution: {
259
+ default: "container",
260
+ fallback: "remote",
261
+ trusted_scopes: ["@my-org/*"],
262
+ },
263
+ }),
264
+ "utf-8"
265
+ );
266
+
267
+ const config = loadConfig();
268
+
269
+ // Trust
270
+ expect(config.trust?.policy).toBe("require_attestation");
271
+ expect(config.trust?.auditors).toContain("@my-org");
272
+
273
+ // Execution
274
+ expect(config.execution?.default).toBe("container");
275
+ expect(config.execution?.fallback).toBe("remote");
276
+ expect(config.execution?.trusted_scopes).toEqual(["@my-org/*"]);
277
+ });
278
+ });
279
+ });
@@ -534,7 +534,7 @@ describe("configuration manager", () => {
534
534
  expect(existsSync(enactHome)).toBe(true);
535
535
  });
536
536
 
537
- test("creates ~/.enact/cache/ directory", () => {
537
+ test("creates ~/.agent/skills/ directory", () => {
538
538
  const enactHome = getEnactHome();
539
539
  const cacheDir = getCacheDir();
540
540
 
@@ -542,6 +542,9 @@ describe("configuration manager", () => {
542
542
  if (existsSync(enactHome)) {
543
543
  rmSync(enactHome, { recursive: true, force: true });
544
544
  }
545
+ if (existsSync(cacheDir)) {
546
+ rmSync(cacheDir, { recursive: true, force: true });
547
+ }
545
548
 
546
549
  ensureGlobalSetup();
547
550
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tests for getEffectiveInputSchema and DEFAULT_INPUT_SCHEMA.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { DEFAULT_INPUT_SCHEMA, getEffectiveInputSchema } from "../src/types/actions";
7
+ import type { Action } from "../src/types/actions";
8
+
9
+ describe("DEFAULT_INPUT_SCHEMA", () => {
10
+ test("is an object type schema", () => {
11
+ expect(DEFAULT_INPUT_SCHEMA.type).toBe("object");
12
+ });
13
+
14
+ test("has empty properties", () => {
15
+ expect(DEFAULT_INPUT_SCHEMA.properties).toEqual({});
16
+ });
17
+
18
+ test("has no required fields", () => {
19
+ expect(DEFAULT_INPUT_SCHEMA.required).toBeUndefined();
20
+ });
21
+ });
22
+
23
+ describe("getEffectiveInputSchema", () => {
24
+ test("returns action inputSchema when provided", () => {
25
+ const action: Action = {
26
+ description: "test",
27
+ command: ["echo"],
28
+ inputSchema: {
29
+ type: "object",
30
+ properties: { name: { type: "string" } },
31
+ required: ["name"],
32
+ },
33
+ };
34
+
35
+ const schema = getEffectiveInputSchema(action);
36
+ expect(schema).toBe(action.inputSchema!);
37
+ expect(schema.required).toEqual(["name"]);
38
+ });
39
+
40
+ test("returns DEFAULT_INPUT_SCHEMA when no inputSchema", () => {
41
+ const action: Action = {
42
+ description: "test",
43
+ command: "echo hello",
44
+ };
45
+
46
+ const schema = getEffectiveInputSchema(action);
47
+ expect(schema).toBe(DEFAULT_INPUT_SCHEMA);
48
+ expect(schema.type).toBe("object");
49
+ expect(schema.properties).toEqual({});
50
+ });
51
+
52
+ test("returns DEFAULT_INPUT_SCHEMA when inputSchema is undefined", () => {
53
+ const action = {
54
+ description: "test",
55
+ command: ["echo", "hello"],
56
+ } as Action;
57
+
58
+ const schema = getEffectiveInputSchema(action);
59
+ expect(schema).toBe(DEFAULT_INPUT_SCHEMA);
60
+ });
61
+
62
+ test("preserves complex inputSchema with nested properties", () => {
63
+ const action: Action = {
64
+ description: "test",
65
+ command: ["cmd"],
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ config: {
70
+ type: "object",
71
+ properties: {
72
+ depth: { type: "number", default: 3 },
73
+ format: { type: "string", enum: ["json", "csv"] },
74
+ },
75
+ },
76
+ },
77
+ required: ["config"],
78
+ },
79
+ };
80
+
81
+ const schema = getEffectiveInputSchema(action);
82
+ expect(schema.required).toEqual(["config"]);
83
+ const props = schema.properties as Record<string, { type: string }>;
84
+ expect(props.config!.type).toBe("object");
85
+ });
86
+ });
@@ -1,23 +1,16 @@
1
1
  ---
2
2
  enact: "2.0.0"
3
- name: acme/docs/analyzer
3
+ name: acme/analyzer
4
4
  description: Analyzes documentation for completeness
5
5
  version: "1.2.0"
6
6
  from: python:3.11-slim
7
- command: "python analyze.py ${file}"
8
7
  timeout: 5m
9
8
  license: Apache-2.0
10
9
  tags:
11
10
  - documentation
12
11
  - analysis
13
- inputSchema:
14
- type: object
15
- properties:
16
- file:
17
- type: string
18
- description: Path to the documentation file
19
- required:
20
- - file
12
+ scripts:
13
+ analyze: "python analyze.py {{file}}"
21
14
  outputSchema:
22
15
  type: object
23
16
  properties:
@@ -39,7 +32,7 @@ Provide a path to a documentation file and get a quality score along with
39
32
  any issues found.
40
33
 
41
34
  ```bash
42
- enact run acme/docs/analyzer --file README.md
35
+ enact run acme/analyzer --file README.md
43
36
  ```
44
37
 
45
38
  ## Output
@@ -54,7 +47,7 @@ The tool returns a JSON object with:
54
47
  ### Basic Analysis
55
48
 
56
49
  ```bash
57
- enact run acme/docs/analyzer --file docs/api.md
50
+ enact run acme/analyzer --file docs/api.md
58
51
  ```
59
52
 
60
53
  ### Integration with CI
@@ -1,22 +1,15 @@
1
1
  enact: "2.0.0"
2
- name: acme/utils/greeter
2
+ name: acme/greeter
3
3
  description: Greets users by name
4
4
  version: "1.0.0"
5
5
  from: node:18-alpine
6
- command: "echo 'Hello ${name}!'"
7
6
  timeout: 30s
8
7
  license: MIT
9
8
  tags:
10
9
  - greeting
11
10
  - utility
12
- inputSchema:
13
- type: object
14
- properties:
15
- name:
16
- type: string
17
- description: The name to greet
18
- required:
19
- - name
11
+ scripts:
12
+ greet: "echo 'Hello {{name}}!'"
20
13
  outputSchema:
21
14
  type: object
22
15
  properties:
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Tests for the ToolHooks type and postinstall hook behavior.
3
+ */
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type { ToolHooks, ToolManifest } from "../src/types/manifest";
9
+
10
+ const FIXTURES_DIR = join(import.meta.dir, "fixtures", "hooks-test");
11
+
12
+ beforeAll(() => {
13
+ mkdirSync(FIXTURES_DIR, { recursive: true });
14
+ });
15
+
16
+ afterAll(() => {
17
+ if (existsSync(FIXTURES_DIR)) {
18
+ rmSync(FIXTURES_DIR, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ describe("ToolHooks type", () => {
23
+ test("manifest accepts hooks with postinstall string", () => {
24
+ const manifest: ToolManifest = {
25
+ name: "@test/with-hook",
26
+ description: "A tool with a postinstall hook",
27
+ hooks: {
28
+ postinstall: "npm install",
29
+ },
30
+ };
31
+ expect(manifest.hooks?.postinstall).toBe("npm install");
32
+ });
33
+
34
+ test("manifest accepts hooks with postinstall array", () => {
35
+ const manifest: ToolManifest = {
36
+ name: "@test/with-hooks",
37
+ description: "A tool with multiple postinstall commands",
38
+ hooks: {
39
+ postinstall: ["npm install", "npm run build"],
40
+ },
41
+ };
42
+ expect(manifest.hooks?.postinstall).toEqual(["npm install", "npm run build"]);
43
+ });
44
+
45
+ test("manifest works without hooks", () => {
46
+ const manifest: ToolManifest = {
47
+ name: "@test/no-hooks",
48
+ description: "A tool without hooks",
49
+ };
50
+ expect(manifest.hooks).toBeUndefined();
51
+ });
52
+
53
+ test("hooks field can be empty object", () => {
54
+ const hooks: ToolHooks = {};
55
+ expect(hooks.postinstall).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe("postinstall hook execution", () => {
60
+ test("single command creates expected side-effect", async () => {
61
+ const toolDir = join(FIXTURES_DIR, "single-cmd");
62
+ mkdirSync(toolDir, { recursive: true });
63
+
64
+ const markerFile = join(toolDir, "postinstall-ran.txt");
65
+ const cmd = `echo "hook executed" > postinstall-ran.txt`;
66
+
67
+ const proc = Bun.spawn(["sh", "-c", cmd], {
68
+ cwd: toolDir,
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ });
72
+ await proc.exited;
73
+
74
+ expect(existsSync(markerFile)).toBe(true);
75
+ const content = await Bun.file(markerFile).text();
76
+ expect(content.trim()).toBe("hook executed");
77
+ });
78
+
79
+ test("multiple commands run sequentially", async () => {
80
+ const toolDir = join(FIXTURES_DIR, "multi-cmd");
81
+ mkdirSync(toolDir, { recursive: true });
82
+
83
+ const commands = [
84
+ 'echo "step1" > step1.txt',
85
+ 'echo "step2" > step2.txt',
86
+ "cat step1.txt step2.txt > combined.txt",
87
+ ];
88
+
89
+ for (const cmd of commands) {
90
+ const proc = Bun.spawn(["sh", "-c", cmd], {
91
+ cwd: toolDir,
92
+ stdout: "pipe",
93
+ stderr: "pipe",
94
+ });
95
+ const exitCode = await proc.exited;
96
+ expect(exitCode).toBe(0);
97
+ }
98
+
99
+ const combined = await Bun.file(join(toolDir, "combined.txt")).text();
100
+ expect(combined).toContain("step1");
101
+ expect(combined).toContain("step2");
102
+ });
103
+
104
+ test("failing command returns non-zero exit code", async () => {
105
+ const toolDir = join(FIXTURES_DIR, "fail-cmd");
106
+ mkdirSync(toolDir, { recursive: true });
107
+
108
+ const proc = Bun.spawn(["sh", "-c", "exit 1"], {
109
+ cwd: toolDir,
110
+ stdout: "pipe",
111
+ stderr: "pipe",
112
+ });
113
+ const exitCode = await proc.exited;
114
+
115
+ expect(exitCode).toBe(1);
116
+ });
117
+
118
+ test("command runs in the tool directory (cwd)", async () => {
119
+ const toolDir = join(FIXTURES_DIR, "cwd-check");
120
+ mkdirSync(toolDir, { recursive: true });
121
+
122
+ const proc = Bun.spawn(["sh", "-c", "pwd"], {
123
+ cwd: toolDir,
124
+ stdout: "pipe",
125
+ stderr: "pipe",
126
+ });
127
+ await proc.exited;
128
+
129
+ const output = await new Response(proc.stdout).text();
130
+ expect(output.trim()).toBe(toolDir);
131
+ });
132
+
133
+ test("command inherits environment variables", async () => {
134
+ const toolDir = join(FIXTURES_DIR, "env-check");
135
+ mkdirSync(toolDir, { recursive: true });
136
+
137
+ const proc = Bun.spawn(["sh", "-c", "echo $HOME"], {
138
+ cwd: toolDir,
139
+ stdout: "pipe",
140
+ stderr: "pipe",
141
+ env: { ...process.env },
142
+ });
143
+ await proc.exited;
144
+
145
+ const output = await new Response(proc.stdout).text();
146
+ expect(output.trim()).toBeTruthy();
147
+ expect(output.trim()).toBe(process.env.HOME!);
148
+ });
149
+ });
150
+
151
+ describe("manifest with hooks serialization", () => {
152
+ test("hooks survive JSON round-trip (single command)", () => {
153
+ const manifest: ToolManifest = {
154
+ name: "@test/roundtrip",
155
+ description: "test",
156
+ hooks: { postinstall: "make build" },
157
+ };
158
+
159
+ const json = JSON.stringify(manifest);
160
+ const parsed = JSON.parse(json) as ToolManifest;
161
+
162
+ expect(parsed.hooks?.postinstall).toBe("make build");
163
+ });
164
+
165
+ test("hooks survive JSON round-trip (command array)", () => {
166
+ const manifest: ToolManifest = {
167
+ name: "@test/roundtrip-array",
168
+ description: "test",
169
+ hooks: { postinstall: ["npm ci", "npm run build"] },
170
+ };
171
+
172
+ const json = JSON.stringify(manifest);
173
+ const parsed = JSON.parse(json) as ToolManifest;
174
+
175
+ expect(parsed.hooks?.postinstall).toEqual(["npm ci", "npm run build"]);
176
+ });
177
+ });