@indigoai-us/hq-cloud 6.14.4 → 6.14.6

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.6",
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
  }
@@ -595,6 +595,60 @@ describe("share", () => {
595
595
  });
596
596
  });
597
597
 
598
+ it("cloud-authoritative: never pushes a stale local ontology/.last-run over the server value (one-sided-change clobber guard)", async () => {
599
+ // Regression for the gardener watermark clobber. A second HQ root sharing this
600
+ // machine's sync journal can leave the local mirror holding a STALE value A while
601
+ // the shared journal has already been advanced to the gardener's value B (etag EB).
602
+ // On this root's push leg that reads as localChanged (A != journal B) &&
603
+ // !remoteChanged (remote etag EB == journal remoteEtag EB). Previously that
604
+ // one-sided combination slipped past the CONFLICT-scoped isCloudAuthoritative
605
+ // fence and PUT the stale local value back over the server's — rewinding it. The
606
+ // fence now runs at the TOP of processUploadItem, so any ontology/ (server-owned)
607
+ // path is skipped before HEAD/upload regardless of the divergence shape.
608
+ const companyRoot = path.join(tmpDir, "companies", "acme");
609
+ fs.mkdirSync(path.join(companyRoot, "ontology"), { recursive: true });
610
+ const testFile = path.join(companyRoot, "ontology", ".last-run");
611
+ fs.writeFileSync(testFile, "1782275168000"); // stale value A
612
+
613
+ // The journal is already advanced to the gardener's value B (a sibling HQ root's
614
+ // pull stamped the shared journal), so localChanged=true. The fence skips the file
615
+ // BEFORE any HEAD/upload, so we deliberately queue NO headRemoteFile response and
616
+ // assert it is never consulted below — the skip is cheap, and a leftover
617
+ // mockResolvedValueOnce would otherwise leak into a later test's HEAD queue.
618
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
619
+ fs.writeFileSync(
620
+ journalPath,
621
+ JSON.stringify({
622
+ version: "1",
623
+ lastSync: new Date().toISOString(),
624
+ files: {
625
+ "ontology/.last-run": {
626
+ hash: "advanced-value-B-hash", // != hash(A) → localChanged=true
627
+ size: 13,
628
+ syncedAt: new Date().toISOString(),
629
+ direction: "up",
630
+ remoteEtag: "eb-advanced-etag", // == remote → remoteChanged=false
631
+ },
632
+ },
633
+ }),
634
+ );
635
+
636
+ const result = await share({
637
+ paths: [testFile],
638
+ company: "acme",
639
+ vaultConfig: mockConfig,
640
+ hqRoot: tmpDir,
641
+ });
642
+
643
+ // Server-owned file: push is skipped BEFORE HEAD, the stale local value is NEVER
644
+ // uploaded (the pull leg refreshes local from cloud instead), and it is not a conflict.
645
+ expect(uploadFile).not.toHaveBeenCalled();
646
+ expect(headRemoteFile).not.toHaveBeenCalled();
647
+ expect(result.filesUploaded).toBe(0);
648
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
649
+ expect(result.conflictPaths).toEqual([]);
650
+ });
651
+
598
652
  it("first-time-upload-with-cloud-collision: emits conflict + writes mirror under --on-conflict keep (Bug #7)", async () => {
599
653
  // Bug #7 (data-loss class) from the 5.33.0 deep test: when a file has
600
654
  // NO prior journal entry (fresh upload from this machine) but the
@@ -684,6 +738,53 @@ describe("share", () => {
684
738
  expect(result.filesUploaded).toBe(0);
685
739
  });
686
740
 
741
+ it("fresh-install SSE-KMS etag: byte-identical remote is reconciled without a conflict", async () => {
742
+ const companyRoot = path.join(tmpDir, "companies", "acme");
743
+ fs.mkdirSync(companyRoot, { recursive: true });
744
+ const testFile = path.join(companyRoot, "kms-scaffold.md");
745
+ const scaffoldBytes = "identical-content-encrypted-with-sse-kms";
746
+ fs.writeFileSync(testFile, scaffoldBytes);
747
+
748
+ // A 32-hex SSE-KMS ETag is not the MD5 of the plaintext object.
749
+ vi.mocked(headRemoteFile).mockResolvedValueOnce({
750
+ lastModified: new Date(),
751
+ etag: '"288bfa91684ae21f9ae27c061254d412"',
752
+ size: scaffoldBytes.length,
753
+ });
754
+ vi.mocked(downloadFile).mockImplementationOnce(async (_ctx, _key, dest) => {
755
+ fs.writeFileSync(dest as string, scaffoldBytes);
756
+ return undefined as never;
757
+ });
758
+
759
+ const events: unknown[] = [];
760
+ const result = await share({
761
+ paths: [testFile],
762
+ company: "acme",
763
+ vaultConfig: mockConfig,
764
+ hqRoot: tmpDir,
765
+ onConflict: "keep",
766
+ onEvent: (e) => events.push(e),
767
+ });
768
+
769
+ expect(result.conflictPaths).toEqual([]);
770
+ expect(result.filesUploaded).toBe(0);
771
+ expect(uploadFile).not.toHaveBeenCalled();
772
+ expect(events).toContainEqual({
773
+ type: "reconciled",
774
+ path: "kms-scaffold.md",
775
+ direction: "push",
776
+ });
777
+ expect(fs.readdirSync(companyRoot).filter((name) => name.includes(".conflict-"))).toEqual([]);
778
+
779
+ const journal = JSON.parse(
780
+ fs.readFileSync(path.join(stateDir, "sync-journal.acme.json"), "utf-8"),
781
+ ) as { files: Record<string, { direction: string; remoteEtag?: string }> };
782
+ expect(journal.files["kms-scaffold.md"]).toMatchObject({
783
+ direction: "up",
784
+ remoteEtag: "288bfa91684ae21f9ae27c061254d412",
785
+ });
786
+ });
787
+
687
788
  it("fresh-install multipart collision: byte-identical remote is NOT a conflict (reconciled, no mirror)", async () => {
688
789
  // Root cause of "HQ installs with conflicts": on a first push the
689
790
  // 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
@@ -1232,6 +1232,19 @@ async function executeUploads(
1232
1232
  if (aborted) return;
1233
1233
  const { absolutePath, relativePath, localHash } = item;
1234
1234
 
1235
+ // Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
1236
+ // ontology/ signals/ sources/) are PULL-WINS and server-owned. The push leg must
1237
+ // NEVER upload the local copy over them — not just on a two-sided conflict, but on
1238
+ // ANY divergence, including a one-sided local change. A stale local mirror (e.g. a
1239
+ // second HQ root that shares this machine's sync journal) would otherwise read as
1240
+ // `localChanged && !remoteChanged`, slip past the conflict-scoped guard below, and
1241
+ // rewind the server's value — the `.last-run` watermark clobber. Skip before HEAD.
1242
+ if (isCloudAuthoritative(relativePath)) {
1243
+ run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1244
+ counters.filesSkipped++;
1245
+ return;
1246
+ }
1247
+
1235
1248
  if (run.vaultConfig && isExpiringSoon(run.ctx.expiresAt)) {
1236
1249
  run.ctx = await refreshEntityContext(run.companyRef, run.vaultConfig);
1237
1250
  }
@@ -1252,34 +1265,24 @@ async function executeUploads(
1252
1265
  const remoteChanged = !!journalEntry && hasRemoteChanged(remoteMeta, journalEntry);
1253
1266
 
1254
1267
  let isFreshCollision = false;
1255
- let multipartConverged = false;
1268
+ let freshContentConverged = false;
1256
1269
  if (!journalEntry && item.kind === "symlink") {
1257
1270
  isFreshCollision = true;
1258
1271
  } 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
- }
1272
+ const remoteDiffers = await remoteContentDiffers(
1273
+ run.ctx,
1274
+ relativePath,
1275
+ localHash,
1276
+ run.hqRoot,
1277
+ );
1278
+ if (remoteDiffers) {
1279
+ isFreshCollision = true;
1267
1280
  } 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
- }
1281
+ freshContentConverged = true;
1279
1282
  }
1280
1283
  }
1281
1284
 
1282
- if (multipartConverged) {
1285
+ if (freshContentConverged) {
1283
1286
  const lstat = fs.lstatSync(absolutePath);
1284
1287
  updateEntry(
1285
1288
  run.journal,
@@ -1296,16 +1299,9 @@ async function executeUploads(
1296
1299
  }
1297
1300
 
1298
1301
  if ((localChanged && remoteChanged) || isFreshCollision) {
1299
- // Cloud-authoritative paths (server-regenerated: company-brief.md,
1300
- // board.json, ontology/ signals/ sources/) are PULL-WINS. Never push the
1301
- // local copy over the server-owned version and never mint a `.conflict-`
1302
- // mirror — the pull leg overwrites local from cloud. Declining here is
1303
- // what stops the per-sync conflict-mirror loop on these files.
1304
- if (isCloudAuthoritative(relativePath)) {
1305
- run.emit({ type: "reconciled", path: relativePath, direction: "push" });
1306
- counters.filesSkipped++;
1307
- return;
1308
- }
1302
+ // Cloud-authoritative paths are already skipped at the top of
1303
+ // processUploadItem (before HEAD), so anything reaching here is a genuine
1304
+ // conflict on a client-owned file.
1309
1305
  conflictPaths.push(relativePath);
1310
1306
 
1311
1307
  const resolution = await resolveConflictSerialized({