@sweny-ai/core 0.1.53 → 0.1.55

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 (45) hide show
  1. package/dist/__tests__/executor.test.js +2 -1
  2. package/dist/__tests__/integration/sources.integration.test.d.ts +1 -0
  3. package/dist/__tests__/integration/sources.integration.test.js +123 -0
  4. package/dist/__tests__/workflow-yaml.test.js +12 -5
  5. package/dist/cli/__tests__/report-cloud.test.d.ts +1 -0
  6. package/dist/cli/__tests__/report-cloud.test.js +165 -0
  7. package/dist/cli/cloud-report.d.ts +12 -0
  8. package/dist/cli/cloud-report.js +79 -0
  9. package/dist/cli/config-file.d.ts +3 -3
  10. package/dist/cli/config-file.js +18 -0
  11. package/dist/cli/config.d.ts +3 -0
  12. package/dist/cli/config.js +18 -2
  13. package/dist/cli/config.test.js +65 -1
  14. package/dist/cli/e2e.js +4 -2
  15. package/dist/cli/e2e.test.js +9 -2
  16. package/dist/cli/main.js +46 -19
  17. package/dist/cli/new.d.ts +0 -5
  18. package/dist/cli/new.js +0 -5
  19. package/dist/cli/new.test.js +1 -9
  20. package/dist/cli/output.test.js +1 -0
  21. package/dist/cli/publish.js +23 -12
  22. package/dist/cli/publish.test.d.ts +1 -0
  23. package/dist/cli/publish.test.js +610 -0
  24. package/dist/executor.d.ts +8 -0
  25. package/dist/executor.js +21 -3
  26. package/dist/executor.test.js +56 -1
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.js +1 -1
  29. package/dist/mermaid.test.js +2 -0
  30. package/dist/schema.d.ts +174 -46
  31. package/dist/schema.js +31 -2
  32. package/dist/sources.d.ts +70 -0
  33. package/dist/sources.js +194 -0
  34. package/dist/sources.test.d.ts +1 -0
  35. package/dist/sources.test.js +264 -0
  36. package/dist/sources.validation.test.d.ts +12 -0
  37. package/dist/sources.validation.test.js +360 -0
  38. package/dist/templates.d.ts +6 -2
  39. package/dist/templates.js +36 -46
  40. package/dist/testing.js +4 -1
  41. package/dist/types.d.ts +8 -1
  42. package/dist/types.js +0 -5
  43. package/dist/workflows/browser.js +1 -1
  44. package/dist/workflows/triage.yml +1 -23
  45. package/package.json +1 -5
