@apicircle/cli 1.0.2 → 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/README.md CHANGED
@@ -166,6 +166,10 @@ apicircle run "Nightly" --secrets ./secrets.json --no-save
166
166
  Resolves a plan by name or id, runs each step through the **real** request
167
167
  engine (same auth, same retries, same assertions as the desktop app),
168
168
  evaluates assertions, and carries extracted context forward between steps.
169
+ If a request or linked-workspace step needs Global Assets file bytes that are
170
+ missing on this machine, the CLI downloads the attachment blobs from GitHub,
171
+ verifies their checksums, and only then starts the request. A checksum mismatch
172
+ fails closed instead of sending a partial upload.
169
173
 
170
174
  **Flags:**
171
175
 
package/dist/index.cjs CHANGED
@@ -141,7 +141,7 @@ async function ensureWorkspace(dir) {
141
141
  linkedWorkspaces: {},
142
142
  linkedOverrides: { requests: {}, environmentVars: {} },
143
143
  releases: { self: null, perLink: {} },
144
- globalAssets: { schemas: {}, graphql: {} },
144
+ globalAssets: { schemas: {}, graphql: {}, files: {} },
145
145
  mockServers: {},
146
146
  meta: { createdAt: now, updatedAt: now, appVersion: "1.0.0" }
147
147
  },
@@ -158,13 +158,14 @@ async function ensureWorkspace(dir) {
158
158
  retiredBranch: null,
159
159
  sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] },
160
160
  linkedCollections: {},
161
+ attachmentCache: {},
161
162
  globalContext: {},
162
163
  mockRuntime: { active: {} },
163
164
  ui: {
164
165
  activeRequestId: null,
165
166
  sidebarExpandedSections: [],
166
- themeId: "studio-dark",
167
- fontId: "system-mono",
167
+ themeId: "one-dark-pro",
168
+ fontId: "system-sans",
168
169
  fontSizePercent: import_shared2.FONT_SIZE_PERCENT_DEFAULT
169
170
  },
170
171
  settings: { validateOnSend: true, monacoConsumesWheel: false },
@@ -362,7 +363,7 @@ function buildEmptyState(workspaceId, now, withSample) {
362
363
  linkedWorkspaces: {},
363
364
  linkedOverrides: { requests: {}, environmentVars: {} },
364
365
  releases: { self: null, perLink: {} },
365
- globalAssets: { schemas: {}, graphql: {} },
366
+ globalAssets: { schemas: {}, graphql: {}, files: {} },
366
367
  mockServers: {},
367
368
  meta: { createdAt: now, updatedAt: now, appVersion: "1.0.0" }
368
369
  },
@@ -379,13 +380,14 @@ function buildEmptyState(workspaceId, now, withSample) {
379
380
  retiredBranch: null,
380
381
  sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] },
381
382
  linkedCollections: {},
383
+ attachmentCache: {},
382
384
  globalContext: {},
383
385
  mockRuntime: { active: {} },
384
386
  ui: {
385
387
  activeRequestId: sample?.id ?? null,
386
388
  sidebarExpandedSections: [],
387
- themeId: "studio-dark",
388
- fontId: "system-mono",
389
+ themeId: "one-dark-pro",
390
+ fontId: "system-sans",
389
391
  fontSizePercent: 100
390
392
  },
391
393
  settings: { validateOnSend: true, monacoConsumesWheel: false },
@@ -552,13 +554,13 @@ function registerImportCommand(program) {
552
554
  }
