@sweny-ai/core 0.1.63 → 0.1.65

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/cli/new.js CHANGED
@@ -291,7 +291,7 @@ export function detectGitRemote(cwd) {
291
291
  /**
292
292
  * Infer source-control-provider from git remote detection.
293
293
  */
294
- function inferSourceControl(gitInfo) {
294
+ export function inferSourceControl(gitInfo) {
295
295
  return gitInfo?.provider ?? "github";
296
296
  }
297
297
  /**
@@ -302,7 +302,7 @@ function inferSourceControl(gitInfo) {
302
302
  * provider (yet), and `github-issues` is a sensible "use the same platform
303
303
  * as source control" default for the supported case.
304
304
  */
305
- function inferIssueTracker(skills, _sourceControl) {
305
+ export function inferIssueTracker(skills, _sourceControl) {
306
306
  if (skills.includes("linear"))
307
307
  return "linear";
308
308
  if (skills.includes("jira"))
@@ -312,7 +312,7 @@ function inferIssueTracker(skills, _sourceControl) {
312
312
  /**
313
313
  * Infer observability-provider from skills in the workflow.
314
314
  */
315
- function inferObservability(skills) {
315
+ export function inferObservability(skills) {
316
316
  if (skills.includes("datadog"))
317
317
  return "datadog";
318
318
  if (skills.includes("sentry"))
@@ -396,6 +396,51 @@ export function buildActionWorkflow(credentials, cronExpression) {
396
396
  lines.push("");
397
397
  return lines.join("\n");
398
398
  }
399
+ // ── Pure write helpers (exported for marketplace install path) ────────
400
+ /** Write .sweny.yml if missing. Returns true if created, false if file existed. */
401
+ export function writeSwenyYmlIfMissing(cwd, sourceControl, observability, issueTracker) {
402
+ const configPath = path.join(cwd, ".sweny.yml");
403
+ if (fs.existsSync(configPath))
404
+ return false;
405
+ fs.writeFileSync(configPath, buildSwenyYml(sourceControl, observability, issueTracker), "utf-8");
406
+ return true;
407
+ }
408
+ /** Append credential keys to .env that aren't already present. Returns count appended. */
409
+ export function appendMissingEnvKeys(cwd, credentials) {
410
+ const envPath = path.join(cwd, ".env");
411
+ if (!fs.existsSync(envPath)) {
412
+ fs.writeFileSync(envPath, buildEnvTemplate(credentials), "utf-8");
413
+ return credentials.length;
414
+ }
415
+ const existing = fs.readFileSync(envPath, "utf-8");
416
+ const definedKeys = new Set();
417
+ for (const rawLine of existing.split("\n")) {
418
+ const line = rawLine.trimStart().replace(/^#\s*/, "");
419
+ const eq = line.indexOf("=");
420
+ if (eq <= 0)
421
+ continue;
422
+ const key = line.slice(0, eq).trim();
423
+ if (/^[A-Z_][A-Z0-9_]*$/i.test(key))
424
+ definedKeys.add(key);
425
+ }
426
+ const newKeys = credentials.filter((c) => !definedKeys.has(c.key));
427
+ if (newKeys.length === 0)
428
+ return 0;
429
+ fs.appendFileSync(envPath, "\n" + buildEnvTemplate(newKeys), "utf-8");
430
+ return newKeys.length;
431
+ }
432
+ /** Write workflow YAML into .sweny/workflows/<id>.yml. Returns info about what happened. */
433
+ export function writeWorkflowFile(cwd, id, yaml, options) {
434
+ const dir = path.join(cwd, ".sweny", "workflows");
435
+ fs.mkdirSync(dir, { recursive: true });
436
+ const target = path.join(dir, `${id}.yml`);
437
+ const overwrite = options?.overwrite ?? false;
438
+ if (fs.existsSync(target) && !overwrite) {
439
+ return { written: false, exists: true, path: target };
440
+ }
441
+ fs.writeFileSync(target, yaml, "utf-8");
442
+ return { written: true, path: target };
443
+ }
399
444
  // ── Interactive wizard ────────────────────────────────────────────────
400
445
  function cancel() {
401
446
  p.cancel("Setup cancelled.");
@@ -406,7 +451,8 @@ function cancel() {
406
451
  *
407
452
  * Flow:
408
453
  * 1. Detect git remote (used to infer source-control provider)
409
- * 2. Pick one of four branches:
454
+ * 2. Pick one of five branches:
455
+ * - `__marketplace` → browse published workflows at swenyai/workflows, then delegate to `installMarketplaceWorkflow`
410
456
  * - A built-in `WORKFLOW_TEMPLATES` entry → write that workflow file
411
457
  * - `__custom` → LLM generates a workflow from a user description
412
458
  * - `__e2e` → delegate to the e2e browser-testing wizard in `./e2e.ts`
@@ -420,8 +466,63 @@ function cancel() {
420
466
  *
421
467
  * Safe to re-run in an existing project to add additional workflows.
422
468
  */
423
- export async function runNew() {
469
+ export async function runNew(options) {
424
470
  const cwd = process.cwd();
471
+ // ── Marketplace install fast path ───────────────────────────────────
472
+ if (options?.marketplaceId) {
473
+ const { installMarketplaceWorkflow } = await import("./marketplace.js");
474
+ const allSkills = configuredSkills(process.env, cwd);
475
+ const hasAgent = !!process.env.ANTHROPIC_API_KEY;
476
+ const claude = hasAgent ? new ClaudeClient({ maxTurns: 3, cwd, logger: consoleLogger }) : null;
477
+ if (!options.skipIntro)
478
+ p.intro(`Installing ${options.marketplaceId} from marketplace`);
479
+ // Pre-compute source-control from git-remote (same method as the wizard path).
480
+ // Don't pre-compute issue-tracker / observability — those are skill-based,
481
+ // and marketplace.ts infers them after the workflow is fetched.
482
+ const gitInfo = detectGitRemote(cwd);
483
+ const inferredSourceControl = inferSourceControl(gitInfo);
484
+ // Check for existing workflow file and prompt before fetching.
485
+ const existingWorkflowPath = path.join(cwd, ".sweny", "workflows", `${options.marketplaceId}.yml`);
486
+ let overwrite = false;
487
+ if (fs.existsSync(existingWorkflowPath)) {
488
+ const confirmed = await p.confirm({
489
+ message: `.sweny/workflows/${options.marketplaceId}.yml already exists. Overwrite?`,
490
+ initialValue: false,
491
+ });
492
+ if (p.isCancel(confirmed) || !confirmed) {
493
+ p.log.info(`Workflow already exists at ${path.relative(cwd, existingWorkflowPath)}. Not overwritten.`);
494
+ p.cancel("Setup cancelled.");
495
+ process.exit(0);
496
+ }
497
+ overwrite = true;
498
+ }
499
+ let result;
500
+ try {
501
+ result = await installMarketplaceWorkflow(options.marketplaceId, {
502
+ cwd,
503
+ availableSkills: allSkills,
504
+ claude,
505
+ logger: consoleLogger,
506
+ inferredSourceControl,
507
+ overwrite,
508
+ });
509
+ }
510
+ catch (err) {
511
+ p.log.error(err instanceof Error ? err.message : String(err));
512
+ process.exit(1);
513
+ }
514
+ if (!result.installed) {
515
+ p.cancel("Setup cancelled.");
516
+ process.exit(0);
517
+ }
518
+ if (result.adapted)
519
+ p.log.success("Adapted workflow for your project");
520
+ if (result.addedEnvKeys > 0)
521
+ p.log.success(`Appended ${result.addedEnvKeys} key(s) to .env`);
522
+ p.log.success(`Created ${path.relative(cwd, result.workflowPath)}`);
523
+ p.outro("Workflow installed!");
524
+ return;
525
+ }
425
526
  // ── Fresh vs existing detection ─────────────────────────────────────
426
527
  const configPath = path.join(cwd, ".sweny.yml");
427
528
  const hasExistingConfig = fs.existsSync(configPath);
@@ -441,6 +542,11 @@ export async function runNew() {
441
542
  const templateChoice = await p.select({
442
543
  message: "What do you want to do?",
443
544
  options: [
545
+ {
546
+ value: "__marketplace",
547
+ label: "Browse marketplace",
548
+ hint: "install a published workflow from swenyai/workflows",
549
+ },
444
550
  ...WORKFLOW_TEMPLATES.map((t) => ({
445
551
  value: t.id,
446
552
  label: t.name,
@@ -453,6 +559,35 @@ export async function runNew() {
453
559
  });
454
560
  if (p.isCancel(templateChoice))
455
561
  cancel();
562
+ // ── Marketplace short-circuit: fetch index, pick, delegate ──────────
563
+ if (templateChoice === "__marketplace") {
564
+ const { fetchMarketplaceIndex } = await import("./marketplace.js");
565
+ const spinner = p.spinner();
566
+ spinner.start("Fetching marketplace index…");
567
+ let entries;
568
+ try {
569
+ entries = await fetchMarketplaceIndex();
570
+ spinner.stop(`Found ${entries.length} workflow(s)`);
571
+ }
572
+ catch (err) {
573
+ spinner.stop("Failed");
574
+ p.log.error(err instanceof Error ? err.message : String(err));
575
+ process.exit(1);
576
+ }
577
+ const pick = await p.select({
578
+ message: "Which workflow?",
579
+ options: entries.map((e) => ({
580
+ value: e.id,
581
+ label: e.name,
582
+ hint: e.description,
583
+ })),
584
+ });
585
+ if (p.isCancel(pick))
586
+ cancel();
587
+ // Delegate to the marketplace install path
588
+ // Wizard already showed its intro — skip the fast-path intro to avoid doubling.
589
+ return runNew({ marketplaceId: pick, skipIntro: true });
590
+ }
456
591
  // ── E2E short-circuit: delegate to the e2e wizard ────────────────────
457
592
  if (templateChoice === "__e2e") {
458
593
  await runE2eInit({ skipIntro: true });
@@ -519,60 +654,30 @@ export async function runNew() {
519
654
  process.exit(0);
520
655
  }
521
656
  // ── Step 4: Write files ─────────────────────────────────────────────
522
- // 1. Write .sweny.yml (only if fresh — never clobber existing config)
523
- if (!hasExistingConfig) {
524
- fs.writeFileSync(configPath, buildSwenyYml(sourceControl, observability, issueTracker), "utf-8");
657
+ // 1. Write .sweny.yml (only if fresh)
658
+ if (writeSwenyYmlIfMissing(cwd, sourceControl, observability, issueTracker)) {
525
659
  p.log.success("Created .sweny.yml");
526
660
  }
527
661
  else {
528
662
  p.log.info(".sweny.yml already exists — keeping existing config");
529
663
  }
530
- // 2. Write .env (append-only: safe for both fresh and existing repos)
531
- let addedNewKeys = false;
532
- const envPath = path.join(cwd, ".env");
533
- if (fs.existsSync(envPath)) {
534
- const existing = fs.readFileSync(envPath, "utf-8");
535
- // A key is "present" if it appears as either an active `KEY=...` line
536
- // or a commented `# KEY=...` placeholder. Either way appending the same
537
- // key would create a duplicate the user has to reconcile.
538
- const definedKeys = new Set();
539
- for (const rawLine of existing.split("\n")) {
540
- const line = rawLine.trimStart().replace(/^#\s*/, "");
541
- const eq = line.indexOf("=");
542
- if (eq <= 0)
543
- continue;
544
- const key = line.slice(0, eq).trim();
545
- if (/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
546
- definedKeys.add(key);
547
- }
548
- }
549
- const newKeys = [];
550
- for (const cred of credentials) {
551
- if (!definedKeys.has(cred.key)) {
552
- newKeys.push(cred);
553
- }
554
- }
555
- if (newKeys.length > 0) {
556
- const appendBlock = "\n" + buildEnvTemplate(newKeys);
557
- fs.appendFileSync(envPath, appendBlock, "utf-8");
558
- p.log.success(`Appended ${newKeys.length} new key(s) to .env`);
559
- addedNewKeys = true;
560
- }
561
- else {
562
- p.log.info(".env already contains all required keys — skipped");
563
- }
664
+ // 2. Append missing .env keys
665
+ const envExistedBefore = fs.existsSync(path.join(cwd, ".env"));
666
+ const addedCount = appendMissingEnvKeys(cwd, credentials);
667
+ const addedNewKeys = addedCount > 0;
668
+ if (!envExistedBefore) {
669
+ p.log.success("Created .env");
670
+ }
671
+ else if (addedCount > 0) {
672
+ p.log.success(`Appended ${addedCount} new key(s) to .env`);
564
673
  }
565
674
  else {
566
- fs.writeFileSync(envPath, buildEnvTemplate(credentials), "utf-8");
567
- p.log.success("Created .env");
568
- addedNewKeys = true;
675
+ p.log.info(".env already contains all required keys — skipped");
569
676
  }
570
- // 3. Workflow template (prompt per-file if it already exists)
677
+ // 3. Workflow template
571
678
  if (template) {
572
- const workflowDir = path.join(cwd, ".sweny", "workflows");
573
- fs.mkdirSync(workflowDir, { recursive: true });
574
- const templatePath = path.join(workflowDir, `${template.id}.yml`);
575
- if (fs.existsSync(templatePath)) {
679
+ const firstAttempt = writeWorkflowFile(cwd, template.id, template.yaml, { overwrite: false });
680
+ if (firstAttempt.exists) {
576
681
  const overwrite = await p.confirm({
577
682
  message: `.sweny/workflows/${template.id}.yml already exists. Overwrite?`,
578
683
  initialValue: false,
@@ -584,8 +689,8 @@ export async function runNew() {
584
689
  p.outro("Done.");
585
690
  return;
586
691
  }
692
+ writeWorkflowFile(cwd, template.id, template.yaml, { overwrite: true });
587
693
  }
588
- fs.writeFileSync(templatePath, template.yaml, "utf-8");
589
694
  p.log.success(`Created .sweny/workflows/${template.id}.yml`);
590
695
  }
591
696
  // 4. .gitignore check
@@ -1,9 +1,9 @@
1
- import { describe, it, expect, afterEach } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import * as os from "node:os";
5
5
  import { parse as parseYaml } from "yaml";
6
- import { collectCredentials, collectCredentialsForSkills, extractSkillsFromYaml, detectGitRemote, buildSwenyYml, buildEnvTemplate, buildActionWorkflow, PROVIDER_CREDENTIALS, SKILL_CREDENTIALS, } from "./new.js";
6
+ import { collectCredentials, collectCredentialsForSkills, extractSkillsFromYaml, detectGitRemote, buildSwenyYml, buildEnvTemplate, buildActionWorkflow, runNew, PROVIDER_CREDENTIALS, SKILL_CREDENTIALS, writeSwenyYmlIfMissing, appendMissingEnvKeys, writeWorkflowFile, } from "./new.js";
7
7
  // ── detectGitRemote ────────────────────────────────────────────────────
8
8
  describe("detectGitRemote", () => {
9
9
  const tmpDirs = [];
@@ -828,3 +828,216 @@ describe("SKILL_CREDENTIALS", () => {
828
828
  }
829
829
  });
830
830
  });
831
+ describe("writeSwenyYmlIfMissing", () => {
832
+ const tmpDirs = [];
833
+ afterEach(() => {
834
+ for (const d of tmpDirs)
835
+ fs.rmSync(d, { recursive: true, force: true });
836
+ tmpDirs.length = 0;
837
+ });
838
+ it("creates .sweny.yml when missing", () => {
839
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-writer-"));
840
+ tmpDirs.push(dir);
841
+ const created = writeSwenyYmlIfMissing(dir, "github", "datadog", "github-issues");
842
+ expect(created).toBe(true);
843
+ expect(fs.existsSync(path.join(dir, ".sweny.yml"))).toBe(true);
844
+ });
845
+ it("does not overwrite existing .sweny.yml", () => {
846
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-writer-"));
847
+ tmpDirs.push(dir);
848
+ fs.writeFileSync(path.join(dir, ".sweny.yml"), "custom: value\n");
849
+ const created = writeSwenyYmlIfMissing(dir, "github", null, "github-issues");
850
+ expect(created).toBe(false);
851
+ expect(fs.readFileSync(path.join(dir, ".sweny.yml"), "utf-8")).toBe("custom: value\n");
852
+ });
853
+ });
854
+ describe("appendMissingEnvKeys", () => {
855
+ const tmpDirs = [];
856
+ afterEach(() => {
857
+ for (const d of tmpDirs)
858
+ fs.rmSync(d, { recursive: true, force: true });
859
+ tmpDirs.length = 0;
860
+ });
861
+ it("creates .env from scratch", () => {
862
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-writer-"));
863
+ tmpDirs.push(dir);
864
+ const creds = [{ key: "FOO", hint: "set FOO" }];
865
+ const added = appendMissingEnvKeys(dir, creds);
866
+ expect(added).toBe(1);
867
+ expect(fs.readFileSync(path.join(dir, ".env"), "utf-8")).toContain("FOO=");
868
+ });
869
+ it("skips already-present keys (including commented placeholders)", () => {
870
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-writer-"));
871
+ tmpDirs.push(dir);
872
+ fs.writeFileSync(path.join(dir, ".env"), "# FOO=set-me\nBAR=baz\n");
873
+ const creds = [{ key: "FOO" }, { key: "BAR" }, { key: "QUUX" }];
874
+ const added = appendMissingEnvKeys(dir, creds);
875
+ expect(added).toBe(1);
876
+ const final = fs.readFileSync(path.join(dir, ".env"), "utf-8");
877
+ expect(final).toContain("QUUX=");
878
+ });
879
+ });
880
+ describe("writeWorkflowFile", () => {
881
+ const tmpDirs = [];
882
+ afterEach(() => {
883
+ for (const d of tmpDirs)
884
+ fs.rmSync(d, { recursive: true, force: true });
885
+ tmpDirs.length = 0;
886
+ });
887
+ it("writes a new workflow file", () => {
888
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-writer-"));
889
+ tmpDirs.push(dir);
890
+ const result = writeWorkflowFile(dir, "pr-review", "id: pr-review\n");
891
+ expect(result).toEqual({ written: true, path: path.join(dir, ".sweny", "workflows", "pr-review.yml") });
892
+ expect(fs.existsSync(result.path)).toBe(true);
893
+ });
894
+ it("returns exists=true when file already present and overwrite=false", () => {
895
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-writer-"));
896
+ tmpDirs.push(dir);
897
+ fs.mkdirSync(path.join(dir, ".sweny", "workflows"), { recursive: true });
898
+ fs.writeFileSync(path.join(dir, ".sweny", "workflows", "pr-review.yml"), "old\n");
899
+ const result = writeWorkflowFile(dir, "pr-review", "new\n", { overwrite: false });
900
+ expect(result).toEqual({ written: false, exists: true, path: expect.any(String) });
901
+ expect(fs.readFileSync(result.path, "utf-8")).toBe("old\n");
902
+ });
903
+ it("overwrites when overwrite=true", () => {
904
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-writer-"));
905
+ tmpDirs.push(dir);
906
+ fs.mkdirSync(path.join(dir, ".sweny", "workflows"), { recursive: true });
907
+ fs.writeFileSync(path.join(dir, ".sweny", "workflows", "pr-review.yml"), "old\n");
908
+ const result = writeWorkflowFile(dir, "pr-review", "new\n", { overwrite: true });
909
+ expect(result).toEqual({ written: true, path: expect.any(String) });
910
+ expect(fs.readFileSync(result.path, "utf-8")).toBe("new\n");
911
+ });
912
+ });
913
+ vi.mock("./marketplace.js", async () => {
914
+ const actual = await vi.importActual("./marketplace.js");
915
+ return {
916
+ ...actual,
917
+ installMarketplaceWorkflow: vi.fn(),
918
+ fetchMarketplaceIndex: vi.fn(),
919
+ };
920
+ });
921
+ vi.mock("@clack/prompts", async () => {
922
+ const actual = await vi.importActual("@clack/prompts");
923
+ return {
924
+ ...actual,
925
+ intro: vi.fn(),
926
+ outro: vi.fn(),
927
+ cancel: vi.fn(),
928
+ isCancel: vi.fn(() => false),
929
+ select: vi.fn(),
930
+ confirm: vi.fn(),
931
+ text: vi.fn(),
932
+ spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
933
+ log: {
934
+ info: vi.fn(),
935
+ success: vi.fn(),
936
+ warn: vi.fn(),
937
+ error: vi.fn(),
938
+ message: vi.fn(),
939
+ },
940
+ note: vi.fn(),
941
+ };
942
+ });
943
+ describe("runNew with marketplaceId", () => {
944
+ const tmpDirs = [];
945
+ afterEach(() => {
946
+ for (const d of tmpDirs)
947
+ fs.rmSync(d, { recursive: true, force: true });
948
+ tmpDirs.length = 0;
949
+ vi.restoreAllMocks();
950
+ vi.clearAllMocks();
951
+ });
952
+ function tmp() {
953
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-new-mkt-"));
954
+ tmpDirs.push(dir);
955
+ return dir;
956
+ }
957
+ it("calls installMarketplaceWorkflow with pre-computed provider fields and overwrite:false when file absent", async () => {
958
+ const cwd = tmp();
959
+ vi.spyOn(process, "cwd").mockReturnValue(cwd);
960
+ const mkt = await import("./marketplace.js");
961
+ mkt.installMarketplaceWorkflow.mockResolvedValueOnce({
962
+ installed: true,
963
+ adapted: false,
964
+ workflowPath: path.join(cwd, ".sweny", "workflows", "pr-review.yml"),
965
+ mismatches: [],
966
+ addedEnvKeys: 0,
967
+ });
968
+ await runNew({ marketplaceId: "pr-review" });
969
+ expect(mkt.installMarketplaceWorkflow).toHaveBeenCalledWith("pr-review", expect.objectContaining({
970
+ cwd,
971
+ inferredSourceControl: expect.any(String),
972
+ overwrite: false,
973
+ }));
974
+ // inferredIssueTracker / inferredObservability are skill-based — runNew
975
+ // must NOT pre-compute them (it has no skills list), so marketplace.ts
976
+ // can fall back to skill-based inference after fetching the workflow.
977
+ const callArgs = mkt.installMarketplaceWorkflow.mock.calls[0][1];
978
+ expect("inferredIssueTracker" in callArgs).toBe(false);
979
+ expect("inferredObservability" in callArgs).toBe(false);
980
+ });
981
+ it("passes overwrite:true to installMarketplaceWorkflow when user confirms overwrite", async () => {
982
+ const cwd = tmp();
983
+ vi.spyOn(process, "cwd").mockReturnValue(cwd);
984
+ // Pre-create the workflow file so the exists check triggers
985
+ const wfDir = path.join(cwd, ".sweny", "workflows");
986
+ fs.mkdirSync(wfDir, { recursive: true });
987
+ fs.writeFileSync(path.join(wfDir, "pr-review.yml"), "# existing\n", "utf-8");
988
+ const mkt = await import("./marketplace.js");
989
+ const p = await import("@clack/prompts");
990
+ mkt.installMarketplaceWorkflow.mockResolvedValueOnce({
991
+ installed: true,
992
+ adapted: false,
993
+ workflowPath: path.join(wfDir, "pr-review.yml"),
994
+ mismatches: [],
995
+ addedEnvKeys: 0,
996
+ });
997
+ // User confirms overwrite
998
+ p.confirm.mockResolvedValueOnce(true);
999
+ await runNew({ marketplaceId: "pr-review" });
1000
+ expect(p.confirm).toHaveBeenCalled();
1001
+ expect(mkt.installMarketplaceWorkflow).toHaveBeenCalledWith("pr-review", expect.objectContaining({ overwrite: true }));
1002
+ });
1003
+ it("exits without installing when user declines overwrite for existing workflow", async () => {
1004
+ const cwd = tmp();
1005
+ vi.spyOn(process, "cwd").mockReturnValue(cwd);
1006
+ // Pre-create the workflow file so the exists check triggers
1007
+ const wfDir = path.join(cwd, ".sweny", "workflows");
1008
+ fs.mkdirSync(wfDir, { recursive: true });
1009
+ fs.writeFileSync(path.join(wfDir, "pr-review.yml"), "# existing\n", "utf-8");
1010
+ const mkt = await import("./marketplace.js");
1011
+ const p = await import("@clack/prompts");
1012
+ // User declines overwrite
1013
+ p.confirm.mockResolvedValueOnce(false);
1014
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation((_code) => {
1015
+ throw new Error("process.exit called");
1016
+ });
1017
+ await expect(runNew({ marketplaceId: "pr-review" })).rejects.toThrow("process.exit called");
1018
+ expect(exitSpy).toHaveBeenCalledWith(0);
1019
+ // installMarketplaceWorkflow must NOT have been called
1020
+ expect(mkt.installMarketplaceWorkflow).not.toHaveBeenCalled();
1021
+ });
1022
+ });
1023
+ describe("runNew wizard — browse marketplace branch", () => {
1024
+ afterEach(() => vi.restoreAllMocks());
1025
+ it("fetches index and delegates to installMarketplaceWorkflow when user picks a marketplace entry", async () => {
1026
+ const p = await import("@clack/prompts");
1027
+ const mkt = await import("./marketplace.js");
1028
+ p.select.mockResolvedValueOnce("__marketplace").mockResolvedValueOnce("pr-review");
1029
+ mkt.fetchMarketplaceIndex.mockResolvedValueOnce([
1030
+ { id: "pr-review", name: "PR Review", description: "Review PRs", skills: ["github"] },
1031
+ ]);
1032
+ mkt.installMarketplaceWorkflow.mockResolvedValueOnce({
1033
+ installed: true,
1034
+ adapted: false,
1035
+ workflowPath: "/tmp/.sweny/workflows/pr-review.yml",
1036
+ mismatches: [],
1037
+ addedEnvKeys: 0,
1038
+ });
1039
+ await runNew();
1040
+ expect(mkt.fetchMarketplaceIndex).toHaveBeenCalled();
1041
+ expect(mkt.installMarketplaceWorkflow).toHaveBeenCalledWith("pr-review", expect.any(Object));
1042
+ });
1043
+ });
package/dist/executor.js CHANGED
@@ -13,7 +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
+ import { resolveSources } from "./source-resolver.js";
17
17
  /**
18
18
  * Execute a workflow from entry to completion.
19
19
  *
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Source resolution (Node-only).
3
+ *
4
+ * Runtime helpers that read files, hash content, and fetch URLs. These are
5
+ * separated from `./sources.ts` so the browser-safe bits of the Source type
6
+ * (Zod schema, classifier, types) can be imported without pulling `node:fs`
7
+ * or `node:crypto` into bundlers that target the browser.
8
+ */
9
+ import type { ResolvedSource, Source, SourceResolutionContext, SourceResolutionMap } from "./sources.js";
10
+ /**
11
+ * Produce a stable 16-hex-char hash of `content` for use as a cache key /
12
+ * change-detection ID.
13
+ *
14
+ * Intentionally avoids `node:crypto` so this module can be bundled for the
15
+ * browser (studio SPA's in-browser simulator reaches this via the inline
16
+ * Source resolver). `cyrb64` is a well-known non-crypto 64-bit hash — fine
17
+ * for cache keys, not fine for security. Deterministic across Node and
18
+ * browser (both operate on UTF-16 code units).
19
+ */
20
+ export declare function hashContent(content: string): string;
21
+ export declare function resolveSource(source: Source, fieldPath: string, ctx: SourceResolutionContext): Promise<ResolvedSource>;
22
+ export declare function resolveSources(sources: Record<string, Source>, ctx: SourceResolutionContext): Promise<SourceResolutionMap>;