@@ -0,0 +1,610 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ // ── Mocks ─────────────────────────────────────────────────────────
5
+ const mockExecFileSync = vi.fn();
6
+ vi.mock("node:child_process", () => ({
7
+ execFileSync: mockExecFileSync,
8
+ }));
9
+ vi.mock("../schema.js", () => ({
10
+ parseWorkflow: vi.fn(),
11
+ validateWorkflow: vi.fn(),
12
+ }));
13
+ const mockSelect = vi.fn();
14
+ const mockText = vi.fn();
15
+ const mockSpinner = vi.fn();
16
+ const mockIsCancel = vi.fn((_value) => false);
17
+ const mockIntro = vi.fn();
18
+ const mockCancel = vi.fn();
19
+ const mockLog = { error: vi.fn(), success: vi.fn(), info: vi.fn() };
20
+ vi.mock("@clack/prompts", () => ({
21
+ select: (...args) => mockSelect(...args),
22
+ text: (...args) => mockText(...args),
23
+ spinner: () => {
24
+ const s = { start: vi.fn(), message: vi.fn(), stop: vi.fn() };
25
+ mockSpinner(s);
26
+ return s;
27
+ },
28
+ isCancel: (value) => mockIsCancel(value),
29
+ intro: (...args) => mockIntro(...args),
30
+ cancel: (...args) => mockCancel(...args),
31
+ log: mockLog,
32
+ }));
33
+ vi.mock("chalk", () => ({
34
+ default: { cyan: (s) => s },
35
+ }));
36
+ const { validateWorkflowFile, validateSkillDir, runPublish } = await import("./publish.js");
37
+ const { parseWorkflow, validateWorkflow } = await import("../schema.js");
38
+ // ── Helpers ───────────────────────────────────────────────────────
39
+ let tmpDirs = [];
40
+ function makeTmpDir() {
41
+ const dir = fs.mkdtempSync(path.join(process.env.TMPDIR ?? "/tmp", "publish-test-"));
42
+ tmpDirs.push(dir);
43
+ return dir;
44
+ }
45
+ function makeTmpFile(name, content) {
46
+ const dir = makeTmpDir();
47
+ const filePath = path.join(dir, name);
48
+ fs.writeFileSync(filePath, content);
49
+ return filePath;
50
+ }
51
+ /** Minimal mock node that satisfies the Node type. */
52
+ const mockNode = (name = "n") => ({ name, instruction: "test", skills: [] });
53
+ // ── Setup / Teardown ──────────────────────────────────────────────
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ mockIsCancel.mockReturnValue(false);
57
+ tmpDirs = [];
58
+ });
59
+ afterEach(() => {
60
+ for (const dir of tmpDirs) {
61
+ fs.rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+ // ── validateWorkflowFile ──────────────────────────────────────────
65
+ describe("validateWorkflowFile", () => {
66
+ it("returns error for missing file", () => {
67
+ const result = validateWorkflowFile("/nonexistent/file.yml");
68
+ expect(result.valid).toBe(false);
69
+ expect(result.errors).toContain("File not found");
70
+ });
71
+ it("returns error for invalid YAML", () => {
72
+ const filePath = makeTmpFile("bad.yml", "{{{{invalid yaml");
73
+ const result = validateWorkflowFile(filePath);
74
+ expect(result.valid).toBe(false);
75
+ expect(result.errors[0]).toMatch(/Invalid YAML/);
76
+ });
77
+ it("returns schema validation errors with id and name", () => {
78
+ const filePath = makeTmpFile("test.yml", "id: test\nname: Test\n");
79
+ vi.mocked(parseWorkflow).mockReturnValue({
80
+ id: "test",
81
+ name: "Test",
82
+ description: "",
83
+ nodes: { start: mockNode("start") },
84
+ edges: [],
85
+ entry: "start",
86
+ skills: {},
87
+ });
88
+ vi.mocked(validateWorkflow).mockReturnValue([
89
+ { code: "MISSING_ENTRY", message: 'Entry node "start" does not exist' },
90
+ ]);
91
+ const result = validateWorkflowFile(filePath);
92
+ expect(result.valid).toBe(false);
93
+ expect(result.id).toBe("test");
94
+ expect(result.name).toBe("Test");
95
+ expect(result.errors).toContain('Entry node "start" does not exist');
96
+ });
97
+ it("returns valid result with node and edge counts", () => {
98
+ const filePath = makeTmpFile("good.yml", "id: test\nname: Good\n");
99
+ vi.mocked(parseWorkflow).mockReturnValue({
100
+ id: "test",
101
+ name: "Good",
102
+ description: "",
103
+ nodes: { a: mockNode("a"), b: mockNode("b"), c: mockNode("c") },
104
+ edges: [
105
+ { from: "a", to: "b" },
106
+ { from: "b", to: "c" },
107
+ ],
108
+ entry: "a",
109
+ skills: {},
110
+ });
111
+ vi.mocked(validateWorkflow).mockReturnValue([]);
112
+ const result = validateWorkflowFile(filePath);
113
+ expect(result.valid).toBe(true);
114
+ expect(result.nodeCount).toBe(3);
115
+ expect(result.edgeCount).toBe(2);
116
+ expect(result.errors).toEqual([]);
117
+ });
118
+ it("catches parseWorkflow throw as schema error", () => {
119
+ const filePath = makeTmpFile("throw.yml", "id: test\n");
120
+ vi.mocked(parseWorkflow).mockImplementation(() => {
121
+ throw new Error("Missing required field: entry");
122
+ });
123
+ const result = validateWorkflowFile(filePath);
124
+ expect(result.valid).toBe(false);
125
+ expect(result.errors[0]).toMatch(/Schema error.*Missing required field/);
126
+ });
127
+ });
128
+ // ── validateSkillDir ──────────────────────────────────────────────
129
+ describe("validateSkillDir", () => {
130
+ it("returns error when SKILL.md is missing", () => {
131
+ const dir = makeTmpDir();
132
+ const result = validateSkillDir(dir);
133
+ expect(result.valid).toBe(false);
134
+ expect(result.errors).toContain("SKILL.md not found in directory");
135
+ });
136
+ it("returns error when no frontmatter found", () => {
137
+ const dir = makeTmpDir();
138
+ fs.writeFileSync(path.join(dir, "SKILL.md"), "Just text, no frontmatter");
139
+ const result = validateSkillDir(dir);
140
+ expect(result.valid).toBe(false);
141
+ expect(result.errors).toContain("No YAML frontmatter found");
142
+ });
143
+ it("returns error for invalid frontmatter YAML", () => {
144
+ const dir = makeTmpDir();
145
+ fs.writeFileSync(path.join(dir, "SKILL.md"), "---\n{{{{bad\n---\n\nBody");
146
+ const result = validateSkillDir(dir);
147
+ expect(result.valid).toBe(false);
148
+ expect(result.errors[0]).toMatch(/Invalid frontmatter YAML/);
149
+ });
150
+ it("returns error when name is missing from frontmatter", () => {
151
+ const dir = makeTmpDir();
152
+ fs.writeFileSync(path.join(dir, "SKILL.md"), "---\ndescription: test\n---\n\nBody");
153
+ const result = validateSkillDir(dir);
154
+ expect(result.valid).toBe(false);
155
+ expect(result.errors).toContain("Missing 'name' field in frontmatter");
156
+ });
157
+ it("rejects uppercase in skill ID", () => {
158
+ const dir = makeTmpDir();
159
+ const skillDir = path.join(dir, "BadName");
160
+ fs.mkdirSync(skillDir);
161
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: BadName\n---\n\nBody");
162
+ const result = validateSkillDir(skillDir);
163
+ expect(result.valid).toBe(false);
164
+ expect(result.errors[0]).toMatch(/Invalid skill ID/);
165
+ });
166
+ it("rejects consecutive hyphens in skill ID", () => {
167
+ const dir = makeTmpDir();
168
+ const skillDir = path.join(dir, "my--skill");
169
+ fs.mkdirSync(skillDir);
170
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: my--skill\n---\n\nBody");
171
+ const result = validateSkillDir(skillDir);
172
+ expect(result.valid).toBe(false);
173
+ expect(result.errors[0]).toMatch(/Invalid skill ID/);
174
+ });
175
+ it("rejects skill ID over 64 characters", () => {
176
+ const dir = makeTmpDir();
177
+ const longId = "a".repeat(65);
178
+ const skillDir = path.join(dir, longId);
179
+ fs.mkdirSync(skillDir);
180
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---\nname: ${longId}\n---\n\nBody`);
181
+ const result = validateSkillDir(skillDir);
182
+ expect(result.valid).toBe(false);
183
+ expect(result.errors[0]).toMatch(/Invalid skill ID/);
184
+ });
185
+ it("accepts exactly 64-character skill ID", () => {
186
+ const dir = makeTmpDir();
187
+ const id64 = "a".repeat(64);
188
+ const skillDir = path.join(dir, id64);
189
+ fs.mkdirSync(skillDir);
190
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---\nname: ${id64}\n---\n\nBody`);
191
+ const result = validateSkillDir(skillDir);
192
+ expect(result.valid).toBe(true);
193
+ });
194
+ it("rejects skill ID starting with hyphen", () => {
195
+ const dir = makeTmpDir();
196
+ const skillDir = path.join(dir, "-bad");
197
+ fs.mkdirSync(skillDir);
198
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: -bad\n---\n\nBody");
199
+ const result = validateSkillDir(skillDir);
200
+ expect(result.valid).toBe(false);
201
+ });
202
+ it("rejects skill ID ending with hyphen", () => {
203
+ const dir = makeTmpDir();
204
+ const skillDir = path.join(dir, "bad-");
205
+ fs.mkdirSync(skillDir);
206
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: bad-\n---\n\nBody");
207
+ const result = validateSkillDir(skillDir);
208
+ expect(result.valid).toBe(false);
209
+ });
210
+ it("reports when directory name doesn't match skill name", () => {
211
+ const dir = makeTmpDir();
212
+ const skillDir = path.join(dir, "wrong-name");
213
+ fs.mkdirSync(skillDir);
214
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: real-name\n---\n\nBody");
215
+ const result = validateSkillDir(skillDir);
216
+ expect(result.valid).toBe(false);
217
+ expect(result.errors).toContain('Directory name "wrong-name" doesn\'t match skill name "real-name"');
218
+ });
219
+ it("rejects skill with neither instruction body nor mcp", () => {
220
+ const dir = makeTmpDir();
221
+ const skillDir = path.join(dir, "empty");
222
+ fs.mkdirSync(skillDir);
223
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: empty\n---\n");
224
+ const result = validateSkillDir(skillDir);
225
+ expect(result.valid).toBe(false);
226
+ expect(result.errors).toContain("Skill must have an instruction body or an mcp config");
227
+ });
228
+ it("validates skill with instruction body", () => {
229
+ const dir = makeTmpDir();
230
+ const skillDir = path.join(dir, "my-skill");
231
+ fs.mkdirSync(skillDir);
232
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: my-skill\n---\n\nDo the thing.");
233
+ const result = validateSkillDir(skillDir);
234
+ expect(result.valid).toBe(true);
235
+ expect(result.id).toBe("my-skill");
236
+ expect(result.hasInstruction).toBe(true);
237
+ expect(result.hasMcp).toBe(false);
238
+ });
239
+ it("validates skill with mcp config only", () => {
240
+ const dir = makeTmpDir();
241
+ const skillDir = path.join(dir, "mcp-only");
242
+ fs.mkdirSync(skillDir);
243
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), '---\nname: mcp-only\nmcp:\n command: "node"\n---\n');
244
+ const result = validateSkillDir(skillDir);
245
+ expect(result.valid).toBe(true);
246
+ expect(result.hasInstruction).toBe(false);
247
+ expect(result.hasMcp).toBe(true);
248
+ });
249
+ it("validates skill with both instruction and mcp", () => {
250
+ const dir = makeTmpDir();
251
+ const skillDir = path.join(dir, "both");
252
+ fs.mkdirSync(skillDir);
253
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), '---\nname: both\nmcp:\n command: "node"\n---\n\nAlso instructions.');
254
+ const result = validateSkillDir(skillDir);
255
+ expect(result.valid).toBe(true);
256
+ expect(result.hasInstruction).toBe(true);
257
+ expect(result.hasMcp).toBe(true);
258
+ });
259
+ it("accepts single-character skill ID", () => {
260
+ const dir = makeTmpDir();
261
+ const skillDir = path.join(dir, "a");
262
+ fs.mkdirSync(skillDir);
263
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: a\n---\n\nBody.");
264
+ const result = validateSkillDir(skillDir);
265
+ expect(result.valid).toBe(true);
266
+ });
267
+ it("accepts skill ID with numbers", () => {
268
+ const dir = makeTmpDir();
269
+ const skillDir = path.join(dir, "tool2");
270
+ fs.mkdirSync(skillDir);
271
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: tool2\n---\n\nBody.");
272
+ const result = validateSkillDir(skillDir);
273
+ expect(result.valid).toBe(true);
274
+ });
275
+ });
276
+ // ── runPublish ────────────────────────────────────────────────────
277
+ describe("runPublish", () => {
278
+ it("shows intro message", async () => {
279
+ mockSelect.mockResolvedValue("workflow");
280
+ mockText.mockResolvedValue("/nonexistent.yml");
281
+ await runPublish();
282
+ expect(mockIntro).toHaveBeenCalledWith("Publish to the SWEny Marketplace");
283
+ });
284
+ it("returns null when user cancels content type selection", async () => {
285
+ const cancelSymbol = Symbol("cancel");
286
+ mockSelect.mockResolvedValue(cancelSymbol);
287
+ mockIsCancel.mockImplementation((v) => v === cancelSymbol);
288
+ // cancel() calls process.exit(0), which we need to catch
289
+ const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
290
+ throw new Error("process.exit");
291
+ });
292
+ await expect(runPublish()).rejects.toThrow("process.exit");
293
+ expect(mockCancel).toHaveBeenCalledWith("Publish cancelled.");
294
+ mockExit.mockRestore();
295
+ });
296
+ });
297
+ // ── openGitHubPR (via runPublish integration) ─────────────────────
298
+ describe("openGitHubPR flow", () => {
299
+ function setupWorkflowPRFlow(workflowPath) {
300
+ // Step 1: select "workflow"
301
+ mockSelect.mockResolvedValueOnce("workflow");
302
+ // Step 2: text input for file path
303
+ mockText.mockResolvedValueOnce(workflowPath);
304
+ // Step 3: author
305
+ mockText.mockResolvedValueOnce("test-author");
306
+ // Step 4: category
307
+ mockSelect.mockResolvedValueOnce("testing");
308
+ // Step 5: tags
309
+ mockText.mockResolvedValueOnce("test, ci");
310
+ // Step 6: output mode = PR
311
+ mockSelect.mockResolvedValueOnce("pr");
312
+ // Mock workflow validation
313
+ vi.mocked(parseWorkflow).mockReturnValue({
314
+ id: "my-wf",
315
+ name: "My Workflow",
316
+ description: "",
317
+ nodes: { a: mockNode("a") },
318
+ edges: [],
319
+ entry: "a",
320
+ skills: {},
321
+ });
322
+ vi.mocked(validateWorkflow).mockReturnValue([]);
323
+ // Mock gh available — clone creates the dir structure the code expects
324
+ mockExecFileSync.mockImplementation((cmd, args) => {
325
+ if (cmd === "gh" && args[0] === "--version")
326
+ return "gh version 2.0.0";
327
+ if (cmd === "gh" && args[0] === "auth")
328
+ return "";
329
+ if (cmd === "gh" && args[0] === "repo" && args[1] === "fork")
330
+ return "";
331
+ if (cmd === "gh" && args[0] === "api" && args[1] === "user") {
332
+ return JSON.stringify({ login: "testuser" });
333
+ }
334
+ if (cmd === "gh" && args[0] === "repo" && args[1] === "clone") {
335
+ // args: ["repo", "clone", forkRepo, tmpDir, "--", "--depth", "1"]
336
+ const cloneDir = args[3];
337
+ fs.mkdirSync(path.join(cloneDir, "workflows", "community"), { recursive: true });
338
+ fs.mkdirSync(path.join(cloneDir, "skills", "community"), { recursive: true });
339
+ return "";
340
+ }
341
+ if (cmd === "git")
342
+ return "";
343
+ if (cmd === "gh" && args[0] === "pr")
344
+ return "https://github.com/swenyai/marketplace/pull/42\n";
345
+ return "";
346
+ });
347
+ }
348
+ it("creates PR with correct gh pr create arguments", async () => {
349
+ const dir = makeTmpDir();
350
+ const wfPath = path.join(dir, "test.yml");
351
+ fs.writeFileSync(wfPath, "id: my-wf\nname: My Workflow\n");
352
+ setupWorkflowPRFlow(wfPath);
353
+ const result = await runPublish();
354
+ // Find the gh pr create call
355
+ const prCall = mockExecFileSync.mock.calls.find((c) => c[0] === "gh" && c[1]?.[0] === "pr" && c[1]?.[1] === "create");
356
+ expect(prCall).toBeDefined();
357
+ const args = prCall[1];
358
+ expect(args).toContain("--repo");
359
+ expect(args).toContain("swenyai/marketplace");
360
+ expect(args).toContain("--head");
361
+ expect(args).toContain("testuser:publish/workflow/my-wf");
362
+ expect(args).toContain("--title");
363
+ expect(args).toContain("feat: add workflow my-wf");
364
+ expect(result?.prUrl).toBe("https://github.com/swenyai/marketplace/pull/42");
365
+ expect(result?.type).toBe("workflow");
366
+ expect(result?.id).toBe("my-wf");
367
+ });
368
+ it("passes commit message as array arg, not shell string", async () => {
369
+ const dir = makeTmpDir();
370
+ const wfPath = path.join(dir, "test.yml");
371
+ fs.writeFileSync(wfPath, "id: my-wf\nname: My Workflow\n");
372
+ setupWorkflowPRFlow(wfPath);
373
+ await runPublish();
374
+ const commitCall = mockExecFileSync.mock.calls.find((c) => c[0] === "git" && c[1]?.includes("commit"));
375
+ expect(commitCall).toBeDefined();
376
+ const args = commitCall[1];
377
+ // Commit message is a single array element, not split by shell
378
+ expect(args).toContain("-m");
379
+ expect(args).toContain("feat: add my-wf workflow");
380
+ });
381
+ it("falls back to saveLocally when gh commands fail", async () => {
382
+ const dir = makeTmpDir();
383
+ const wfPath = path.join(dir, "test.yml");
384
+ fs.writeFileSync(wfPath, "id: my-wf\nname: My Workflow\n");
385
+ // Step 1: select "workflow"
386
+ mockSelect.mockResolvedValueOnce("workflow");
387
+ mockText.mockResolvedValueOnce(wfPath);
388
+ mockText.mockResolvedValueOnce("author");
389
+ mockSelect.mockResolvedValueOnce("testing");
390
+ mockText.mockResolvedValueOnce("test");
391
+ mockSelect.mockResolvedValueOnce("pr"); // choose PR mode
392
+ vi.mocked(parseWorkflow).mockReturnValue({
393
+ id: "my-wf",
394
+ name: "My Workflow",
395
+ description: "",
396
+ nodes: { a: mockNode("a") },
397
+ edges: [],
398
+ entry: "a",
399
+ skills: {},
400
+ });
401
+ vi.mocked(validateWorkflow).mockReturnValue([]);
402
+ mockExecFileSync.mockImplementation((cmd, args) => {
403
+ if (cmd === "gh" && args[0] === "--version")
404
+ return "gh version 2.0.0";
405
+ if (cmd === "gh" && args[0] === "auth")
406
+ return "";
407
+ // Fork fails
408
+ if (cmd === "gh" && args[0] === "repo" && args[1] === "fork") {
409
+ throw new Error("Network error");
410
+ }
411
+ return "";
412
+ });
413
+ const result = await runPublish();
414
+ // Should have fallen back to save locally
415
+ expect(result?.type).toBe("workflow");
416
+ expect(result?.outputPath).toBeDefined();
417
+ expect(mockLog.error).toHaveBeenCalled();
418
+ expect(mockLog.info).toHaveBeenCalledWith(expect.stringContaining("Falling back"));
419
+ });
420
+ it("handles special characters in skill ID safely via array args", async () => {
421
+ const dir = makeTmpDir();
422
+ // Create a skill directory with a name that would be dangerous in shell
423
+ const skillDir = path.join(dir, "safe-skill");
424
+ fs.mkdirSync(skillDir);
425
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: safe-skill\n---\n\nBody.");
426
+ // Select skill → provide path → choose PR mode
427
+ mockSelect.mockResolvedValueOnce("skill");
428
+ mockText.mockResolvedValueOnce(skillDir);
429
+ mockSelect.mockResolvedValueOnce("pr");
430
+ mockExecFileSync.mockImplementation((cmd, args) => {
431
+ if (cmd === "gh" && args[0] === "--version")
432
+ return "gh version 2.0.0";
433
+ if (cmd === "gh" && args[0] === "auth")
434
+ return "";
435
+ if (cmd === "gh" && args[0] === "repo" && args[1] === "fork")
436
+ return "";
437
+ if (cmd === "gh" && args[0] === "api")
438
+ return JSON.stringify({ login: "user" });
439
+ if (cmd === "gh" && args[0] === "repo" && args[1] === "clone") {
440
+ const cloneDir = args[2];
441
+ fs.mkdirSync(path.join(cloneDir, "workflows", "community"), { recursive: true });
442
+ fs.mkdirSync(path.join(cloneDir, "skills", "community"), { recursive: true });
443
+ return "";
444
+ }
445
+ if (cmd === "git")
446
+ return "";
447
+ if (cmd === "gh" && args[0] === "pr")
448
+ return "https://github.com/swenyai/marketplace/pull/99\n";
449
+ return "";
450
+ });
451
+ const result = await runPublish();
452
+ expect(result?.type).toBe("skill");
453
+ // Verify all git/gh commands used array args (not string interpolation)
454
+ for (const call of mockExecFileSync.mock.calls) {
455
+ expect(typeof call[0]).toBe("string"); // command is a string
456
+ expect(Array.isArray(call[1])).toBe(true); // args is an array
457
+ }
458
+ });
459
+ });
460
+ // ── saveLocally flow ──────────────────────────────────────────────
461
+ describe("saveLocally flow", () => {
462
+ afterEach(() => {
463
+ // Clean up sweny-publish directory if created
464
+ const publishDir = path.resolve("sweny-publish");
465
+ if (fs.existsSync(publishDir)) {
466
+ fs.rmSync(publishDir, { recursive: true, force: true });
467
+ }
468
+ });
469
+ it("saves workflow file locally when no gh available", async () => {
470
+ const dir = makeTmpDir();
471
+ const wfPath = path.join(dir, "test.yml");
472
+ fs.writeFileSync(wfPath, "id: local-wf\nname: Local\n");
473
+ mockSelect.mockResolvedValueOnce("workflow");
474
+ mockText.mockResolvedValueOnce(wfPath);
475
+ mockText.mockResolvedValueOnce("author");
476
+ mockSelect.mockResolvedValueOnce("testing");
477
+ mockText.mockResolvedValueOnce("test");
478
+ // gh not available → only "save" option
479
+ mockSelect.mockResolvedValueOnce("save");
480
+ vi.mocked(parseWorkflow).mockReturnValue({
481
+ id: "local-wf",
482
+ name: "Local",
483
+ description: "",
484
+ nodes: { a: mockNode("a") },
485
+ edges: [],
486
+ entry: "a",
487
+ skills: {},
488
+ });
489
+ vi.mocked(validateWorkflow).mockReturnValue([]);
490
+ mockExecFileSync.mockImplementation(() => {
491
+ throw new Error("not found");
492
+ });
493
+ const result = await runPublish();
494
+ expect(result?.type).toBe("workflow");
495
+ expect(result?.outputPath).toMatch(/sweny-publish.*local-wf\.yml/);
496
+ expect(fs.existsSync(result.outputPath)).toBe(true);
497
+ });
498
+ it("saves skill directory locally", async () => {
499
+ const dir = makeTmpDir();
500
+ const skillDir = path.join(dir, "my-skill");
501
+ fs.mkdirSync(skillDir);
502
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: my-skill\n---\n\nBody.");
503
+ fs.writeFileSync(path.join(skillDir, "extra.txt"), "extra file");
504
+ mockSelect.mockResolvedValueOnce("skill");
505
+ mockText.mockResolvedValueOnce(skillDir);
506
+ mockSelect.mockResolvedValueOnce("save");
507
+ mockExecFileSync.mockImplementation(() => {
508
+ throw new Error("not found");
509
+ });
510
+ const result = await runPublish();
511
+ expect(result?.type).toBe("skill");
512
+ expect(result?.outputPath).toMatch(/sweny-publish.*my-skill/);
513
+ // Verify files were copied
514
+ expect(fs.existsSync(path.join(result.outputPath, "SKILL.md"))).toBe(true);
515
+ expect(fs.existsSync(path.join(result.outputPath, "extra.txt"))).toBe(true);
516
+ });
517
+ });
518
+ // ── Validation failure paths ──────────────────────────────────────
519
+ describe("validation failure paths", () => {
520
+ it("returns null when workflow validation fails", async () => {
521
+ const dir = makeTmpDir();
522
+ const wfPath = path.join(dir, "bad.yml");
523
+ fs.writeFileSync(wfPath, "id: bad\n");
524
+ mockSelect.mockResolvedValueOnce("workflow");
525
+ mockText.mockResolvedValueOnce(wfPath);
526
+ vi.mocked(parseWorkflow).mockImplementation(() => {
527
+ throw new Error("Invalid");
528
+ });
529
+ const result = await runPublish();
530
+ expect(result).toBeNull();
531
+ expect(mockLog.error).toHaveBeenCalled();
532
+ });
533
+ it("returns null when skill validation fails", async () => {
534
+ const dir = makeTmpDir();
535
+ // No SKILL.md in directory
536
+ mockSelect.mockResolvedValueOnce("skill");
537
+ // The text validator will reject this before we get to validateSkillDir
538
+ // So let's provide a dir with SKILL.md that has bad frontmatter
539
+ fs.writeFileSync(path.join(dir, "SKILL.md"), "no frontmatter");
540
+ mockText.mockResolvedValueOnce(dir);
541
+ const result = await runPublish();
542
+ expect(result).toBeNull();
543
+ expect(mockLog.error).toHaveBeenCalled();
544
+ });
545
+ });
546
+ // ── ghAvailable / ghAuthenticated ─────────────────────────────────
547
+ describe("gh availability detection", () => {
548
+ it("shows PR option when gh is available", async () => {
549
+ const dir = makeTmpDir();
550
+ const wfPath = path.join(dir, "test.yml");
551
+ fs.writeFileSync(wfPath, "id: wf\nname: WF\n");
552
+ mockSelect.mockResolvedValueOnce("workflow");
553
+ mockText.mockResolvedValueOnce(wfPath);
554
+ mockText.mockResolvedValueOnce("author");
555
+ mockSelect.mockResolvedValueOnce("testing");
556
+ mockText.mockResolvedValueOnce("test");
557
+ mockSelect.mockResolvedValueOnce("save"); // choose save
558
+ vi.mocked(parseWorkflow).mockReturnValue({
559
+ id: "wf",
560
+ name: "WF",
561
+ description: "",
562
+ nodes: { a: mockNode("a") },
563
+ edges: [],
564
+ entry: "a",
565
+ skills: {},
566
+ });
567
+ vi.mocked(validateWorkflow).mockReturnValue([]);
568
+ // gh is available — only --version needs to succeed for ghAvailable()
569
+ mockExecFileSync.mockImplementation((cmd, args) => {
570
+ if (cmd === "gh" && args[0] === "--version")
571
+ return "gh version 2.0.0";
572
+ return "";
573
+ });
574
+ await runPublish();
575
+ // Verify the output mode select was called with PR option
576
+ const outputModeCall = mockSelect.mock.calls[2]; // 3rd select call
577
+ const options = outputModeCall[0].options;
578
+ expect(options.some((o) => o.value === "pr")).toBe(true);
579
+ });
580
+ it("hides PR option when gh is not available", async () => {
581
+ const dir = makeTmpDir();
582
+ const wfPath = path.join(dir, "test.yml");
583
+ fs.writeFileSync(wfPath, "id: wf\nname: WF\n");
584
+ mockSelect.mockResolvedValueOnce("workflow");
585
+ mockText.mockResolvedValueOnce(wfPath);
586
+ mockText.mockResolvedValueOnce("author");
587
+ mockSelect.mockResolvedValueOnce("testing");
588
+ mockText.mockResolvedValueOnce("test");
589
+ mockSelect.mockResolvedValueOnce("save");
590
+ vi.mocked(parseWorkflow).mockReturnValue({
591
+ id: "wf",
592
+ name: "WF",
593
+ description: "",
594
+ nodes: { a: mockNode("a") },
595
+ edges: [],
596
+ entry: "a",
597
+ skills: {},
598
+ });
599
+ vi.mocked(validateWorkflow).mockReturnValue([]);
600
+ // gh not available
601
+ mockExecFileSync.mockImplementation(() => {
602
+ throw new Error("not found");
603
+ });
604
+ await runPublish();
605
+ // Verify output mode select has no PR option
606
+ const outputModeCall = mockSelect.mock.calls[2];
607
+ const options = outputModeCall[0].options;
608
+ expect(options.every((o) => o.value !== "pr")).toBe(true);
609
+ });
610
+ });
@@ -24,6 +24,14 @@ export interface ExecuteOptions {
24
24
  observer?: Observer;
25
25
  /** Logger */
26
26
  logger?: Logger;
27
+ /** Working directory for resolving file Sources (default: process.cwd()) */
28
+ cwd?: string;
29
+ /** Environment variables for Source resolution auth lookup */
30
+ env?: NodeJS.ProcessEnv;
31
+ /** Host → env-var-name map for URL Source authentication */
32
+ fetchAuth?: Record<string, string>;
33
+ /** When true, URL Sources throw instead of fetching */
34
+ offline?: boolean;
27
35
  }
