@apicircle/cli 1.0.3 → 1.0.4

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/index.js CHANGED
@@ -114,7 +114,7 @@ async function ensureWorkspace(dir) {
114
114
  linkedWorkspaces: {},
115
115
  linkedOverrides: { requests: {}, environmentVars: {} },
116
116
  releases: { self: null, perLink: {} },
117
- globalAssets: { schemas: {}, graphql: {} },
117
+ globalAssets: { schemas: {}, graphql: {}, files: {} },
118
118
  mockServers: {},
119
119
  meta: { createdAt: now, updatedAt: now, appVersion: "1.0.0" }
120
120
  },
@@ -131,13 +131,14 @@ async function ensureWorkspace(dir) {
131
131
  retiredBranch: null,
132
132
  sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] },
133
133
  linkedCollections: {},
134
+ attachmentCache: {},
134
135
  globalContext: {},
135
136
  mockRuntime: { active: {} },
136
137
  ui: {
137
138
  activeRequestId: null,
138
139
  sidebarExpandedSections: [],
139
- themeId: "studio-dark",
140
- fontId: "system-mono",
140
+ themeId: "one-dark-pro",
141
+ fontId: "system-sans",
141
142
  fontSizePercent: FONT_SIZE_PERCENT_DEFAULT
142
143
  },
143
144
  settings: { validateOnSend: true, monacoConsumesWheel: false },
@@ -341,7 +342,7 @@ function buildEmptyState(workspaceId, now, withSample) {
341
342
  linkedWorkspaces: {},
342
343
  linkedOverrides: { requests: {}, environmentVars: {} },
343
344
  releases: { self: null, perLink: {} },
344
- globalAssets: { schemas: {}, graphql: {} },
345
+ globalAssets: { schemas: {}, graphql: {}, files: {} },
345
346
  mockServers: {},
346
347
  meta: { createdAt: now, updatedAt: now, appVersion: "1.0.0" }
347
348
  },
@@ -358,13 +359,14 @@ function buildEmptyState(workspaceId, now, withSample) {
358
359
  retiredBranch: null,
359
360
  sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] },
360
361
  linkedCollections: {},
362
+ attachmentCache: {},
361
363
  globalContext: {},
362
364
  mockRuntime: { active: {} },
363
365
  ui: {
364
366
  activeRequestId: sample?.id ?? null,
365
367
  sidebarExpandedSections: [],
366
- themeId: "studio-dark",
367
- fontId: "system-mono",
368
+ themeId: "one-dark-pro",
369
+ fontId: "system-sans",
368
370
  fontSizePercent: 100
369
371
  },
370
372
  settings: { validateOnSend: true, monacoConsumesWheel: false },
@@ -535,13 +537,13 @@ function registerImportCommand(program) {
535
537
  }
