@indigoai-us/hq-cloud 6.14.4 → 6.14.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.4",
3
+ "version": "6.14.5",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,120 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { runRescue } from "./rescue-core.js";
6
+
7
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
8
+ let stdout = "";
9
+ let stderr = "";
10
+ const origOut = process.stdout.write.bind(process.stdout);
11
+ const origErr = process.stderr.write.bind(process.stderr);
12
+ process.stdout.write = ((chunk: unknown) => {
13
+ stdout += String(chunk);
14
+ return true;
15
+ }) as typeof process.stdout.write;
16
+ process.stderr.write = ((chunk: unknown) => {
17
+ stderr += String(chunk);
18
+ return true;
19
+ }) as typeof process.stderr.write;
20
+ let status: number;
21
+ try {
22
+ status = runRescue(argv, { env }).status;
23
+ } finally {
24
+ process.stdout.write = origOut;
25
+ process.stderr.write = origErr;
26
+ }
27
+ return { status, stdout, stderr };
28
+ }
29
+
30
+ describe("rescue clone diagnostics", () => {
31
+ let workDir: string;
32
+ let hqRoot: string;
33
+
34
+ beforeAll(() => {
35
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-clone-diagnostics-"));
36
+ hqRoot = path.join(workDir, "hq");
37
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
38
+ fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
39
+ });
40
+
41
+ afterAll(() => {
42
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
43
+ });
44
+
45
+ function envWithGit(name: string, script: string): NodeJS.ProcessEnv {
46
+ const shimDir = path.join(workDir, name);
47
+ fs.mkdirSync(shimDir, { recursive: true });
48
+ fs.writeFileSync(path.join(shimDir, "git"), script, { mode: 0o755 });
49
+ return {
50
+ ...process.env,
51
+ GH_TOKEN: "ghp_clone_secret_123",
52
+ PATH: `${shimDir}:${process.env.PATH ?? ""}`,
53
+ };
54
+ }
55
+
56
+ function rescueArgs(extra: string[] = []): string[] {
57
+ return [
58
+ "--hq-root", hqRoot,
59
+ "--source", "test/repo",
60
+ "--ref", "main",
61
+ "--dry-run",
62
+ "--yes",
63
+ "--no-backup",
64
+ ...extra,
65
+ ];
66
+ }
67
+
68
+ it("surfaces redacted stderr when the full-history clone fails", () => {
69
+ const env = envWithGit(
70
+ "full-history",
71
+ `#!/usr/bin/env bash
72
+ printf 'fatal: clone args: %s\n' "$*" >&2
73
+ printf 'mirror: https://user:url-secret@example.com/repo.git\n' >&2
74
+ printf 'Authorization: Bearer header-secret\n' >&2
75
+ exit 42
76
+ `,
77
+ );
78
+
79
+ const r = runRescueCapture(rescueArgs(), env);
80
+
81
+ expect(r.status).toBe(5);
82
+ expect(r.stderr).toContain("fatal: clone args:");
83
+ expect(r.stderr).toContain("https://***@github.com/test/repo.git");
84
+ expect(r.stderr).toContain("mirror: https://***@example.com/repo.git");
85
+ expect(r.stderr).toContain("Authorization: ***");
86
+ expect(r.stderr).toContain("error: clone failed");
87
+ expect(r.stderr.indexOf("fatal: clone args:")).toBeLessThan(
88
+ r.stderr.indexOf("error: clone failed"),
89
+ );
90
+ expect(r.stderr).not.toContain("ghp_clone_secret_123");
91
+ expect(r.stderr).not.toContain("url-secret");
92
+ expect(r.stderr).not.toContain("header-secret");
93
+ });
94
+
95
+ it("surfaces stderr from the shallow attempt and stdout from the failed fallback", () => {
96
+ const env = envWithGit(
97
+ "shallow-fallback",
98
+ `#!/usr/bin/env bash
99
+ if [ "$2" = "--depth" ]; then
100
+ printf 'fatal: shallow clone diagnostic\n' >&2
101
+ exit 41
102
+ fi
103
+ printf 'fatal: fallback clone diagnostic for %s\n' "$*"
104
+ exit 42
105
+ `,
106
+ );
107
+
108
+ const r = runRescueCapture(rescueArgs(["--no-history-check"]), env);
109
+
110
+ expect(r.status).toBe(5);
111
+ expect(r.stderr).toContain("fatal: shallow clone diagnostic");
112
+ expect(r.stderr).toContain("fatal: fallback clone diagnostic");
113
+ expect(r.stderr).toContain("https://***@github.com/test/repo.git");
114
+ expect(r.stderr).toContain("error: clone failed");
115
+ expect(r.stderr.indexOf("fatal: fallback clone diagnostic")).toBeLessThan(
116
+ r.stderr.indexOf("error: clone failed"),
117
+ );
118
+ expect(r.stderr).not.toContain("ghp_clone_secret_123");
119
+ });
120
+ });
@@ -105,6 +105,14 @@ function run(
105
105
  };
