@indigoai-us/hq-cloud 6.14.3 → 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.
Files changed (50) hide show
  1. package/dist/bin/sync-runner-company.d.ts +1 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +44 -19
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js +25 -36
  7. package/dist/bin/sync-runner.js.map +1 -1
  8. package/dist/bin/sync-runner.test.js +128 -4
  9. package/dist/bin/sync-runner.test.js.map +1 -1
  10. package/dist/cli/reindex.d.ts.map +1 -1
  11. package/dist/cli/reindex.js +209 -29
  12. package/dist/cli/reindex.js.map +1 -1
  13. package/dist/cli/reindex.test.js +116 -37
  14. package/dist/cli/reindex.test.js.map +1 -1
  15. package/dist/cli/rescue-clone-diagnostics.test.d.ts +2 -0
  16. package/dist/cli/rescue-clone-diagnostics.test.d.ts.map +1 -0
  17. package/dist/cli/rescue-clone-diagnostics.test.js +101 -0
  18. package/dist/cli/rescue-clone-diagnostics.test.js.map +1 -0
  19. package/dist/cli/rescue-core.d.ts.map +1 -1
  20. package/dist/cli/rescue-core.js +28 -6
  21. package/dist/cli/rescue-core.js.map +1 -1
  22. package/dist/cli/share.js +11 -22
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +40 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts.map +1 -1
  27. package/dist/cli/sync.js +16 -14
  28. package/dist/cli/sync.js.map +1 -1
  29. package/dist/cli/sync.test.js +73 -0
  30. package/dist/cli/sync.test.js.map +1 -1
  31. package/dist/watcher.d.ts +1 -1
  32. package/dist/watcher.d.ts.map +1 -1
  33. package/dist/watcher.js +5 -6
  34. package/dist/watcher.js.map +1 -1
  35. package/dist/watcher.test.js +54 -0
  36. package/dist/watcher.test.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/bin/sync-runner-company.ts +50 -22
  39. package/src/bin/sync-runner.test.ts +147 -4
  40. package/src/bin/sync-runner.ts +49 -54
  41. package/src/cli/reindex.test.ts +130 -37
  42. package/src/cli/reindex.ts +198 -28
  43. package/src/cli/rescue-clone-diagnostics.test.ts +120 -0
  44. package/src/cli/rescue-core.ts +32 -8
  45. package/src/cli/share.test.ts +47 -0
  46. package/src/cli/share.ts +16 -26
  47. package/src/cli/sync.test.ts +90 -0
  48. package/src/cli/sync.ts +16 -14
  49. package/src/watcher.test.ts +59 -0
  50. package/src/watcher.ts +6 -8
@@ -2,9 +2,9 @@
2
2
  * hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
3
3
  * .claude/skills/<ns>:<skill>/ (one symlink per source file), mirrors
4
4
  * personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
5
- * prunes orphan wrappers + legacy command symlinks, captures this root's Claude
6
- * Code session logs into workspace/.session-logs, and regenerates the workers
7
- * registry.
5
+ * prunes orphan wrappers + legacy command symlinks, captures this HQ tree's
6
+ * coding-harness session logs (Claude Code / Codex / Grok) into
7
+ * workspace/.session-logs/<harness>/, and regenerates the workers registry.
8
8
  *
9
9
  * The logic historically lived in a bundled bash script (scripts/reindex.sh)
10
10
  * that this module exec'd via `bash`. It is now implemented natively in
@@ -199,46 +199,216 @@ function safeSymlink(target: string, linkPath: string, label: string): boolean {
199
199
  }
200
200
  }
201
201
 
202
- // Copy every file/folder under Claude Code's session-log directory for this HQ
203
- // root (`<claude-config>/projects/<encoded-root>`) into
204
- // `workspace/.session-logs`, so the raw session transcripts are captured inside
202
+ // Capture the raw session transcripts of every supported coding harness for
203
+ // this HQ tree into `workspace/.session-logs/<harness>/`, so they live inside
205
204
  // the HQ tree (where sync/search can see them) on each reindex.