536
538
  async function readInput(p) {
537
539
  if (p === "-") {
538
- return new Promise((resolve6, reject) => {
540
+ return new Promise((resolve7, reject) => {
539
541
  let data = "";
540
542
  process.stdin.setEncoding("utf-8");
541
543
  process.stdin.on("data", (chunk) => {
542
544
  data += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
543
545
  });
544
- process.stdin.on("end", () => resolve6(data));
546
+ process.stdin.on("end", () => resolve7(data));
545
547
  process.stdin.on("error", reject);
546
548
  });
547
549
  }
@@ -608,6 +610,274 @@ async function buildSecretsFromCli(options = {}) {
608
610
  return { byId };
609
611
  }
610
612
 
613
+ // src/util/executionAttachments.ts
614
+ import { createHash } from "crypto";
615
+ import { promises as fs6 } from "fs";
616
+ import * as path6 from "path";
617
+ import {
618
+ collectAttachmentSlots
619
+ } from "@apicircle/core";
620
+ var ATTACHMENTS_DIR = path6.join(".apicircle", "attachments");
621
+ async function prepareExecutionAttachments(workspaceDir, state, plan) {
622
+ const cacheDir = path6.resolve(workspaceDir, ATTACHMENTS_DIR);
623
+ const requirements = collectExecutionAttachmentRequirements(state, plan);
624
+ await fs6.mkdir(cacheDir, { recursive: true });
625
+ let downloaded = 0;
626
+ let alreadyPresent = 0;
627
+ let failed = 0;
628
+ const cache = {
629
+ ...state.local.attachmentCache ?? {}
630
+ };
631
+ const entries = [];
632
+ for (const requirement of requirements) {
633
+ const localPath = path6.join(cacheDir, encodeURIComponent(requirement.slotId));
634
+ const present = await hasExpectedFile(localPath, requirement.sha256);
635
+ if (present) {
636
+ alreadyPresent++;
637
+ } else {
638
+ try {
639
+ const bytes = await downloadAttachment(requirement);
640
+ if (!bytes) {
641
+ throw new Error(
642
+ `Attachment ${attachmentLabel(requirement)} was not found in ${sourceLabel(requirement)}.`
643
+ );
644
+ }
645
+ if (requirement.sha256 && sha256Hex(bytes) !== requirement.sha256) {
646
+ throw new Error(
647
+ `Attachment ${attachmentLabel(requirement)} failed checksum verification.`
648
+ );
649
+ }
650
+ await fs6.writeFile(localPath, bytes, { mode: 384 });
651
+ downloaded++;
652
+ } catch (err) {
653
+ failed++;
654
+ throw new Error(
655
+ `Attachment ${attachmentLabel(requirement)} is required by ${requiredByLabel(
656
+ requirement
657
+ )} but could not be downloaded from ${sourceLabel(requirement)}: ${err instanceof Error ? err.message : String(err)}`
658
+ );
659
+ }
660
+ }
661
+ cache[requirement.slotId] = {
662
+ slotId: requirement.slotId,
663
+ filename: requirement.filename ?? requirement.slotId,
664
+ mimeType: requirement.mimeType ?? "application/octet-stream",
665
+ size: requirement.size ?? await fileSize(localPath),
666
+ sha256: requirement.sha256,
667
+ localPath,
668
+ storage: "filesystem",
669
+ source: requirement.source,
670
+ ...requirement.linkedWorkspaceId ? { linkedWorkspaceId: requirement.linkedWorkspaceId } : {},
671
+ requiredBy: requirement.requiredBy,
672
+ downloadedAt: (/* @__PURE__ */ new Date()).toISOString()
673
+ };
674
+ entries.push({
675
+ slotId: requirement.slotId,
676
+ filename: requirement.filename ?? requirement.slotId,
677
+ localPath,
678
+ source: requirement.source,
679
+ ...requirement.linkedWorkspaceId ? { linkedWorkspaceId: requirement.linkedWorkspaceId } : {},
680
+ requiredBy: requirement.requiredBy
681
+ });
682
+ }
683
+ const nextState = {
684
+ ...state,
685
+ local: {
686
+ ...state.local,
687
+ attachmentCache: cache
688
+ }
689
+ };
690
+ return {
691
+ state: nextState,
692
+ resolveAttachment: createFileAttachmentResolver(nextState),
693
+ summary: {
694
+ total: requirements.length,
695
+ downloaded,
696
+ alreadyPresent,
697
+ failed,
698
+ cacheDir,
699
+ entries
700
+ }
701
+ };
702
+ }
703
+ function createFileAttachmentResolver(state) {
704
+ return async (slotId) => {
705
+ const meta = state.local.attachmentCache?.[slotId];
706
+ if (!meta) return null;
707
+ const bytes = await fs6.readFile(meta.localPath);
708
+ const view = new Uint8Array(bytes);
709
+ const body = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
710
+ return {
711
+ blob: new Blob([body], { type: meta.mimeType }),
712
+ filename: meta.filename
713
+ };
714
+ };
715
+ }
716
+ function collectExecutionAttachmentRequirements(state, plan) {
717
+ const seen = /* @__PURE__ */ new Map();
718
+ const localRequestFilter = requestFilterForPlan(plan, null);
719
+ const localCollections = localRequestFilter ? {
720
+ ...state.synced.collections,
721
+ requests: Object.fromEntries(
722
+ Object.entries(state.synced.collections.requests).filter(
723
+ ([id]) => localRequestFilter.has(id)
724
+ )
725
+ )
726
+ } : state.synced.collections;
727
+ const workspaceSlots = collectAttachmentSlots({ ...state.synced, collections: localCollections });
728
+ for (const slot of workspaceSlots) {
729
+ const requiredBy = collectRequiredBy(localCollections.requests, slot.slotId);
730
+ if (requiredBy.length === 0) continue;
731
+ addRequirement(seen, {
732
+ ...slot,
733
+ source: "workspace",
734
+ repoFullName: state.local.connectedRepo?.fullName ?? void 0,
735
+ branch: state.local.workingBranch?.name ?? void 0,
736
+ publicRepo: state.local.connectedRepo ? !state.local.connectedRepo.isPrivate : false,
737
+ requiredBy
738
+ });
739
+ }
740
+ for (const [linkedWorkspaceId, snapshot] of Object.entries(state.local.linkedCollections)) {
741
+ const link = state.synced.linkedWorkspaces[linkedWorkspaceId];
742
+ if (!link) continue;
743
+ const linkedRequestFilter = requestFilterForPlan(plan, linkedWorkspaceId);
744
+ if (plan && linkedRequestFilter && linkedRequestFilter.size === 0) continue;
745
+ const linkedCollections = linkedRequestFilter ? {
746
+ ...snapshot.collections,
747
+ requests: Object.fromEntries(
748
+ Object.entries(snapshot.collections.requests).filter(
749
+ ([id]) => linkedRequestFilter.has(id)
750
+ )
751
+ )
752
+ } : snapshot.collections;
753
+ const linkedSynced = {
754
+ ...state.synced,
755
+ collections: linkedCollections,
756
+ environments: snapshot.environments,
757
+ globalAssets: snapshot.globalAssets ?? state.synced.globalAssets
758
+ };
759
+ for (const slot of collectAttachmentSlots(linkedSynced)) {
760
+ const requiredBy = collectRequiredBy(linkedCollections.requests, slot.slotId);
761
+ if (requiredBy.length === 0) continue;
762
+ addRequirement(seen, {
763
+ ...slot,
764
+ source: "linked-workspace",
765
+ linkedWorkspaceId,
766
+ repoFullName: link.source.repoFullName,
767
+ branch: link.source.branch,
768
+ publicRepo: link.kind === "public",
769
+ requiredBy
770
+ });
771
+ }
772
+ }
773
+ return [...seen.values()];
774
+ }
775
+ function requestFilterForPlan(plan, linkedWorkspaceId) {
776
+ if (!plan) return null;
777
+ const ids = /* @__PURE__ */ new Set();
778
+ for (const step of plan.steps) {
779
+ if (step.enabled === false) continue;
780
+ if ((step.linkedWorkspaceId ?? null) === linkedWorkspaceId) ids.add(step.requestId);
781
+ }
782
+ return ids;
783
+ }
784
+ function addRequirement(seen, requirement) {
785
+ const existing = seen.get(requirement.slotId);
786
+ if (!existing) {
787
+ seen.set(requirement.slotId, requirement);
788
+ return;
789
+ }
790
+ for (const usage of requirement.requiredBy) {
791
+ if (!existing.requiredBy.some((item) => item.requestId === usage.requestId)) {
792
+ existing.requiredBy.push(usage);
793
+ }
794
+ }
795
+ }
796
+ function collectRequiredBy(requests, slotId) {
797
+ const requiredBy = [];
798
+ for (const request of Object.values(requests)) {
799
+ if (bodyReferencesSlot(request.body, slotId)) {
800
+ requiredBy.push({ requestId: request.id, requestName: request.name });
801
+ }
802
+ }
803
+ return requiredBy;
804
+ }
805
+ function bodyReferencesSlot(body, slotId) {
806
+ if (body.type === "binary") return body.attachment?.slotId === slotId;
807
+ if (body.type !== "form-data") return false;
808
+ return (body.formRows ?? []).some((row) => row.kind === "file" && row.slotId === slotId);
809
+ }
810
+ async function hasExpectedFile(localPath, sha256) {
811
+ try {
812
+ const bytes = await fs6.readFile(localPath);
813
+ if (!sha256) return true;
814
+ return sha256Hex(bytes) === sha256;
815
+ } catch {
816
+ return false;
817
+ }
818
+ }
819
+ async function fileSize(localPath) {
820
+ try {
821
+ return (await fs6.stat(localPath)).size;
822
+ } catch {
823
+ return 0;
824
+ }
825
+ }
826
+ async function downloadAttachment(requirement) {
827
+ if (!requirement.repoFullName || !requirement.branch) return null;
828
+ const [owner, repo] = requirement.repoFullName.split("/", 2);
829
+ if (!owner || !repo) return null;
830
+ const token = resolveGitHubToken(requirement);
831
+ if (!token && !requirement.publicRepo) {
832
+ throw new Error(
833
+ "private linked attachments need a GitHub token (set APICIRCLE_GITHUB_TOKEN or GITHUB_TOKEN)"
834
+ );
835
+ }
836
+ const apiPath = [".apicircle", "attachments", requirement.slotId].map(encodeURIComponent).join("/");
837
+ const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
838
+ repo
839
+ )}/contents/${apiPath}?ref=${encodeURIComponent(requirement.branch)}`;
840
+ const headers = {
841
+ Accept: "application/vnd.github+json",
842
+ "User-Agent": "apicircle-cli",
843
+ "X-GitHub-Api-Version": "2022-11-28"
844
+ };
845
+ if (token) headers.Authorization = `Bearer ${token}`;
846
+ const res = await fetch(url, { headers, cache: "no-store" });
847
+ if (res.status === 404) return null;
848
+ if (!res.ok) {
849
+ throw new Error(`GitHub returned ${res.status}: ${await res.text()}`);
850
+ }
851
+ const json = await res.json();
852
+ if (json.type !== "file" || typeof json.content !== "string") {
853
+ throw new Error("GitHub response was not a file");
854
+ }
855
+ if (json.encoding !== "base64") {
856
+ throw new Error(`GitHub response used unsupported encoding ${json.encoding ?? "(missing)"}`);
857
+ }
858
+ return new Uint8Array(Buffer.from(json.content.replace(/\n/g, ""), "base64"));
859
+ }
860
+ function resolveGitHubToken(requirement) {
861
+ if (requirement.source === "linked-workspace") {
862
+ return process.env.APICIRCLE_E2E_BOT_PAT_LINK_DEDICATED ?? process.env.APICIRCLE_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.APICIRCLE_E2E_GITHUB_PAT ?? process.env.APICIRCLE_E2E_BOT_PAT ?? "";
863
+ }
864
+ return process.env.APICIRCLE_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.APICIRCLE_E2E_GITHUB_PAT ?? process.env.APICIRCLE_E2E_BOT_PAT ?? "";
865
+ }
866
+ function sha256Hex(bytes) {
867
+ return createHash("sha256").update(bytes).digest("hex");
868
+ }
869
+ function attachmentLabel(requirement) {
870
+ return `${requirement.filename ?? requirement.slotId} (${requirement.slotId})`;
871
+ }
872
+ function sourceLabel(requirement) {
873
+ const repo = requirement.repoFullName ?? "local workspace";
874
+ const branch = requirement.branch ? `@${requirement.branch}` : "";
875
+ return `${repo}${branch}`;
876
+ }
877
+ function requiredByLabel(requirement) {
878
+ return requirement.requiredBy.map((item) => item.requestName).join(", ") || "a request";
879
+ }
880
+
611
881
  // src/commands/run.ts
612
882
  var REPORTERS = ["text", "json", "junit"];
613
883
  function registerRunCommand(program) {
@@ -681,15 +951,27 @@ function registerRunCommand(program) {
681
951
  const onSigint = () => controller.abort(new Error("aborted by SIGINT"));
682
952
  process.on("SIGINT", onSigint);
683
953
  if (text) process.stdout.write(formatHeader(ref.plan, actor, withAssertions, opts));
954
+ let prepared;
955
+ try {
956
+ prepared = await prepareExecutionAttachments(dir, state, ref.plan);
957
+ } catch (err) {
958
+ process.off("SIGINT", onSigint);
959
+ fail(err instanceof Error ? err.message : String(err), 1, "attachment");
960
+ return;
961
+ }
962
+ if (text && prepared.summary.total > 0) {
963
+ process.stdout.write(formatAttachmentPreparation(prepared.summary));
964
+ }
684
965
  let result;
685
966
  try {
686
- result = await runPlan(state, ref.id, {
967
+ result = await runPlan(prepared.state, ref.id, {
687
968
  withAssertions,
688
969
  bail: opts.bail === true,
689
970
  env: opts.env,
690
971
  secretsById,
691
972
  actor,
692
973
  signal: controller.signal,
974
+ resolveAttachment: prepared.resolveAttachment,
693
975
  authorize: checkRunPermission,
694
976
  onStep: text ? (step) => process.stdout.write(formatStepLine(step)) : void 0
695
977
  });
@@ -708,7 +990,7 @@ function registerRunCommand(program) {
708
990
  if (reporter === "json") {
709
991
  process.stdout.write(
710
992
  JSON.stringify(
711
- buildJsonReport(dir, ref.id, ref.plan, actor, result, saved, aborted),
993
+ buildJsonReport(dir, ref.id, ref.plan, actor, result, saved, aborted, prepared.summary),
712
994
  null,
713
995
  2
714
996
  ) + "\n"
@@ -750,6 +1032,26 @@ function formatHeader(plan, actor, withAssertions, opts) {
750
1032
  )}
751
1033
  ${kleur4.dim("Run by")} ${actor.name} ${kleur4.dim(`(${actor.kind})`)}
752
1034
 
1035
+ `;
1036
+ }
1037
+ function formatAttachmentPreparation(summary) {
1038
+ const status = `${summary.downloaded} downloaded, ${summary.alreadyPresent} already local`;
1039
+ const lines = [
1040
+ `${kleur4.bold("Attachments")} ${summary.total} required ${kleur4.dim(
1041
+ `(${status} - ${summary.cacheDir})`
1042
+ )}`
1043
+ ];
1044
+ for (const entry2 of summary.entries) {
1045
+ const source = entry2.source === "linked-workspace" ? `linked:${entry2.linkedWorkspaceId ?? "unknown"}` : "workspace";
1046
+ const requiredBy = entry2.requiredBy.map((item) => item.requestName).join(", ");
1047
+ lines.push(
1048
+ ` ${kleur4.dim("file")} ${entry2.filename} ${kleur4.dim(
1049
+ `${source} - ${requiredBy} - ${entry2.localPath}`
1050
+ )}`
1051
+ );
1052
+ }
1053
+ return `${lines.join("\n")}
1054
+
753
1055
  `;
754
1056
  }
755
1057
  function formatStepLine(step) {
@@ -819,7 +1121,7 @@ ${verdict} ${parts.join(kleur4.dim(" \xB7 "))} ${kleur4.dim(
819
1121
  out += saved ? kleur4.dim("Plan run saved to workspace history.\n") : kleur4.dim("Plan run not saved (--no-save).\n");
820
1122
  return out;
821
1123
  }
822
- function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted) {
1124
+ function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted, attachments) {
823
1125
  return {
824
1126
  workspace,
825
1127
  plan: { id: planId, name: plan.name },
@@ -829,6 +1131,7 @@ function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted)
829
1131
  aborted,
830
1132
  durationMs: result.planRun.durationMs,
831
1133
  saved,
1134
+ attachments,
832
1135
  counts: tally(result),
833
1136
  steps: result.steps.map((s) => ({
834
1137
  step: s.stepIndex + 1,