106
106
  }
107
107
 
108
+ function redactGitDiagnostic(output: string, ghToken: string): string {
109
+ let redacted = output;
110
+ if (ghToken) redacted = redacted.replaceAll(ghToken, "***");
111
+ return redacted
112
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, "$1***@")
113
+ .replace(/(authorization\s*:\s*)[^\r\n]*/gi, "$1***");
114
+ }
115
+
108
116
  /** `command -v <bin>` — is a binary on PATH? */
109
117
  function hasCmd(bin: string, env: NodeJS.ProcessEnv): boolean {
110
118
  const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
@@ -551,29 +559,45 @@ function doRescue(
551
559
  cloneUrlDisplay = cloneUrl;
552
560
  }
553
561
 
562
+ const reportGitFailure = (result: ReturnType<typeof run>) => {
563
+ const diagnostic = result.stderr.trim() || result.stdout.trim();
564
+ if (diagnostic) err(`${redactGitDiagnostic(diagnostic, ghToken)}\n`);
565
+ };
566
+
554
567
  out("\n");
555
568
  if (cfg.historyCheck) {
556
569
  out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (full history, blob:none filter) ...\n`);
557
- if (run("git", ["clone", "--filter=blob:none", cloneUrl, srcDir], { env }).status !== 0) {
570
+ const cloneResult = run("git", ["clone", "--filter=blob:none", cloneUrl, srcDir], { env });
571
+ if (cloneResult.status !== 0) {
572
+ reportGitFailure(cloneResult);
558
573
  err("error: clone failed\n");
559
574
  throw new ExitError(5);
560
575
  }
561
- if (run("git", ["checkout", cfg.ref], { cwd: srcDir, env }).status !== 0) {
576
+ const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
577
+ if (checkoutResult.status !== 0) {
578
+ reportGitFailure(checkoutResult);
562
579
  err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
563
580
  throw new ExitError(5);
564
581
  }
565
582
  } else {
566
583
  out(`==> Cloning ${cloneUrlDisplay} @${cfg.ref} (shallow) ...\n`);
567
- if (
568
- run("git", ["clone", "--depth", "1", "--branch", cfg.ref, cloneUrl, srcDir], { env })
569
- .status !== 0
570
- ) {
584
+ const shallowCloneResult = run(
585
+ "git",
586
+ ["clone", "--depth", "1", "--branch", cfg.ref, cloneUrl, srcDir],
587
+ { env },
588
+ );
589
+ if (shallowCloneResult.status !== 0) {
590
+ reportGitFailure(shallowCloneResult);
571
591
  out(" (shallow branch clone failed; trying full clone + checkout)\n");
572
- if (run("git", ["clone", cloneUrl, srcDir], { env }).status !== 0) {
592
+ const cloneResult = run("git", ["clone", cloneUrl, srcDir], { env });
593
+ if (cloneResult.status !== 0) {
594
+ reportGitFailure(cloneResult);
573
595
  err("error: clone failed\n");
574
596
  throw new ExitError(5);
575
597
  }
576
- if (run("git", ["checkout", cfg.ref], { cwd: srcDir, env }).status !== 0) {
598
+ const checkoutResult = run("git", ["checkout", cfg.ref], { cwd: srcDir, env });
599
+ if (checkoutResult.status !== 0) {
600
+ reportGitFailure(checkoutResult);
577
601
  err(`error: could not check out ref '${cfg.ref}' from ${cfg.sourceRepo}\n`);
578
602
  throw new ExitError(5);
579
603
  }
@@ -684,6 +684,53 @@ describe("share", () => {
684
684
  expect(result.filesUploaded).toBe(0);
685
685
  });
686
686
 
687
+ it("fresh-install SSE-KMS etag: byte-identical remote is reconciled without a conflict", async () => {
688
+ const companyRoot = path.join(tmpDir, "companies", "acme");
689
+ fs.mkdirSync(companyRoot, { recursive: true });
690
+ const testFile = path.join(companyRoot, "kms-scaffold.md");
691
+ const scaffoldBytes = "identical-content-encrypted-with-sse-kms";
692
+ fs.writeFileSync(testFile, scaffoldBytes);
693
+
694
+ // A 32-hex SSE-KMS ETag is not the MD5 of the plaintext object.
695
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
696
+ lastModified: new Date(),
697
+ etag: '"288bfa91684ae21f9ae27c061254d412"',
698
+ size: scaffoldBytes.length,
699
+ });
700
+ vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
701
+ fs.writeFileSync(dest as string, scaffoldBytes);
702
+ return undefined as never;
703
+ });
704
+
705
+ const events: unknown[] = [];
706
+ const result = await share({
707
+ paths: [testFile],
708
+ company: "acme",
709
+ vaultConfig: mockConfig,
710
+ hqRoot: tmpDir,
711
+ onConflict: "keep",
712
+ onEvent: (e) => events.push(e),
713
+ });
714
+
715
+ expect(result.conflictPaths).toEqual([]);
716
+ expect(result.filesUploaded).toBe(0);
717
+ expect(uploadFile).not.toHaveBeenCalled();
718
+ expect(events).toContainEqual({
719
+ type: "reconciled",
720
+ path: "kms-scaffold.md",
721
+ direction: "push",
722
+ });
723
+ expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
724
+
725
+ const journal = JSON.parse(
726
+ fs.readFileSync(path.join(stateDir, "sync-journal.acme.json"), "utf-8"),
727
+ ) as { files: Record<string, { direction: string; remoteEtag?: string }> };
728
+ expect(journal.files["kms-scaffold.md"]).toMatchObject({
729
+ direction: "up",
730
+ remoteEtag: "288bfa91684ae21f9ae27c061254d412",
731
+ });
732
+ });
733
+
687
734
  it("fresh-install multipart collision: byte-identical remote is NOT a conflict (reconciled, no mirror)", async () => {
688
735
  // Root cause of "HQ installs with conflicts": on a first push the
689
736
  // journal is empty, so the fresh-collision branch decides conflict-vs-
package/src/cli/share.ts CHANGED
@@ -70,11 +70,11 @@ import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
70
70
  * Push-side fresh-collision convergence probe.
71
71
  *
72
72
  * For a first push (no journal entry) where the remote object already
73
- * exists AND was multipart-uploaded, the remote etag (`<md5>-<partCount>`)
74
- * is not a content hash, so we cannot tell "byte-identical" from "genuine
75
- * divergence" without looking at the bytes. Fetch the remote object once to
76
- * a throwaway temp file, hash it the same symlink-aware way the planner
77
- * hashed local, and report whether the two contents DIFFER.
73
+ * exists, its etag is a version token rather than a reliable content hash,
74
+ * so we cannot tell "byte-identical" from "genuine divergence" without
75
+ * looking at the bytes. Fetch the remote object once to a throwaway temp
76
+ * file, hash it the same symlink-aware way the planner hashed local, and
77
+ * report whether the two contents DIFFER.
78
78
  *
79
79
  * Returns `true` on a genuine difference (a real fresh collision) and
80
80
  * `false` when the bytes are identical. Fails safe to `true` on any
@@ -1252,34 +1252,24 @@ async function executeUploads(
1252
1252
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
1253
1253
 
1254
1254
  let isFreshCollision = false;
1255
- let multipartConverged = false;
1255
+ let freshContentConverged = false;
1256
1256
  if (!journalEntry && item.kind === "symlink") {
1257
1257
  isFreshCollision = true;
1258
1258
  } else if (!journalEntry && item.kind === "file") {
1259
- const remoteEtagNormalized = normalizeEtag(remoteMeta.etag);
1260
- const isMultipart = /-\d+$/.test(remoteEtagNormalized);
1261
- if (!isMultipart) {
1262
- const localBody = fs.readFileSync(absolutePath);
1263
- const localMd5 = crypto.createHash("md5").update(localBody).digest("hex");
1264
- if (localMd5 !== remoteEtagNormalized) {
1265
- isFreshCollision = true;
1266
- }
1259
+ const remoteDiffers = await remoteContentDiffers(
1260
+ run.ctx,
1261
+ relativePath,
1262
+ localHash,
1263
+ run.hqRoot,
1264
+ );
1265
+ if (remoteDiffers) {
1266
+ isFreshCollision = true;
1267
1267
  } else {
1268
- const remoteDiffers = await remoteContentDiffers(
1269
- run.ctx,
1270
- relativePath,
1271
- localHash,
1272
- run.hqRoot,
1273
- );
1274
- if (remoteDiffers) {
1275
- isFreshCollision = true;
1276
- } else {
1277
- multipartConverged = true;
1278
- }
1268
+ freshContentConverged = true;
1279
1269
  }
1280
1270
  }
1281
1271
 
1282
- if (multipartConverged) {
1272
+ if (freshContentConverged) {
1283
1273
  const lstat = fs.lstatSync(absolutePath);
1284
1274
  updateEntry(
1285
1275
  run.journal,