@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.
- package/dist/bin/sync-runner-company.d.ts +1 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +44 -19
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +25 -36
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +128 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +209 -29
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +116 -37
- package/dist/cli/reindex.test.js.map +1 -1
- 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/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +16 -14
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +73 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/watcher.d.ts +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +5 -6
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +54 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +50 -22
- package/src/bin/sync-runner.test.ts +147 -4
- package/src/bin/sync-runner.ts +49 -54
- package/src/cli/reindex.test.ts +130 -37
- package/src/cli/reindex.ts +198 -28
- 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/src/cli/sync.test.ts +90 -0
- package/src/cli/sync.ts +16 -14
- package/src/watcher.test.ts +59 -0
- package/src/watcher.ts +6 -8
package/src/cli/reindex.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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
|
-
//
|
|
203
|
-
//
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
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
|
-
//
|
|
216
|
-
//
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
|
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
|
+
});
|
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,
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -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);
|