206
205
  //
207
- // Contract (per the feature request):
208
- // - copies ALL files and folders from the projects dir into the dest,
209
- // - OVERWRITES existing dest files (force), and
210
- // - NEVER deletes dest entries that lack a source counterpart — this is a
211
- // one-way overlay, not a mirror, so anything already parked in
212
- // workspace/.session-logs survives even after Claude Code rotates or prunes
213
- // its own projects dir.
206
+ // Scope "this HQ tree" = the HQ root cwd OR any working directory UNDER it
207
+ // (worktrees, repo subdirs). Harnesses key their logs by cwd differently, so
208
+ // each helper re-derives the same scope in its own encoding.
214
209
  //
215
- // Best-effort: any failure warns to stderr and is swallowed so it can never
216
- // fail the reindex. `<claude-config>` honors CLAUDE_CONFIG_DIR, else ~/.claude.
210
+ // Contract (per the feature request), applied by every helper:
211
+ // - copies ALL matching files/folders into `.session-logs/<harness>/…`,
212
+ // - OVERWRITES existing dest files, and
213
+ // - NEVER deletes dest entries that lack a source counterpart — a one-way
214
+ // overlay, not a mirror, so anything already parked in `.session-logs`
215
+ // survives even after a harness rotates or prunes its own store.
216
+ //
217
+ // Best-effort throughout: any failure warns to stderr and is swallowed so it
218
+ // can never fail the reindex. Each harness is independent — one failing must
219
+ // not stop the others. Home dirs honor CLAUDE_CONFIG_DIR / CODEX_HOME /
220
+ // GROK_HOME, else ~/.claude, ~/.codex, ~/.grok.
217
221
  function copySessionLogs(root: string): void {
218
- // Claude Code names a project's log dir by replacing every non-alphanumeric
219
- // character in the project's cwd with '-' (e.g. /home/ec2-user/hq ->
220
- // -home-ec2-user-hq). Re-derive that slug so we target THIS root's logs.
221
- const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
222
- const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
223
- const src = path.join(claudeConfigDir, "projects", slug);
224
- if (!isDir(src)) return; // no session logs for this root yet — nothing to do
222
+ const destBase = path.join(root, "workspace", ".session-logs");
223
+ for (const capture of [
224
+ copyClaudeSessionLogs,
225
+ copyCodexSessionLogs,
226
+ copyGrokSessionLogs,
227
+ ]) {
228
+ try {
229
+ capture(root, destBase);
230
+ } catch (err) {
231
+ warn(`reindex: session-log capture step failed (${(err as Error).message}); skipping.`);
232
+ }
233
+ }
234
+ }
225
235
 