28
36
  /**
29
37
  * Execute a workflow from entry to completion.
package/dist/executor.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * This replaces ~8k lines of engine + recipe step code.
14
14
  */
15
15
  import { consoleLogger } from "./types.js";
16
+ import { resolveSources } from "./sources.js";
16
17
  /**
17
18
  * Execute a workflow from entry to completion.
18
19
  *
@@ -31,9 +32,25 @@ export async function execute(workflow, input, options) {
31
32
  const results = new Map();
32
33
  const edgeCounts = new Map(); // "from→to" → times followed
33
34
  const nodeRunCounts = new Map(); // node → times executed
34
- const trace = { steps: [], edges: [] };
35
+ const trace = { steps: [], edges: [], sources: {} };
35
36
  validate(workflow, skills);
37
+ // ── Source resolution phase ─────────────────────────────────────
38
+ // Resolve all node instructions (which are Source values) into plain text
39
+ // before the main execution loop. This eagerly fetches file/URL content.
40
+ const sourceMap = {};
41
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
42
+ sourceMap[`nodes.${nodeId}.instruction`] = node.instruction;
43
+ }
44
+ const resolvedSources = await resolveSources(sourceMap, {
45
+ cwd: options.cwd ?? process.cwd(),
46
+ env: options.env ?? process.env,
47
+ authConfig: options.fetchAuth ?? {},
48
+ offline: options.offline ?? false,
49
+ logger,
50
+ });
51
+ trace.sources = resolvedSources;
36
52
  safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
53
+ safeObserve(observer, { type: "sources:resolved", sources: resolvedSources }, logger);
37
54
  let currentId = workflow.entry;
38
55
  while (currentId) {
39
56
  const node = workflow.nodes[currentId];
@@ -41,7 +58,8 @@ export async function execute(workflow, input, options) {
41
58
  throw new Error(`Unknown node: "${currentId}"`);
42
59
  const iteration = (nodeRunCounts.get(currentId) ?? 0) + 1;
43
60
  nodeRunCounts.set(currentId, iteration);
44
- safeObserve(observer, { type: "node:enter", node: currentId, instruction: node.instruction }, logger);
61
+ const resolvedInstruction = resolvedSources[`nodes.${currentId}.instruction`].content;
62
+ safeObserve(observer, { type: "node:enter", node: currentId, instruction: resolvedInstruction }, logger);
45
63
  logger.info(`→ ${node.name}`, { node: currentId });
46
64
  // Gather tools from the node's skills
47
65
  const tools = resolveTools(node.skills, skills);
@@ -72,7 +90,7 @@ export async function execute(workflow, input, options) {
72
90
  },
73
91
  }));
74
92
  // Prepend rules and context to instruction if provided
75
- const instruction = buildNodeInstruction(node.instruction, input, skillInstructions);
93
+ const instruction = buildNodeInstruction(resolvedInstruction, input, skillInstructions);
76
94
  // Run Claude on this node
77
95
  const result = await claude.run({
78
96
  instruction,