@indigoai-us/hq-cli 5.345.137 → 5.345.139

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.
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { Command } from "commander";
37
37
  import { type ReclaimReport } from "./storage-core.js";
38
- import { ListObjectVersionsCommand, DeleteObjectsCommand, type ListObjectVersionsCommandOutput, type DeleteObjectsCommandOutput } from "@aws-sdk/client-s3";
38
+ import { S3Client, ListObjectVersionsCommand, DeleteObjectsCommand, type ListObjectVersionsCommandOutput, type DeleteObjectsCommandOutput } from "@aws-sdk/client-s3";
39
39
  import { type OffloadResult, type OffloadStatus } from "./storage-offload.js";
40
40
  export type TrancheId = "7d" | "30d" | "90d" | "365d" | "older";
41
41
  export declare const TRANCHES: readonly {
@@ -265,6 +265,17 @@ export declare const NEEDS_YES = "No terminal to confirm in. Re-run with --yes t
265
265
  export declare function confirmOnTty(message: string, input?: NodeJS.ReadableStream & {
266
266
  isTTY?: boolean;
267
267
  }, output?: NodeJS.WritableStream): Promise<boolean>;
268
+ /**
269
+ * An S3 client for one vault whose credentials are re-vended before they
270
+ * expire, plus `revend`, which vends now and returns a client on the new
271
+ * credentials (the SDK caches credentials per client, so a forced refresh
272
+ * needs a new client).
273
+ */
274
+ export declare function vendedS3(vend: () => Promise<VendedCredentials>, bucket: string, makeClient?: (credentials: ReturnType<typeof refreshingVendedCredentials>) => S3Client): {
275
+ bucket: string;
276
+ client: S3Client;
277
+ revend: () => Promise<S3Client>;
278
+ };
268
279
  /** STS credentials from one vend, with the time they stop working. */
269
280
  export interface VendedCredentials {
270
281
  accessKeyId: string;
@@ -48,7 +48,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
48
48
  import { findHqRoot } from "../utils/manifest.js";
49
49
  import { maxLength } from "../utils/table-width.js";
50
50
  import { resolveCanonicalPersonUid } from "./cloud.js";
51
- import { DEFAULT_IDLE_DAYS, currentAvailability, defaultTmpDir, getOffloadStatus, gitExportBlob, gitListPlaceholders, personalS3Store, resolveMinBytes, runFetch, runFetchGitBlob, runOffload, s3VaultObjectStore, syncExcludesOffload, } from "./storage-offload.js";
51
+ import { DEFAULT_IDLE_DAYS, currentAvailability, defaultTmpDir, getOffloadStatus, gitExportBlob, gitListPlaceholders, credentialRetryingClient, resolveMinBytes, runFetch, runFetchGitBlob, runOffload, s3BlobStore, s3VaultObjectStore, syncExcludesOffload, } from "./storage-offload.js";
52
52
  export const TRANCHES = [
53
53
  { id: "7d", label: "Last 7 days", maxAgeDays: 7 },
54
54
  { id: "30d", label: "8-30 days", maxAgeDays: 30 },
@@ -838,12 +838,16 @@ export function formatOffload(r) {
838
838
  const CURRENT_ACTION_LABEL = {
839
839
  "point-at-vault": "point at vault",
840
840
  upload: "upload",
841
+ "upload+offload": "upload+offload",
841
842
  skip: "skip",
842
843
  };
843
844
  /** One `--current` plan line: path, size, owning vault, sync state, action. */
844
845
  function formatCurrentFile(f) {
845
846
  const vault = f.vault === "company" ? `company ${f.company ?? "?"}` : "personal vault";
846
847
  const parts = [f.path, formatBytes(f.bytes), vault, f.synced ? "synced" : "not synced", CURRENT_ACTION_LABEL[f.action]];
848
+ if (f.action === "upload+offload") {
849
+ parts.push(`(${f.bytes} bytes to the company ${f.company ?? "?"} vault at ${f.vault_key ?? f.path})`);
850
+ }
847
851
  if (f.action === "skip" && f.reason)
848
852
  parts.push(`(${f.reason})`);
849
853
  return parts.join(" ");
@@ -1014,8 +1018,14 @@ async function buildOffloadDeps() {
1014
1018
  }
1015
1019
  if (!entity.bucketName)
1016
1020
  throw new Error("Your personal vault has no storage yet. Run `hq onboard`.");
1017
- const vend = await vault.sts.vendSelf({ personUid: entity.uid });
1018
- return { bucket: entity.bucketName, credentials: vend.credentials };
1021
+ const uid = entity.uid;
1022
+ // A run can outlast one STS session, so every client re-vends before the
1023
+ // credentials expire, and once more when S3 refuses them anyway.
1024
+ return vendedS3(async () => {
1025
+ const { vault } = await signedInVault();
1026
+ const vend = await vault.sts.vendSelf({ personUid: uid });
1027
+ return { ...vend.credentials, expiresAt: vend.expiresAt };
1028
+ }, entity.bucketName);
1019
1029
  };
1020
1030
  const activeCompanies = async () => {
1021
1031
  const { token } = await signedInVault();
@@ -1026,6 +1036,30 @@ async function buildOffloadDeps() {
1026
1036
  return companiesFromMemberships(await res.json());
1027
1037
  };
1028
1038
  const journals = new Map();
1039
+ const journalSlug = (target) => target.vault === "company" ? target.slug : hqCloud.PERSONAL_VAULT_JOURNAL_SLUG;
1040
+ const companyVault = async (slug) => {
1041
+ const company = (await activeCompanies()).find((c) => c.slug === slug);
1042
+ if (!company)
1043
+ throw new Error(`You are not an active member of '${slug}'.`);
1044
+ const { vault } = await signedInVault();
1045
+ const entity = await vault.entity.get(company.uid);
1046
+ const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
1047
+ const s3 = vendedS3(async () => {
1048
+ const { vault } = await signedInVault();
1049
+ const vend = await vault.sts.vend({ companyUid: company.uid });
1050
+ return { ...vend.credentials, expiresAt: vend.expiresAt };
1051
+ }, bucket);
1052
+ return { company, ...s3 };
1053
+ };
1054
+ // Company upload, the ignore explainer and the cloud-only journal row ship
1055
+ // in a newer @indigoai-us/hq-cloud. Until the bundled one exports them,
1056
+ // company files over the sync limit report "update HQ sync".
1057
+ const cloud = hqCloud;
1058
+ const explainIgnore = cloud.createIgnoreExplainer;
1059
+ const uploadLarge = cloud.uploadLargeObjectVerified;
1060
+ const multipartClient = cloud.s3MultipartClient;
1061
+ const recordOffloaded = cloud.recordOffloadedObject;
1062
+ let explainer;
1029
1063
  return {
1030
1064
  hqRoot,
1031
1065
  now: () => new Date(),
@@ -1037,8 +1071,8 @@ async function buildOffloadDeps() {
1037
1071
  tmpDir: defaultTmpDir,
1038
1072
  syncExcludesOffload: () => syncExcludesOffload(PERSONAL_VAULT_EXCLUDED_TOP_LEVEL),
1039
1073
  openPersonal: async () => {
1040
- const { bucket, credentials } = await personalVault();
1041
- return personalS3Store({ bucket, region: DEFAULT_COGNITO.region, credentials });
1074
+ const { bucket, client, revend } = await personalVault();
1075
+ return s3BlobStore(bucket, client, { where: "personal vault", revend });
1042
1076
  },
1043
1077
  activeCompanySlugs: async () => (await activeCompanies()).map((c) => c.slug),
1044
1078
  // Current-file offload replaces a file with a placeholder only when the
@@ -1047,9 +1081,9 @@ async function buildOffloadDeps() {
1047
1081
  syncHoldsPlaceholders: () => hqCloud.CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING === true,
1048
1082
  // What HQ sync last stored for a file, read from its local journal.
1049
1083
  // Company journal keys are relative to the company folder; personal keys
1050
- // are relative to the HQ root. Read-only: offload never writes a journal.
1084
+ // are relative to the HQ root. The only journal write is recordCloudOnly.
1051
1085
  syncRecord: async (target) => {
1052
- const slug = target.vault === "company" ? target.slug : hqCloud.PERSONAL_VAULT_JOURNAL_SLUG;
1086
+ const slug = journalSlug(target);
1053
1087
  let journal = journals.get(slug);
1054
1088
  if (!journal) {
1055
1089
  journal = hqCloud.readJournal(slug);
@@ -1064,17 +1098,63 @@ async function buildOffloadDeps() {
1064
1098
  // come from the existing membership-scoped /sts/vend path.
1065
1099
  openVaultObjects: async (target) => {
1066
1100
  if (target.vault === "personal") {
1067
- const { bucket, credentials } = await personalVault();
1068
- return s3VaultObjectStore(bucket, new S3Client({ region: DEFAULT_COGNITO.region, credentials }));
1101
+ const { bucket, client, revend } = await personalVault();
1102
+ return s3VaultObjectStore(bucket, client, { where: "personal vault", revend });
1069
1103
  }
1070
- const company = (await activeCompanies()).find((c) => c.slug === target.slug);
1071
- if (!company)
1072
- throw new Error(`You are not an active member of '${target.slug}'.`);
1073
- const { vault } = await signedInVault();
1074
- const vend = await vault.sts.vend({ companyUid: company.uid });
1075
- const entity = await vault.entity.get(company.uid);
1076
- const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
1077
- return s3VaultObjectStore(bucket, new S3Client({ region: DEFAULT_COGNITO.region, credentials: vend.credentials }));
1104
+ const { company, bucket, client, revend } = await companyVault(target.slug);
1105
+ return s3VaultObjectStore(bucket, client, { where: `company vault '${company.slug}'`, revend });
1106
+ },
1107
+ syncMaxFileBytes: typeof cloud.SYNC_MAX_FILE_BYTES === "number" ? cloud.SYNC_MAX_FILE_BYTES : undefined,
1108
+ syncExclusion: explainIgnore
1109
+ ? (rel) => {
1110
+ explainer ??= explainIgnore(hqRoot);
1111
+ return explainer(rel);
1112
+ }
1113
+ : undefined,
1114
+ // Multipart upload into the company vault, through the caller's
1115
+ // membership-scoped /sts/vend credentials (re-vended before they expire,
1116
+ // since a multi-GB upload can outlast one STS session).
1117
+ openCompanyUploader: uploadLarge && multipartClient && recordOffloaded
1118
+ ? async (slug) => {
1119
+ const { company, bucket, client: s3, revend } = await companyVault(slug);
1120
+ if (bucket.startsWith("hq-vault-prs-")) {
1121
+ throw new Error(`'${bucket}' is a personal vault, not the company vault for '${slug}'.`);
1122
+ }
1123
+ // Each multipart request re-vends once if S3 still refuses the
1124
+ // credentials; a part refused twice leaves the upload resumable.
1125
+ const client = credentialRetryingClient(multipartClient(s3, bucket), `company vault '${company.slug}'`, async () => multipartClient(await revend(), bucket));
1126
+ return {
1127
+ slug: company.slug,
1128
+ bucket,
1129
+ upload: ({ key, file, stateFile }) => uploadLarge({ client, key, filePath: file, stateFile }),
1130
+ };
1131
+ }
1132
+ : undefined,
1133
+ recordCloudOnly: recordOffloaded
1134
+ ? async (target, object) => {
1135
+ const slug = journalSlug(target);
1136
+ recordOffloaded({ journalSlug: slug, key: target.key, ...object });
1137
+ // The cached journal is stale now; reread it on the next lookup.
1138
+ journals.delete(slug);
1139
+ }
1140
+ : undefined,
1141
+ };
1142
+ }
1143
+ /**
1144
+ * An S3 client for one vault whose credentials are re-vended before they
1145
+ * expire, plus `revend`, which vends now and returns a client on the new
1146
+ * credentials (the SDK caches credentials per client, so a forced refresh
1147
+ * needs a new client).
1148
+ */
1149
+ export function vendedS3(vend, bucket, makeClient = (credentials) => new S3Client({ region: DEFAULT_COGNITO.region, credentials })) {
1150
+ return {
1151
+ bucket,
1152
+ client: makeClient(refreshingVendedCredentials(vend)),
1153
+ revend: async () => {
1154
+ const credentials = refreshingVendedCredentials(vend);
1155
+ // Vend now, so a failed vend is reported as one rather than as a refused request.
1156
+ await credentials();
1157
+ return makeClient(credentials);
1078
1158
  },
1079
1159
  };
1080
1160
  }