553
555
  async function readInput(p) {
554
556
  if (p === "-") {
555
- return new Promise((resolve6, reject) => {
557
+ return new Promise((resolve7, reject) => {
556
558
  let data = "";
557
559
  process.stdin.setEncoding("utf-8");
558
560
  process.stdin.on("data", (chunk) => {
559
561
  data += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
560
562
  });
561
- process.stdin.on("end", () => resolve6(data));
563
+ process.stdin.on("end", () => resolve7(data));
562
564
  process.stdin.on("error", reject);
563
565
  });
564
566
  }
@@ -585,7 +587,7 @@ function blankRequest(partial) {
585
587
  // src/commands/run.ts
586
588
  var os2 = __toESM(require("os"), 1);
587
589
  var import_kleur4 = __toESM(require("kleur"), 1);
588
- var import_core2 = require("@apicircle/core");
590
+ var import_core3 = require("@apicircle/core");
589
591
  var import_file_backed4 = require("@apicircle/core/workspace/file-backed");
590
592
 
591
593
  // src/util/secrets.ts
@@ -620,6 +622,272 @@ async function buildSecretsFromCli(options = {}) {
620
622
  return { byId };
621
623
  }
622
624
 
625
+ // src/util/executionAttachments.ts
626
+ var import_node_crypto = require("crypto");
627
+ var import_node_fs6 = require("fs");
628
+ var path6 = __toESM(require("path"), 1);
629
+ var import_core2 = require("@apicircle/core");
630
+ var ATTACHMENTS_DIR = path6.join(".apicircle", "attachments");
631
+ async function prepareExecutionAttachments(workspaceDir, state, plan) {
632
+ const cacheDir = path6.resolve(workspaceDir, ATTACHMENTS_DIR);
633
+ const requirements = collectExecutionAttachmentRequirements(state, plan);
634
+ await import_node_fs6.promises.mkdir(cacheDir, { recursive: true });
635
+ let downloaded = 0;
636
+ let alreadyPresent = 0;
637
+ let failed = 0;
638
+ const cache = {
639
+ ...state.local.attachmentCache ?? {}
640
+ };
641
+ const entries = [];
642
+ for (const requirement of requirements) {
643
+ const localPath = path6.join(cacheDir, encodeURIComponent(requirement.slotId));
644
+ const present = await hasExpectedFile(localPath, requirement.sha256);
645
+ if (present) {
646
+ alreadyPresent++;
647
+ } else {
648
+ try {
649
+ const bytes = await downloadAttachment(requirement);
650
+ if (!bytes) {
651
+ throw new Error(
652
+ `Attachment ${attachmentLabel(requirement)} was not found in ${sourceLabel(requirement)}.`
653
+ );
654
+ }
655
+ if (requirement.sha256 && sha256Hex(bytes) !== requirement.sha256) {
656
+ throw new Error(
657
+ `Attachment ${attachmentLabel(requirement)} failed checksum verification.`
658
+ );
659
+ }
660
+ await import_node_fs6.promises.writeFile(localPath, bytes, { mode: 384 });
661
+ downloaded++;
662
+ } catch (err) {
663
+ failed++;
664
+ throw new Error(
665
+ `Attachment ${attachmentLabel(requirement)} is required by ${requiredByLabel(
666
+ requirement
667
+ )} but could not be downloaded from ${sourceLabel(requirement)}: ${err instanceof Error ? err.message : String(err)}`
668
+ );
669
+ }
670
+ }
671
+ cache[requirement.slotId] = {
672
+ slotId: requirement.slotId,
673
+ filename: requirement.filename ?? requirement.slotId,
674
+ mimeType: requirement.mimeType ?? "application/octet-stream",
675
+ size: requirement.size ?? await fileSize(localPath),
676
+ sha256: requirement.sha256,
677
+ localPath,
678
+ storage: "filesystem",
679
+ source: requirement.source,
680
+ ...requirement.linkedWorkspaceId ? { linkedWorkspaceId: requirement.linkedWorkspaceId } : {},
681
+ requiredBy: requirement.requiredBy,
682
+ downloadedAt: (/* @__PURE__ */ new Date()).toISOString()
683
+ };
684
+ entries.push({
685
+ slotId: requirement.slotId,
686
+ filename: requirement.filename ?? requirement.slotId,
687
+ localPath,
688
+ source: requirement.source,
689
+ ...requirement.linkedWorkspaceId ? { linkedWorkspaceId: requirement.linkedWorkspaceId } : {},
690
+ requiredBy: requirement.requiredBy
691
+ });
692
+ }
693
+ const nextState = {
694
+ ...state,
695
+ local: {
696
+ ...state.local,
697
+ attachmentCache: cache
698
+ }
699
+ };
700
+ return {
701
+ state: nextState,
702
+ resolveAttachment: createFileAttachmentResolver(nextState),
703
+ summary: {
704
+ total: requirements.length,
705
+ downloaded,
706
+ alreadyPresent,
707
+ failed,
708
+ cacheDir,
709
+ entries
710
+ }
711
+ };
712
+ }
713
+ function createFileAttachmentResolver(state) {
714
+ return async (slotId) => {
715
+ const meta = state.local.attachmentCache?.[slotId];
716
+ if (!meta) return null;
717
+ const bytes = await import_node_fs6.promises.readFile(meta.localPath);
718
+ const view = new Uint8Array(bytes);
719
+ const body = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
720
+ return {
721
+ blob: new Blob([body], { type: meta.mimeType }),
722
+ filename: meta.filename
723
+ };
724
+ };
725
+ }
726
+ function collectExecutionAttachmentRequirements(state, plan) {
727
+ const seen = /* @__PURE__ */ new Map();
728
+ const localRequestFilter = requestFilterForPlan(plan, null);
729
+ const localCollections = localRequestFilter ? {
730
+ ...state.synced.collections,
731
+ requests: Object.fromEntries(
732
+ Object.entries(state.synced.collections.requests).filter(
733
+ ([id]) => localRequestFilter.has(id)
734
+ )
735
+ )
736
+ } : state.synced.collections;
737
+ const workspaceSlots = (0, import_core2.collectAttachmentSlots)({ ...state.synced, collections: localCollections });
738
+ for (const slot of workspaceSlots) {
739
+ const requiredBy = collectRequiredBy(localCollections.requests, slot.slotId);
740
+ if (requiredBy.length === 0) continue;
741
+ addRequirement(seen, {
742
+ ...slot,
743
+ source: "workspace",
744
+ repoFullName: state.local.connectedRepo?.fullName ?? void 0,
745
+ branch: state.local.workingBranch?.name ?? void 0,
746
+ publicRepo: state.local.connectedRepo ? !state.local.connectedRepo.isPrivate : false,
747
+ requiredBy
748
+ });
749
+ }
750
+ for (const [linkedWorkspaceId, snapshot] of Object.entries(state.local.linkedCollections)) {
751
+ const link = state.synced.linkedWorkspaces[linkedWorkspaceId];
752
+ if (!link) continue;
753
+ const linkedRequestFilter = requestFilterForPlan(plan, linkedWorkspaceId);
754
+ if (plan && linkedRequestFilter && linkedRequestFilter.size === 0) continue;
755
+ const linkedCollections = linkedRequestFilter ? {
756
+ ...snapshot.collections,
757
+ requests: Object.fromEntries(
758
+ Object.entries(snapshot.collections.requests).filter(
759
+ ([id]) => linkedRequestFilter.has(id)
760
+ )
761
+ )
762
+ } : snapshot.collections;
763
+ const linkedSynced = {
764
+ ...state.synced,
765
+ collections: linkedCollections,
766
+ environments: snapshot.environments,
767
+ globalAssets: snapshot.globalAssets ?? state.synced.globalAssets
768
+ };
769
+ for (const slot of (0, import_core2.collectAttachmentSlots)(linkedSynced)) {
770
+ const requiredBy = collectRequiredBy(linkedCollections.requests, slot.slotId);
771
+ if (requiredBy.length === 0) continue;
772
+ addRequirement(seen, {
773
+ ...slot,
774
+ source: "linked-workspace",
775
+ linkedWorkspaceId,
776
+ repoFullName: link.source.repoFullName,
777
+ branch: link.source.branch,
778
+ publicRepo: link.kind === "public",
779
+ requiredBy
780
+ });
781
+ }
782
+ }
783
+ return [...seen.values()];
784
+ }
785
+ function requestFilterForPlan(plan, linkedWorkspaceId) {
786
+ if (!plan) return null;
787
+ const ids = /* @__PURE__ */ new Set();
788
+ for (const step of plan.steps) {
789
+ if (step.enabled === false) continue;
790
+ if ((step.linkedWorkspaceId ?? null) === linkedWorkspaceId) ids.add(step.requestId);
791
+ }
792
+ return ids;
793
+ }
794
+ function addRequirement(seen, requirement) {
795
+ const existing = seen.get(requirement.slotId);
796
+ if (!existing) {
797
+ seen.set(requirement.slotId, requirement);
798
+ return;
799
+ }
800
+ for (const usage of requirement.requiredBy) {
801
+ if (!existing.requiredBy.some((item) => item.requestId === usage.requestId)) {
802
+ existing.requiredBy.push(usage);
803
+ }
804
+ }
805
+ }
806
+ function collectRequiredBy(requests, slotId) {
807
+ const requiredBy = [];
808
+ for (const request of Object.values(requests)) {
809
+ if (bodyReferencesSlot(request.body, slotId)) {
810
+ requiredBy.push({ requestId: request.id, requestName: request.name });
811
+ }
812
+ }
813
+ return requiredBy;
814
+ }
815
+ function bodyReferencesSlot(body, slotId) {
816
+ if (body.type === "binary") return body.attachment?.slotId === slotId;
817
+ if (body.type !== "form-data") return false;
818
+ return (body.formRows ?? []).some((row) => row.kind === "file" && row.slotId === slotId);
819
+ }
820
+ async function hasExpectedFile(localPath, sha256) {
821
+ try {
822
+ const bytes = await import_node_fs6.promises.readFile(localPath);
823
+ if (!sha256) return true;
824
+ return sha256Hex(bytes) === sha256;
825
+ } catch {
826
+ return false;
827
+ }
828
+ }
829
+ async function fileSize(localPath) {
830
+ try {
831
+ return (await import_node_fs6.promises.stat(localPath)).size;
832
+ } catch {
833
+ return 0;
834
+ }
835
+ }
836
+ async function downloadAttachment(requirement) {
837
+ if (!requirement.repoFullName || !requirement.branch) return null;
838
+ const [owner, repo] = requirement.repoFullName.split("/", 2);
839
+ if (!owner || !repo) return null;
840
+ const token = resolveGitHubToken(requirement);
841
+ if (!token && !requirement.publicRepo) {
842
+ throw new Error(
843
+ "private linked attachments need a GitHub token (set APICIRCLE_GITHUB_TOKEN or GITHUB_TOKEN)"
844
+ );
845
+ }
846
+ const apiPath = [".apicircle", "attachments", requirement.slotId].map(encodeURIComponent).join("/");
847
+ const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
848
+ repo
849
+ )}/contents/${apiPath}?ref=${encodeURIComponent(requirement.branch)}`;
850
+ const headers = {
851
+ Accept: "application/vnd.github+json",
852
+ "User-Agent": "apicircle-cli",
853
+ "X-GitHub-Api-Version": "2022-11-28"
854
+ };
855
+ if (token) headers.Authorization = `Bearer ${token}`;
856
+ const res = await fetch(url, { headers, cache: "no-store" });
857
+ if (res.status === 404) return null;
858
+ if (!res.ok) {
859
+ throw new Error(`GitHub returned ${res.status}: ${await res.text()}`);
860
+ }
861
+ const json = await res.json();
862
+ if (json.type !== "file" || typeof json.content !== "string") {
863
+ throw new Error("GitHub response was not a file");
864
+ }
865
+ if (json.encoding !== "base64") {
866
+ throw new Error(`GitHub response used unsupported encoding ${json.encoding ?? "(missing)"}`);
867
+ }
868
+ return new Uint8Array(Buffer.from(json.content.replace(/\n/g, ""), "base64"));
869
+ }
870
+ function resolveGitHubToken(requirement) {
871
+ if (requirement.source === "linked-workspace") {
872
+ 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 ?? "";
873
+ }
874
+ return process.env.APICIRCLE_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? process.env.APICIRCLE_E2E_GITHUB_PAT ?? process.env.APICIRCLE_E2E_BOT_PAT ?? "";
875
+ }
876
+ function sha256Hex(bytes) {
877
+ return (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
878
+ }
879
+ function attachmentLabel(requirement) {
880
+ return `${requirement.filename ?? requirement.slotId} (${requirement.slotId})`;
881
+ }
882
+ function sourceLabel(requirement) {
883
+ const repo = requirement.repoFullName ?? "local workspace";
884
+ const branch = requirement.branch ? `@${requirement.branch}` : "";
885
+ return `${repo}${branch}`;
886
+ }
887
+ function requiredByLabel(requirement) {
888
+ return requirement.requiredBy.map((item) => item.requestName).join(", ") || "a request";
889
+ }
890
+
623
891
  // src/commands/run.ts
624
892
  var REPORTERS = ["text", "json", "junit"];
625
893
  function registerRunCommand(program) {
@@ -661,7 +929,7 @@ function registerRunCommand(program) {
661
929
  fail(`no workspace found at ${dir} (expected workspace.synced.json)`);
662
930
  return;
663
931
  }
664
- const ref = (0, import_core2.resolvePlanRef)(state.synced, planRef);
932
+ const ref = (0, import_core3.resolvePlanRef)(state.synced, planRef);
665
933
  if (!ref.ok) {
666
934
  fail(ref.error);
667
935
  if (ref.available.length > 0) {
@@ -693,21 +961,33 @@ function registerRunCommand(program) {
693
961
  const onSigint = () => controller.abort(new Error("aborted by SIGINT"));
694
962
  process.on("SIGINT", onSigint);
695
963
  if (text) process.stdout.write(formatHeader(ref.plan, actor, withAssertions, opts));
964
+ let prepared;
965
+ try {
966
+ prepared = await prepareExecutionAttachments(dir, state, ref.plan);
967
+ } catch (err) {
968
+ process.off("SIGINT", onSigint);
969
+ fail(err instanceof Error ? err.message : String(err), 1, "attachment");
970
+ return;
971
+ }
972
+ if (text && prepared.summary.total > 0) {
973
+ process.stdout.write(formatAttachmentPreparation(prepared.summary));
974
+ }
696
975
  let result;
697
976
  try {
698
- result = await (0, import_core2.runPlan)(state, ref.id, {
977
+ result = await (0, import_core3.runPlan)(prepared.state, ref.id, {
699
978
  withAssertions,
700
979
  bail: opts.bail === true,
701
980
  env: opts.env,
702
981
  secretsById,
703
982
  actor,
704
983
  signal: controller.signal,
984
+ resolveAttachment: prepared.resolveAttachment,
705
985
  authorize: checkRunPermission,
706
986
  onStep: text ? (step) => process.stdout.write(formatStepLine(step)) : void 0
707
987
  });
708
988
  } catch (err) {
709
989
  process.off("SIGINT", onSigint);
710
- if (err instanceof import_core2.PlanRunDeniedError) {
990
+ if (err instanceof import_core3.PlanRunDeniedError) {
711
991
  fail(err.message, 3, "denied");
712
992
  return;
713
993
  }
@@ -720,7 +1000,7 @@ function registerRunCommand(program) {
720
1000
  if (reporter === "json") {
721
1001
  process.stdout.write(
722
1002
  JSON.stringify(
723
- buildJsonReport(dir, ref.id, ref.plan, actor, result, saved, aborted),
1003
+ buildJsonReport(dir, ref.id, ref.plan, actor, result, saved, aborted, prepared.summary),
724
1004
  null,
725
1005
  2
726
1006
  ) + "\n"
@@ -746,7 +1026,7 @@ function resolveActor(local, override) {
746
1026
  if (username) return { kind: "os", name: username };
747
1027
  } catch {
748
1028
  }
749
- return import_core2.ANONYMOUS_ACTOR;
1029
+ return import_core3.ANONYMOUS_ACTOR;
750
1030
  }
751
1031
  function checkRunPermission(_ctx) {
752
1032
  }
@@ -762,6 +1042,26 @@ function formatHeader(plan, actor, withAssertions, opts) {
762
1042
  )}
763
1043
  ${import_kleur4.default.dim("Run by")} ${actor.name} ${import_kleur4.default.dim(`(${actor.kind})`)}
764
1044
 
1045
+ `;
1046
+ }
1047
+ function formatAttachmentPreparation(summary) {
1048
+ const status = `${summary.downloaded} downloaded, ${summary.alreadyPresent} already local`;
1049
+ const lines = [
1050
+ `${import_kleur4.default.bold("Attachments")} ${summary.total} required ${import_kleur4.default.dim(
1051
+ `(${status} - ${summary.cacheDir})`
1052
+ )}`
1053
+ ];
1054
+ for (const entry2 of summary.entries) {
1055
+ const source = entry2.source === "linked-workspace" ? `linked:${entry2.linkedWorkspaceId ?? "unknown"}` : "workspace";
1056
+ const requiredBy = entry2.requiredBy.map((item) => item.requestName).join(", ");
1057
+ lines.push(
1058
+ ` ${import_kleur4.default.dim("file")} ${entry2.filename} ${import_kleur4.default.dim(
1059
+ `${source} - ${requiredBy} - ${entry2.localPath}`
1060
+ )}`
1061
+ );
1062
+ }
1063
+ return `${lines.join("\n")}
1064
+
765
1065
  `;
766
1066
  }
767
1067
  function formatStepLine(step) {
@@ -831,7 +1131,7 @@ ${verdict} ${parts.join(import_kleur4.default.dim(" \xB7 "))} ${import_kleur4.
831
1131
  out += saved ? import_kleur4.default.dim("Plan run saved to workspace history.\n") : import_kleur4.default.dim("Plan run not saved (--no-save).\n");
832
1132
  return out;
833
1133
  }
834
- function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted) {
1134
+ function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted, attachments) {
835
1135
  return {
836
1136
  workspace,
837
1137
  plan: { id: planId, name: plan.name },
@@ -841,6 +1141,7 @@ function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted)
841
1141
  aborted,
842
1142
  durationMs: result.planRun.durationMs,
843
1143
  saved,
1144
+ attachments,
844
1145
  counts: tally(result),
845
1146
  steps: result.steps.map((s) => ({
846
1147
  step: s.stepIndex + 1,