@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/dist/cli/rescue-clone-diagnostics.test.d.ts +2 -0
- package/dist/cli/rescue-clone-diagnostics.test.d.ts.map +1 -0
- package/dist/cli/rescue-clone-diagnostics.test.js +101 -0
- package/dist/cli/rescue-clone-diagnostics.test.js.map +1 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +28 -6
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.js +11 -22
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +40 -0
- package/dist/cli/share.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/rescue-clone-diagnostics.test.ts +120 -0
- package/src/cli/rescue-core.ts +32 -8
- package/src/cli/share.test.ts +47 -0
- package/src/cli/share.ts +16 -26
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/cli/rescue-core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/share.test.ts
CHANGED
|
@@ -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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
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 (
|
|
1272
|
+
if (freshContentConverged) {
|
|
1283
1273
|
const lstat = fs.lstatSync(absolutePath);
|
|
1284
1274
|
updateEntry(
|
|
1285
1275
|
run.journal,
|