226
- const dest = path.join(root, "workspace", ".session-logs");
227
- if (!safeMkdir(dest, "workspace/.session-logs directory")) return;
236
+ /** `fs.statSync` without throwing (follows symlinks; null when missing). */
237
+ function statOrNull(p: string): fs.Stats | null {
238
+ try {
239
+ return fs.statSync(p);
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
228
244
 
245
+ // Overlay-copy a whole directory tree: mkdir dest, then recursive+force cpSync
246
+ // (overwrite existing, never prune dest-only entries). Best-effort — warns and
247
+ // returns on any failure. Shared by the Claude + Grok per-cwd-folder captures.
248
+ function copyTreeOverlay(src: string, dest: string, label: string): void {
249
+ if (!safeMkdir(dest, `${label} directory`)) return;
229
250
  try {
230
- // recursive: copy the whole tree; force: overwrite existing dest files.
231
- // cpSync copies src INTO dest and never removes dest-only entries — exactly
232
- // the "overwrite, but don't delete extras" contract we want.
233
251
  fs.cpSync(src, dest, { recursive: true, force: true });
234
252
  } catch (err) {
235
253
  warn(
236
- `reindex: could not copy session logs from '${src}' to '${dest}' ` +
254
+ `reindex: could not copy ${label} from '${src}' to '${dest}' ` +
237
255
  `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
238
256
  );
239
257
  }
240
258
  }
241
259
 
260
+ // Claude Code: `<claude-config>/projects/<encoded-cwd>/…`, where the cwd is
261
+ // encoded by replacing every non-alphanumeric char with '-'. Because that map
262
+ // is per-character, a descendant cwd's dir name is exactly the root's encoded
263
+ // name + '-' + <encoded-subpath> — so "this HQ tree" is the exact-root dir OR
264
+ // any dir whose name starts with `<slug>-`. Copy each into `.session-logs/claude/`.
265
+ function copyClaudeSessionLogs(root: string, destBase: string): void {
266
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
267
+ const projectsDir = path.join(configDir, "projects");
268
+ if (!isDir(projectsDir)) return;
269
+
270
+ const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
271
+ for (const entry of globEntries(projectsDir)) {
272
+ if (entry !== slug && !entry.startsWith(`${slug}-`)) continue;
273
+ const src = path.join(projectsDir, entry);
274
+ if (!isDir(src)) continue;
275
+ copyTreeOverlay(src, path.join(destBase, "claude", entry), "claude session logs");
276
+ }
277
+ }
278
+
279
+ // Grok: `<grok-home>/sessions/<url-encoded-cwd>/<session-uuid>/…`, where the cwd
280
+ // is URL-encoded (encodeURIComponent: '/' -> '%2F'). A descendant cwd's key is
281
+ // the root's key + '%2F' + <encoded-subpath>, so match the exact-root key OR
282
+ // that prefix. Copy each matching cwd folder into `.session-logs/grok/`.
283
+ function copyGrokSessionLogs(root: string, destBase: string): void {
284
+ const grokHome = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
285
+ const sessionsDir = path.join(grokHome, "sessions");
286
+ if (!isDir(sessionsDir)) return;
287
+
288
+ const key = encodeURIComponent(root);
289
+ for (const entry of globEntries(sessionsDir)) {
290
+ if (entry !== key && !entry.startsWith(`${key}%2F`)) continue;
291
+ const src = path.join(sessionsDir, entry);
292
+ if (!isDir(src)) continue;
293
+ copyTreeOverlay(src, path.join(destBase, "grok", entry), "grok session logs");
294
+ }
295
+ }
296
+
297
+ // Codex: `<codex-home>/sessions/YYYY/MM/DD/rollout-*.jsonl` — a DATE tree, not
298
+ // per-project. The working dir is only recorded inside each rollout's first
299
+ // line (`session_meta`, at `payload.cwd`). Walk the tree and capture a rollout
300
+ // iff its recorded cwd is the HQ root or under it, mirroring the date-relative
301
+ // path under `.session-logs/codex/`. A rollout already copied and unchanged is
302
+ // skipped by mtime, so a steady-state reindex only classifies newly-written
303
+ // rollouts — the full-tree cwd scan is a one-time first-run cost.
304
+ function copyCodexSessionLogs(root: string, destBase: string): void {
305
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
306
+ const sessionsDir = path.join(codexHome, "sessions");
307
+ if (!isDir(sessionsDir)) return;
308
+
309
+ const destDir = path.join(destBase, "codex");
310
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
311
+
312
+ for (const abs of walkFiles(sessionsDir)) {
313
+ if (!abs.endsWith(".jsonl")) continue;
314
+ const srcStat = statOrNull(abs);
315
+ if (!srcStat) continue;
316
+
317
+ const dest = path.join(destDir, path.relative(sessionsDir, abs));
318
+ // Cheap skip: already captured and unchanged since (dest mtime is the copy
319
+ // time, so it stays >= src mtime until the rollout is appended to again).
320
+ const destStat = statOrNull(dest);
321
+ if (destStat && destStat.mtimeMs >= srcStat.mtimeMs) continue;
322
+
323
+ const cwd = readCodexRolloutCwd(abs);
324
+ if (cwd === null) continue;
325
+ if (cwd !== root && !cwd.startsWith(rootPrefix)) continue;
326
+
327
+ if (!safeMkdir(path.dirname(dest), "codex session-log dir")) continue;
328
+ try {
329
+ fs.copyFileSync(abs, dest);
330
+ } catch (err) {
331
+ warn(
332
+ `reindex: could not copy codex session log '${abs}' -> '${dest}' ` +
333
+ `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
334
+ );
335
+ }
336
+ }
337
+ }
338
+
339
+ // Recursively collect every regular file under `dir` (absolute paths). Symlinks
340
+ // are not followed (lstat gate) so a stray link can't escape the tree or loop.
341
+ // Missing/unreadable dirs yield []. Best-effort — mirrors globEntries' tolerance.
342
+ function walkFiles(dir: string): string[] {
343
+ const out: string[] = [];
344
+ let names: string[];
345
+ try {
346
+ names = fs.readdirSync(dir);
347
+ } catch {
348
+ return out;
349
+ }
350
+ for (const name of names) {
351
+ const abs = path.join(dir, name);
352
+ const st = lstatOrNull(abs);
353
+ if (!st) continue;
354
+ if (st.isDirectory()) out.push(...walkFiles(abs));
355
+ else if (st.isFile()) out.push(abs);
356
+ }
357
+ return out;
358
+ }
359
+
360
+ // Extract `payload.cwd` (Codex ≥ the session_meta format; falls back to a
361
+ // top-level `cwd`) from a rollout's first line, reading only that line. Codex
362
+ // records cwd early, before the large `base_instructions` blob, but the line
363
+ // can still be tens of KB — so read up to a generous cap and stop at the first
364
+ // newline. Returns null on any parse/read failure (the rollout is then skipped).
365
+ function readCodexRolloutCwd(file: string): string | null {
366
+ const line = readFirstLine(file, 4 * 1024 * 1024);
367
+ if (line === null) return null;
368
+ try {
369
+ const obj = JSON.parse(line) as { cwd?: unknown; payload?: { cwd?: unknown } };
370
+ const cwd = obj?.payload?.cwd ?? obj?.cwd;
371
+ return typeof cwd === "string" ? cwd : null;
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ // Read the first line of `file` (up to `capBytes`), returning it WITHOUT the
378
+ // trailing newline, or null on error / empty. Byte-accumulates and decodes once
379
+ // so a multibyte char split across a read boundary can't corrupt the result.
380
+ function readFirstLine(file: string, capBytes: number): string | null {
381
+ let fd: number | null = null;
382
+ try {
383
+ fd = fs.openSync(file, "r");
384
+ const chunks: Buffer[] = [];
385
+ const chunk = Buffer.allocUnsafe(65536);
386
+ let total = 0;
387
+ while (total < capBytes) {
388
+ const n = fs.readSync(fd, chunk, 0, chunk.length, total);
389
+ if (n <= 0) break;
390
+ const nl = chunk.indexOf(0x0a, 0);
391
+ if (nl !== -1 && nl < n) {
392
+ chunks.push(Buffer.from(chunk.subarray(0, nl)));
393
+ return Buffer.concat(chunks).toString("utf8");
394
+ }
395
+ chunks.push(Buffer.from(chunk.subarray(0, n)));
396
+ total += n;
397
+ }
398
+ return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
399
+ } catch {
400
+ return null;
401
+ } finally {
402
+ if (fd !== null) {
403
+ try {
404
+ fs.closeSync(fd);
405
+ } catch {
406
+ /* best-effort */
407
+ }
408
+ }
409
+ }
410
+ }
411
+
242
412
  // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
243
413
  // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
244
414
  function isLegacyCommandTarget(t: string): boolean {
@@ -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,
@@ -3518,6 +3518,17 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
3518
3518
  (fetchMock.mock.calls as Array<[string, RequestInit?]>)
3519
3519
  .filter(([u]) => String(u).includes("/v1/notify/file-added"))
3520
3520
  .map(([, init]) => JSON.parse(String(init!.body)).files.length);
3521
+ const notificationTelemetryEvents = (
3522
+ fetchMock: ReturnType<typeof vi.fn>,
3523
+ ): Array<{ eventName: string; properties?: Record<string, unknown> }> =>
3524
+ (fetchMock.mock.calls as Array<[string, RequestInit?]>)
3525
+ .filter(([u]) => String(u).includes("/v1/telemetry/events"))
3526
+ .flatMap(([, init]) =>
3527
+ JSON.parse(String(init!.body)).events as Array<{
3528
+ eventName: string;
3529
+ properties?: Record<string, unknown>;
3530
+ }>,
3531
+ );
3521
3532
 
3522
3533
  afterEach(() => {
3523
3534
  vi.unstubAllGlobals();
@@ -3580,6 +3591,85 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
3580
3591
  expect(notifyBatchSizes(fetchMock)).toEqual([1000, 1000, 1]);
3581
3592
  });
3582
3593
 
3594
+ it("does not emit notification telemetry after a successful report", async () => {
3595
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
3596
+ if (url.includes("/v1/notify/file-added")) {
3597
+ return { ok: true, status: 200, text: async () => "" };
3598
+ }
3599
+ return {
3600
+ ok: true,
3601
+ status: 200,
3602
+ text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
3603
+ };
3604
+ });
3605
+ vi.stubGlobal("fetch", fetchMock);
3606
+
3607
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
3608
+ await new Promise((resolve) => setTimeout(resolve, 0));
3609
+
3610
+ expect(notificationTelemetryEvents(fetchMock)).toEqual([]);
3611
+ });
3612
+
3613
+ it("emits failure telemetry for a non-successful notification response", async () => {
3614
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
3615
+ if (url.includes("/v1/notify/file-added")) {
3616
+ return { ok: false, status: 503, text: async () => "unavailable" };
3617
+ }
3618
+ return {
3619
+ ok: true,
3620
+ status: 200,
3621
+ text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
3622
+ };
3623
+ });
3624
+ vi.stubGlobal("fetch", fetchMock);
3625
+
3626
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
3627
+ await new Promise((resolve) => setTimeout(resolve, 0));
3628
+
3629
+ expect(notificationTelemetryEvents(fetchMock)).toEqual([
3630
+ expect.objectContaining({
3631
+ eventName: "new_files_notification_reported",
3632
+ properties: {
3633
+ status: "failure",
3634
+ fileCount: 1,
3635
+ batchIndex: 1,
3636
+ batchCount: 1,
3637
+ statusCode: 503,
3638
+ },
3639
+ }),
3640
+ ]);
3641
+ });
3642
+
3643
+ it("emits error-class telemetry when notification reporting throws", async () => {
3644
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
3645
+ if (url.includes("/v1/notify/file-added")) {
3646
+ throw new TypeError("network down");
3647
+ }
3648
+ return {
3649
+ ok: true,
3650
+ status: 200,
3651
+ text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
3652
+ };
3653
+ });
3654
+ vi.stubGlobal("fetch", fetchMock);
3655
+
3656
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
3657
+ await new Promise((resolve) => setTimeout(resolve, 0));
3658
+
3659
+ expect(notificationTelemetryEvents(fetchMock)).toEqual([
3660
+ expect.objectContaining({
3661
+ eventName: "new_files_notification_reported",
3662
+ properties: {
3663
+ status: "failure",
3664
+ fileCount: 1,
3665
+ batchIndex: 1,
3666
+ batchCount: 1,
3667
+ errorClass: "TypeError",
3668
+ },
3669
+ }),
3670
+ ]);
3671
+ });
3672
+
3583
3673
  it("no request at all when there are no new files", async () => {
3584
3674
  const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
3585
3675
  vi.stubGlobal("fetch", fetchMock);