@tenderprompt/accounts 0.8.2 → 0.8.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/main.js CHANGED
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { homedir, hostname, tmpdir } from "node:os";
6
+ import { createHash } from "node:crypto";
6
7
  import { AgentApiClient } from "./api.js";
7
8
  import { CLI_AUTH_SCHEMA, DEFAULT_AUTH_PROFILE, createDefaultAuthStore, validateAuthProfile, validateNamedAuthProfile, } from "./auth-store.js";
8
9
  import { labelLocalArtifact, verifyArtifact } from "./artifact.js";
@@ -146,6 +147,14 @@ async function execute(topic, options, cwd, runtime) {
146
147
  return { value: await sourceOperationStatus(cwd, options, runtime) };
147
148
  case "source operation retry":
148
149
  return { value: await sourceOperationRetry(cwd, options, runtime) };
150
+ case "source change create":
151
+ return { value: await sourceChangeCreate(cwd, options, runtime) };
152
+ case "source change list":
153
+ return { value: await sourceChangeList(cwd, options, runtime) };
154
+ case "source change show":
155
+ return { value: await sourceChangeShow(cwd, options, runtime) };
156
+ case "source change close":
157
+ return { value: await sourceChangeClose(cwd, options, runtime) };
149
158
  case "source connect":
150
159
  return { value: await sourceConnect(cwd, options, runtime) };
151
160
  case "source pull":
@@ -506,7 +515,7 @@ async function sourcePull(cwd, options, runtime) {
506
515
  }
507
516
  let credential = null;
508
517
  try {
509
- const token = await access.client.sourceToken(repository.id, 15 * 60);
518
+ const token = await access.client.sourceToken(repository.id, 15 * 60, "read");
510
519
  if (token.remoteUrl !== repository.remoteUrl) {
511
520
  throw new CliError("source_token_target_mismatch", "Tender refused a repository credential whose target did not match the connected source.");
512
521
  }
@@ -755,8 +764,8 @@ async function sourceToken(cwd, options, runtime) {
755
764
  return {
756
765
  status: "issued",
757
766
  repositoryId: source.repository.id,
758
- ...(await client.sourceToken(source.repository.id, numberFlag(options, "ttl-seconds", 15 * 60, 7 * 24 * 60 * 60, 60 * 60))),
759
- warning: "This repository-scoped credential is shown once. Keep it out of source and shell history.",
767
+ ...(await client.sourceToken(source.repository.id, numberFlag(options, "ttl-seconds", 15 * 60, 7 * 24 * 60 * 60, 60 * 60), "read")),
768
+ warning: "This read-only repository credential is shown once. Keep it out of source and shell history.",
760
769
  };
761
770
  }
762
771
  async function sourcePush(cwd, options, runtime) {
@@ -775,6 +784,18 @@ async function sourcePush(cwd, options, runtime) {
775
784
  const branch = options.flags.get("branch") ?? (currentBranch || source.repository.defaultBranch);
776
785
  assertGitBranchName(branch);
777
786
  const state = await sourceState(gitRoot, runtime);
787
+ if (source.repository.changeRequestProtection === "enabling") {
788
+ throw new CliError("source_review_activation_pending", "Reviewed changes are still activating. Retry after Tender confirms previous write access was revoked.");
789
+ }
790
+ if (source.repository.changeRequestProtection === "enabled") {
791
+ if (!currentBranch) {
792
+ throw new CliError("source_push_detached_head", "Tender will not submit a detached HEAD. Create or switch to a feature branch first.");
793
+ }
794
+ if (branch === source.repository.defaultBranch) {
795
+ throw new CliError("source_default_branch_protected", `${source.repository.defaultBranch} is protected. Push a feature branch, then create a reviewed change request.`, "tender-accounts source push --branch my-change --json");
796
+ }
797
+ return await submitProtectedSourcePush(gitRoot, projectId, source.repository, branch, state, options, client, runtime);
798
+ }
778
799
  let credential = null;
779
800
  try {
780
801
  const token = await client.sourceToken(source.repository.id, 15 * 60);
@@ -795,6 +816,152 @@ async function sourcePush(cwd, options, runtime) {
795
816
  next: "tender-accounts source status --json",
796
817
  };
797
818
  }
819
+ async function submitProtectedSourcePush(gitRoot, projectId, repository, branch, state, options, client, runtime) {
820
+ let credential = null;
821
+ let expectedCurrentSha = null;
822
+ try {
823
+ const token = await client.sourceToken(repository.id, 15 * 60, "read");
824
+ credential = token.token;
825
+ const remote = await runtime.captureCommand("git", ["ls-remote", "--heads", repository.remoteUrl, `refs/heads/${branch}`], gitRoot, managedGitEnvironment(runtime.environment, credential, managedGitCredentialHeaderKey(repository.remoteUrl)));
826
+ const refs = parseManagedSourceRefs(remote.stdout);
827
+ expectedCurrentSha = refs[0]?.sha ?? null;
828
+ }
829
+ finally {
830
+ credential = null;
831
+ }
832
+ const temporary = await mkdtemp(join(tmpdir(), "tender-source-push-"));
833
+ const bundlePath = join(temporary, "source.bundle");
834
+ try {
835
+ await runtime.captureCommand("git", ["bundle", "create", bundlePath, "HEAD"], gitRoot, managedGitCheckoutEnvironment(runtime.environment));
836
+ const bytes = new Uint8Array(await readFile(bundlePath));
837
+ if (bytes.byteLength > 50 * 1024 * 1024) {
838
+ throw new CliError("source_bundle_too_large", "The committed source bundle exceeds Tender's 50 MiB limit.");
839
+ }
840
+ const bundleSha256 = createHash("sha256").update(bytes).digest("hex");
841
+ const retryWindow = Math.floor(Date.now() / (24 * 60 * 60 * 1000));
842
+ const pushIntentDigest = createHash("sha256")
843
+ .update(`${repository.id}\0${branch}\0${state.revision}\0${retryWindow}`)
844
+ .digest("hex");
845
+ const requested = await client.createSourcePush(repository.id, {
846
+ idempotencyKey: `source-push-${pushIntentDigest}`,
847
+ branch,
848
+ expectedCurrentSha,
849
+ sourceRevision: state.revision,
850
+ bundleSha256,
851
+ bundleBytes: bytes.byteLength,
852
+ });
853
+ let push = requested.push;
854
+ if (push.status === "uploading")
855
+ push = (await client.uploadSourceBundle(push.id, bytes)).push;
856
+ if (!options.booleans.has("no-wait")) {
857
+ const deadline = Date.now() + numberFlag(options, "timeout-seconds", 30, 900, 300) * 1000;
858
+ while (push.status === "requested" || push.status === "running" || push.status === "uploading") {
859
+ if (Date.now() >= deadline) {
860
+ throw new CliError("source_push_timeout", `Source push ${push.id} is still ${push.status}.`, `tender-accounts source push --branch ${branch} --no-wait --json`);
861
+ }
862
+ await runtime.sleep(1500);
863
+ push = (await client.sourcePush(push.id)).push;
864
+ }
865
+ }
866
+ if (push.status === "failed") {
867
+ throw new CliError(push.error?.code ?? "source_push_failed", push.error?.message ?? "Tender could not push the exact feature branch.");
868
+ }
869
+ if (push.status === "expired") {
870
+ throw new CliError(push.error?.code ?? "source_push_expired", push.error?.message ?? "The exact source push expired before it completed.");
871
+ }
872
+ return {
873
+ status: push.status === "succeeded" ? "pushed" : push.status,
874
+ projectId,
875
+ repositoryId: repository.id,
876
+ pushId: push.id,
877
+ branch,
878
+ sourceRevision: state.revision,
879
+ dirtyWorkingTreeExcluded: state.dirty,
880
+ credentialPersisted: false,
881
+ defaultBranchProtected: true,
882
+ next: `tender-accounts source change create --head ${branch} --title \"Describe this change\" --json`,
883
+ };
884
+ }
885
+ finally {
886
+ await rm(temporary, { recursive: true, force: true });
887
+ }
888
+ }
889
+ async function sourceChangeCreate(cwd, options, runtime) {
890
+ const access = await sourceReadAccess(cwd, options, runtime);
891
+ if (access.repository.changeRequestProtection !== "enabled") {
892
+ throw new CliError("source_review_not_enabled", "A merchant administrator must enable reviewed changes for this managed repository first.");
893
+ }
894
+ const baseBranch = options.flags.get("base") ?? access.repository.defaultBranch;
895
+ const headBranch = requiredFlag(options, "head");
896
+ const title = requiredFlag(options, "title");
897
+ assertGitBranchName(baseBranch);
898
+ assertGitBranchName(headBranch);
899
+ if (baseBranch === headBranch)
900
+ throw new CliError("source_change_invalid", "The head branch must differ from the protected base branch.");
901
+ return withManagedSourceMirror(cwd, access.repository, access.client, runtime, async (mirror) => {
902
+ const refs = await managedSourceMirrorRefs(mirror, runtime);
903
+ const baseSha = refs.get(`refs/heads/${baseBranch}`);
904
+ const headSha = refs.get(`refs/heads/${headBranch}`);
905
+ if (!baseSha || !headSha)
906
+ throw new CliError("source_branch_missing", "Both the protected base and proposed head branches must exist.");
907
+ const created = await access.client.createSourceChangeRequest(access.repository.id, {
908
+ title,
909
+ description: options.flags.get("description") ?? "",
910
+ baseBranch,
911
+ headBranch,
912
+ baseSha,
913
+ headSha,
914
+ });
915
+ return {
916
+ status: created.changeRequest.status,
917
+ created: created.created,
918
+ projectId: access.projectId,
919
+ repositoryId: access.repository.id,
920
+ changeRequest: created.changeRequest,
921
+ next: created.changeRequest.status === "ready_for_review"
922
+ ? "A merchant administrator can open the exact preview and approve this change in Tender."
923
+ : "Wait for Tender to validate and seal the exact preview, then inspect this change again.",
924
+ };
925
+ });
926
+ }
927
+ async function sourceChangeList(cwd, options, runtime) {
928
+ const access = await sourceReadAccess(cwd, options, runtime);
929
+ const state = (options.flags.get("state") ?? "open");
930
+ if (!new Set(["open", "closed", "all"]).has(state))
931
+ throw new CliError("argument_invalid", "--state must be open, closed, or all.");
932
+ const result = await access.client.sourceChangeRequests(access.repository.id, state);
933
+ return { status: "ready", projectId: access.projectId, repositoryId: access.repository.id, changeRequests: result.changeRequests };
934
+ }
935
+ async function sourceChangeShow(cwd, options, runtime) {
936
+ const context = await loadProjectContext(cwd);
937
+ if (!context.link)
938
+ throw new CliError("project_not_linked", "This app is not linked to a Tender Accounts project.");
939
+ const { client } = await authenticatedClient(cwd, context.link, options, runtime);
940
+ const result = await client.sourceChangeRequest(requiredSourceChangeRequestId(requiredFlag(options, "change")));
941
+ requireLinkedSourceChange(context.link.projectId, result.changeRequest.projectId);
942
+ return result;
943
+ }
944
+ async function sourceChangeClose(cwd, options, runtime) {
945
+ const context = await loadProjectContext(cwd);
946
+ if (!context.link)
947
+ throw new CliError("project_not_linked", "This app is not linked to a Tender Accounts project.");
948
+ const { client } = await authenticatedClient(cwd, context.link, options, runtime);
949
+ const changeRequestId = requiredSourceChangeRequestId(requiredFlag(options, "change"));
950
+ const current = await client.sourceChangeRequest(changeRequestId);
951
+ requireLinkedSourceChange(context.link.projectId, current.changeRequest.projectId);
952
+ return client.closeSourceChangeRequest(changeRequestId);
953
+ }
954
+ function requireLinkedSourceChange(expectedProjectId, actualProjectId) {
955
+ if (actualProjectId !== expectedProjectId) {
956
+ throw new CliError("project_link_mismatch", "That source change belongs to another Tender project. Run the command from its linked checkout.");
957
+ }
958
+ }
959
+ function requiredSourceChangeRequestId(value) {
960
+ if (!/^scr_[A-Za-z0-9_-]{8,64}$/u.test(value)) {
961
+ throw new CliError("source_change_id_invalid", "--change must be the scr_ ID returned by source change create.");
962
+ }
963
+ return value;
964
+ }
798
965
  async function requireGitRoot(root, runtime) {
799
966
  try {
800
967
  return resolve((await runtime.captureCommand("git", ["rev-parse", "--show-toplevel"], root)).stdout);
@@ -1659,7 +1826,7 @@ function commandTopic(argv) {
1659
1826
  const child = argv[1];
1660
1827
  if (!child || child.startsWith("--"))
1661
1828
  return { topic: "help", values: [parent] };
1662
- if (parent === "source" && (child === "default" || child === "history" || child === "operation")) {
1829
+ if (parent === "source" && (child === "default" || child === "history" || child === "operation" || child === "change")) {
1663
1830
  const grandchild = argv[2];
1664
1831
  if (!grandchild || grandchild.startsWith("--"))
1665
1832
  return { topic: "help", values: [`${parent} ${child}`] };
@@ -1764,6 +1931,10 @@ function assertAllowedOptions(topic, options) {
1764
1931
  ],
1765
1932
  "source operation status": [...common, ...authentication, "operation"],
1766
1933
  "source operation retry": [...common, ...authentication, "operation", "no-wait", "timeout-seconds"],
1934
+ "source change create": [...common, ...authentication, "project", "title", "description", "base", "head"],
1935
+ "source change list": [...common, ...authentication, "project", "state"],
1936
+ "source change show": [...common, ...authentication, "change"],
1937
+ "source change close": [...common, ...authentication, "change"],
1767
1938
  "source connect": [
1768
1939
  ...common,
1769
1940
  ...authentication,
@@ -1777,7 +1948,7 @@ function assertAllowedOptions(topic, options) {
1777
1948
  ],
1778
1949
  "source pull": [...common, ...authentication, "project", "remote", "dry-run"],
1779
1950
  "source token": [...common, ...authentication, "ttl-seconds"],
1780
- "source push": [...common, ...authentication, "branch", "remote"],
1951
+ "source push": [...common, ...authentication, "branch", "remote", "no-wait", "timeout-seconds"],
1781
1952
  tail: [
1782
1953
  ...common,
1783
1954
  ...authentication,
@@ -1908,7 +2079,7 @@ function helpText(topic) {
1908
2079
  check: "Usage: tender-accounts check [--json]\n\nRun the app's declared validation command.",
1909
2080
  build: "Usage: tender-accounts build [--json]\n\nRun validation and packaging, then verify the complete portable artifact.",
1910
2081
  preview: "Usage: tender-accounts preview [--dry-run] [--no-wait] [--timeout-seconds N] [--return-path /account] [--json]\n\nBuild and submit an exact preview. --dry-run validates locally without authentication or upload.",
1911
- source: "Usage: tender-accounts source <status|branches|log|compare|connect|pull|push|default|promote|history|operation> [options]\n\nUse standard Git through Tender for a gateway or service project. Source changes never publish production directly.",
2082
+ source: "Usage: tender-accounts source <status|branches|log|compare|connect|pull|push|change|default|promote|history|operation> [options]\n\nUse standard Git through Tender for a gateway or service project. Source changes never publish production directly.",
1912
2083
  "source status": "Usage: tender-accounts source status [--project PROJECT_ID] [--json]\n\nRead the managed repository, immutable build contract, and recent source builds. --project works before a checkout is linked.\n\nExamples:\n tender-accounts source status --project prj_... --json\n tender-accounts source status --json",
1913
2084
  "source branches": "Usage: tender-accounts source branches [--project PROJECT_ID] [--json]\n\nList exact branch refs and identify Tender's authoritative default separately from the provider's create-time default. Uses a short-lived read credential without persisting it.",
1914
2085
  "source log": "Usage: tender-accounts source log [--project PROJECT_ID] [--branch BRANCH] [--limit 20] [--json]\n\nRead bounded commit history from one exact managed branch without requiring a checkout.",
@@ -1921,10 +2092,15 @@ function helpText(topic) {
1921
2092
  "source operation": "Usage: tender-accounts source operation <status|retry> --operation sop_... [--json]",
1922
2093
  "source operation status": "Usage: tender-accounts source operation status --operation sop_... [--json]\n\nRead durable status and safe evidence for one exact source operation.",
1923
2094
  "source operation retry": "Usage: tender-accounts source operation retry --operation sop_... [--no-wait] [--timeout-seconds N] [--json]\n\nRetry or resume the same durable source operation without changing its reviewed intent. This never publishes production.",
2095
+ "source change": "Usage: tender-accounts source change <create|list|show|close> [options]\n\nCreate and inspect exact preview-backed changes. Approval and landing stay merchant-admin actions in Tender.",
2096
+ "source change create": "Usage: tender-accounts source change create --head BRANCH --title TITLE [--description TEXT] [--base BRANCH] [--json]\n\nOpen a review for the exact protected-base and feature-branch SHAs. Tender binds checks and approval to this immutable snapshot.",
2097
+ "source change list": "Usage: tender-accounts source change list [--state open|closed|all] [--json]\n\nList reviewed source changes for this exact linked project.",
2098
+ "source change show": "Usage: tender-accounts source change show --change scr_... [--json]\n\nRead exact snapshot, preview, decision, and landing state.",
2099
+ "source change close": "Usage: tender-accounts source change close --change scr_... [--json]\n\nClose an unlanded change without changing source or production.",
1924
2100
  "source connect": "Usage: tender-accounts source connect [--repo-name NAME] [--default-branch NAME] [--publication preview-only|default-branch] [--remote NAME] [--install-command COMMAND] [--working-directory PATH] [--artifact-path PATH] [--json]\n\nCreate or reconnect this linked project to Tender-managed Git. Build and artifact commands come from tender-accounts.json. Protected gateways accept preview-only publication.",
1925
2101
  "source pull": "Usage: tender-accounts source pull [--project PROJECT_ID] [--remote NAME] [--dry-run] [--json]\n\nClone the exact connected Tender-managed repository into an empty workspace, or fast-forward an existing clean matching checkout. The app is linked automatically and the short-lived Git credential is never persisted.\n\nExamples:\n tender-accounts source pull --project prj_... --dry-run --json\n tender-accounts source pull --project prj_... --json\n tender-accounts source pull --json",
1926
- "source token": "Usage: tender-accounts source token [--ttl-seconds N] [--json]\n\nIssue a short-lived, repository-scoped Git credential. It is shown once and is never stored by the CLI.",
1927
- "source push": "Usage: tender-accounts source push [--branch NAME] [--remote NAME] [--json]\n\nPush the exact committed revision with an ephemeral credential supplied only to the Git child process. Dirty working-tree changes are excluded.",
2102
+ "source token": "Usage: tender-accounts source token [--ttl-seconds N] [--json]\n\nIssue a short-lived read-only repository credential. It is shown once and is never stored by the CLI.",
2103
+ "source push": "Usage: tender-accounts source push [--branch NAME] [--remote NAME] [--no-wait] [--json]\n\nPush the exact committed revision. Reviewed repositories upload a bounded Git bundle and never expose a write credential; the default branch remains protected. Dirty working-tree changes are excluded.",
1928
2104
  tail: "Usage: tender-accounts tail (--delivery dly_... | --production) [--status ok,error,canceled] [--method GET,POST] [--search TEXT] [--sampling-rate 0.25] [--json]\n\nStream sanitized logs for this exact linked project. Preview tails require a succeeded dly_ delivery. Production tails require merchant-administrator authorization. Sessions expire after 15 minutes and do not persist events.",
1929
2105
  delivery: "Usage: tender-accounts delivery <status|preview|retry> --delivery dly_... [--json]\n\nThese commands accept the developer-delivery ID returned by preview. A dwf_ ID is a production workflow managed by merchant administrators in Activity.",
1930
2106
  "delivery status": "Usage: tender-accounts delivery status --delivery dly_... [--json]\n\nRead one developer preview delivery. Do not pass a dwf_ production workflow ID.",