@indigoai-us/hq-cloud 6.14.22 → 6.14.24
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.map +1 -1
- package/dist/bin/sync-runner-company.js +20 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-events.test.js +22 -0
- package/dist/bin/sync-runner-events.test.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +11 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +37 -2
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +1 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +17 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +33 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +28 -3
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +206 -2
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/doctor.d.ts +119 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +485 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/doctor.test.d.ts +13 -0
- package/dist/cli/doctor.test.d.ts.map +1 -0
- package/dist/cli/doctor.test.js +485 -0
- package/dist/cli/doctor.test.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/share.js +6 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +1 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +54 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +223 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +394 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.test.js +7 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/conflict-file.d.ts.map +1 -1
- package/dist/lib/conflict-file.js +5 -1
- package/dist/lib/conflict-file.js.map +1 -1
- package/dist/lib/conflict-index.d.ts +7 -0
- package/dist/lib/conflict-index.d.ts.map +1 -1
- package/dist/lib/conflict-index.js +10 -2
- package/dist/lib/conflict-index.js.map +1 -1
- package/dist/lib/conflict.test.js +31 -0
- package/dist/lib/conflict.test.js.map +1 -1
- package/dist/lib/machine-id.test.js +14 -0
- package/dist/lib/machine-id.test.js.map +1 -1
- package/dist/local-path-codec.d.ts +18 -0
- package/dist/local-path-codec.d.ts.map +1 -1
- package/dist/local-path-codec.js +63 -0
- package/dist/local-path-codec.js.map +1 -1
- package/dist/local-path-codec.test.d.ts +14 -0
- package/dist/local-path-codec.test.d.ts.map +1 -0
- package/dist/local-path-codec.test.js +102 -0
- package/dist/local-path-codec.test.js.map +1 -0
- package/dist/machine-auth.test.js +7 -0
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/s3.d.ts +5 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +129 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.symlink-materialize.test.d.ts +18 -0
- package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
- package/dist/s3.symlink-materialize.test.js +394 -0
- package/dist/s3.symlink-materialize.test.js.map +1 -0
- package/dist/s3.test.js +19 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +23 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +17 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +10 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +28 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +18 -0
- package/src/bin/sync-runner-events.test.ts +31 -0
- package/src/bin/sync-runner-planning.test.ts +1 -0
- package/src/bin/sync-runner-planning.ts +46 -3
- package/src/bin/sync-runner-watch-loop.ts +19 -1
- package/src/bin/sync-runner.test.ts +236 -1
- package/src/bin/sync-runner.ts +57 -2
- package/src/cli/doctor.test.ts +581 -0
- package/src/cli/doctor.ts +640 -0
- package/src/cli/index.ts +14 -0
- package/src/cli/share.test.ts +1 -0
- package/src/cli/share.ts +6 -1
- package/src/cli/sync.test.ts +473 -1
- package/src/cli/sync.ts +310 -3
- package/src/cognito-auth.test.ts +5 -0
- package/src/index.ts +10 -0
- package/src/lib/conflict-file.ts +6 -1
- package/src/lib/conflict-index.ts +18 -2
- package/src/lib/conflict.test.ts +33 -0
- package/src/lib/machine-id.test.ts +10 -0
- package/src/local-path-codec.test.ts +138 -0
- package/src/local-path-codec.ts +66 -0
- package/src/machine-auth.test.ts +5 -0
- package/src/s3.symlink-materialize.test.ts +492 -0
- package/src/s3.test.ts +24 -0
- package/src/s3.ts +148 -4
- package/src/skill-telemetry.test.ts +19 -0
- package/src/skill-telemetry.ts +31 -2
- package/src/watcher.test.ts +33 -0
- package/src/watcher.ts +10 -1
package/src/s3.ts
CHANGED
|
@@ -317,7 +317,13 @@ function removeTempPath(tempPath: string): void {
|
|
|
317
317
|
try {
|
|
318
318
|
fs.unlinkSync(tempPath);
|
|
319
319
|
} catch {
|
|
320
|
-
//
|
|
320
|
+
// A win32 directory-type symlink is a directory reparse point and
|
|
321
|
+
// refuses unlink (EPERM); rmdir is the correct removal call for it.
|
|
322
|
+
try {
|
|
323
|
+
fs.rmdirSync(tempPath);
|
|
324
|
+
} catch {
|
|
325
|
+
// Best-effort cleanup; do not mask the transfer/materialization error.
|
|
326
|
+
}
|
|
321
327
|
}
|
|
322
328
|
}
|
|
323
329
|
|
|
@@ -368,6 +374,69 @@ export function createStagedSymlink(
|
|
|
368
374
|
ops.symlink(absTarget, linkPath, "junction");
|
|
369
375
|
}
|
|
370
376
|
|
|
377
|
+
/**
|
|
378
|
+
* downloadFile's `options.win32` seam is the authority for symlink-flavor
|
|
379
|
+
* decisions (it is what makes Windows semantics testable on POSIX CI), so the
|
|
380
|
+
* staged-link helper must see the SAME platform the idempotence fast path
|
|
381
|
+
* reasoned about rather than the raw `process.platform`. createStagedSymlink
|
|
382
|
+
* only ever asks "is this win32?", so "linux" is a faithful stand-in for
|
|
383
|
+
* "not Windows" on the false branch.
|
|
384
|
+
*/
|
|
385
|
+
function stagedSymlinkOpsFor(win32: boolean): CreateStagedSymlinkOps {
|
|
386
|
+
return {
|
|
387
|
+
...DEFAULT_CREATE_STAGED_SYMLINK_OPS,
|
|
388
|
+
platform: win32 ? "win32" : "linux",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Would createStagedSymlink() materialize `target` as a DIRECTORY-flavored
|
|
394
|
+
* link (a junction) rather than a file symlink? Mirrors that helper's rule
|
|
395
|
+
* exactly — only a target confirmed to be a non-directory earns a file
|
|
396
|
+
* symlink; directories and unresolvable targets become junctions, which need
|
|
397
|
+
* no Windows privilege. Keeping the two in lockstep is what lets the
|
|
398
|
+
* idempotence fast path below decide whether an existing link is already the
|
|
399
|
+
* link we would mint today.
|
|
400
|
+
*/
|
|
401
|
+
function win32StagedLinkIsDirectoryFlavored(
|
|
402
|
+
target: string,
|
|
403
|
+
linkPath: string,
|
|
404
|
+
ops: CreateStagedSymlinkOps = DEFAULT_CREATE_STAGED_SYMLINK_OPS,
|
|
405
|
+
): boolean {
|
|
406
|
+
const absTarget = path.isAbsolute(target)
|
|
407
|
+
? target
|
|
408
|
+
: path.resolve(path.dirname(linkPath), target);
|
|
409
|
+
return ops.statIsDirectory(absTarget) !== false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Compare an on-disk readlink() result against an incoming wire target.
|
|
414
|
+
*
|
|
415
|
+
* POSIX compares exactly: we write the wire target verbatim, so readlink()
|
|
416
|
+
* returns it byte-for-byte, and a backslash is an ordinary filename character
|
|
417
|
+
* there. On win32 neither property holds — createStagedSymlink() materializes
|
|
418
|
+
* directory overlays as JUNCTIONS, which require an ABSOLUTE substitute path,
|
|
419
|
+
* and Node rewrites '/' → '\\' at symlink() time. A raw string compare would
|
|
420
|
+
* therefore never match on Windows and the fast path would never fire for the
|
|
421
|
+
* .claude/skills link farm (the exact perpetual-churn bug this guards). So
|
|
422
|
+
* win32 equality resolves BOTH sides against the link's parent directory and
|
|
423
|
+
* compares separator-insensitively, which normalizes the relative wire target
|
|
424
|
+
* and the absolute junction target onto the same footing.
|
|
425
|
+
*/
|
|
426
|
+
function symlinkTargetsEqual(
|
|
427
|
+
existing: string,
|
|
428
|
+
incoming: string,
|
|
429
|
+
linkPath: string,
|
|
430
|
+
win32: boolean,
|
|
431
|
+
): boolean {
|
|
432
|
+
if (!win32) return existing === incoming;
|
|
433
|
+
const normalize = (value: string): string =>
|
|
434
|
+
path
|
|
435
|
+
.resolve(path.dirname(linkPath), value.replace(/\\/g, "/"))
|
|
436
|
+
.replace(/\\/g, "/");
|
|
437
|
+
return normalize(existing) === normalize(incoming);
|
|
438
|
+
}
|
|
439
|
+
|
|
371
440
|
export interface ReplaceStagedPathOps {
|
|
372
441
|
lstat(path: string): fs.Stats;
|
|
373
442
|
rename(from: string, to: string): void;
|
|
@@ -377,7 +446,19 @@ export interface ReplaceStagedPathOps {
|
|
|
377
446
|
const DEFAULT_REPLACE_STAGED_PATH_OPS: ReplaceStagedPathOps = {
|
|
378
447
|
lstat: (p) => fs.lstatSync(p),
|
|
379
448
|
rename: (from, to) => fs.renameSync(from, to),
|
|
380
|
-
remove: (p) =>
|
|
449
|
+
remove: (p) => {
|
|
450
|
+
try {
|
|
451
|
+
fs.unlinkSync(p);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
// A win32 directory-type symlink or junction is a directory reparse
|
|
454
|
+
// point; some runtimes refuse unlink (EPERM/EISDIR) and need rmdir —
|
|
455
|
+
// same fallback as removeTempPath. Anything else stays loud so
|
|
456
|
+
// replaceStagedPath can roll back.
|
|
457
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
458
|
+
if (code !== "EPERM" && code !== "EISDIR") throw err;
|
|
459
|
+
fs.rmdirSync(p);
|
|
460
|
+
}
|
|
461
|
+
},
|
|
381
462
|
};
|
|
382
463
|
|
|
383
464
|
function replacementBackupPath(localPath: string): string {
|
|
@@ -1060,7 +1141,14 @@ export async function downloadFile(
|
|
|
1060
1141
|
ctx: EntityContext,
|
|
1061
1142
|
key: string,
|
|
1062
1143
|
localPath: string,
|
|
1063
|
-
options: {
|
|
1144
|
+
options: {
|
|
1145
|
+
beforeReplace?: () => void;
|
|
1146
|
+
/**
|
|
1147
|
+
* Platform override so win32 symlink semantics are directly testable
|
|
1148
|
+
* on any host (same convention as local-path-codec.ts).
|
|
1149
|
+
*/
|
|
1150
|
+
win32?: boolean;
|
|
1151
|
+
} = {},
|
|
1064
1152
|
): Promise<{
|
|
1065
1153
|
metadata?: Record<string, string>;
|
|
1066
1154
|
contentHash?: string;
|
|
@@ -1151,9 +1239,65 @@ export async function downloadFile(
|
|
|
1151
1239
|
);
|
|
1152
1240
|
}
|
|
1153
1241
|
|
|
1242
|
+
// Idempotence fast path: the .claude/skills link farm makes symlink
|
|
1243
|
+
// records the most re-pulled object class, and a pull that changes
|
|
1244
|
+
// nothing must not touch disk at all (pre-fix, an already-correct
|
|
1245
|
+
// Windows dir link still went through temp+rename, EPERM'd every
|
|
1246
|
+
// pass, never journaled, and re-downloaded forever). On POSIX a
|
|
1247
|
+
// target-string match is sufficient (lstat + readlink — never stat:
|
|
1248
|
+
// dangling links are legitimate). On win32 a matching target string is
|
|
1249
|
+
// NOT sufficient — links are flavored, and a file-type link at a
|
|
1250
|
+
// directory target (minted by an older client, or while the target was
|
|
1251
|
+
// absent) is broken despite the matching string. There the fast path
|
|
1252
|
+
// additionally requires stat-follow to confirm the link FUNCTIONS as
|
|
1253
|
+
// the type win32SymlinkType() would mint now; anything unconfirmable
|
|
1254
|
+
// (including a dangling link, whose flavor cannot be probed) falls
|
|
1255
|
+
// through to replacement, which is cheap and journals correctly.
|
|
1256
|
+
const win32 = options.win32 ?? process.platform === "win32";
|
|
1257
|
+
let existingLink: fs.Stats | null = null;
|
|
1258
|
+
try {
|
|
1259
|
+
existingLink = fs.lstatSync(localPath);
|
|
1260
|
+
} catch {
|
|
1261
|
+
// Absent (or unreadable) destination: no fast path; the normal
|
|
1262
|
+
// materialization below surfaces real filesystem faults loudly.
|
|
1263
|
+
}
|
|
1264
|
+
if (existingLink?.isSymbolicLink()) {
|
|
1265
|
+
let existingTarget: string | null = null;
|
|
1266
|
+
try {
|
|
1267
|
+
existingTarget = fs.readlinkSync(localPath);
|
|
1268
|
+
} catch {
|
|
1269
|
+
// Link raced away between lstat and readlink; replace it below.
|
|
1270
|
+
}
|
|
1271
|
+
if (
|
|
1272
|
+
existingTarget !== null &&
|
|
1273
|
+
symlinkTargetsEqual(existingTarget, symlinkTarget, localPath, win32)
|
|
1274
|
+
) {
|
|
1275
|
+
if (!win32) return { metadata };
|
|
1276
|
+
const desiredIsDirectory = win32StagedLinkIsDirectoryFlavored(
|
|
1277
|
+
symlinkTarget,
|
|
1278
|
+
localPath,
|
|
1279
|
+
);
|
|
1280
|
+
let followed: fs.Stats | null = null;
|
|
1281
|
+
try {
|
|
1282
|
+
followed = fs.statSync(localPath);
|
|
1283
|
+
} catch {
|
|
1284
|
+
// Unresolvable via this link (dangling, or a wrong-flavor link
|
|
1285
|
+
// that Windows refuses to traverse): recreate below.
|
|
1286
|
+
}
|
|
1287
|
+
if (followed !== null && followed.isDirectory() === desiredIsDirectory) {
|
|
1288
|
+
return { metadata };
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1154
1293
|
const tempPath = downloadTempPath(localPath);
|
|
1155
1294
|
try {
|
|
1156
|
-
createStagedSymlink(
|
|
1295
|
+
// Creation goes through createStagedSymlink (#219): on win32 a directory
|
|
1296
|
+
// overlay becomes an unprivileged JUNCTION rather than a 'dir' NTFS
|
|
1297
|
+
// symlink, which is what stops the EPERM that pushed the one-shot runner
|
|
1298
|
+
// to PARTIAL_SYNC_EXIT. The staged link lands beside `localPath`, so its
|
|
1299
|
+
// flavor decision resolves the target identically to the fast path above.
|
|
1300
|
+
createStagedSymlink(symlinkTarget, tempPath, stagedSymlinkOpsFor(win32));
|
|
1157
1301
|
options.beforeReplace?.();
|
|
1158
1302
|
replaceStagedPath(tempPath, localPath);
|
|
1159
1303
|
} catch (err) {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
computeSkillVersion,
|
|
14
14
|
readFileRegion,
|
|
15
15
|
} from "./skill-telemetry.js";
|
|
16
|
+
import { encodeLocalVaultSegment } from "./local-path-codec.js";
|
|
16
17
|
import type { SkillInvocationBatch } from "./vault-client.js";
|
|
17
18
|
|
|
18
19
|
describe("extractSkillEvents", () => {
|
|
@@ -1364,6 +1365,24 @@ describe("computeSkillVersion (US-015)", () => {
|
|
|
1364
1365
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
1365
1366
|
});
|
|
1366
1367
|
|
|
1368
|
+
it("resolves an ALREADY-ENCODED local segment without double-encoding (win32 Codex path capture)", async () => {
|
|
1369
|
+
// Codex telemetry extraction captures the on-disk wrapper name from a
|
|
1370
|
+
// win32 path (`indigo%3Ahello-world`); encoding that again would look
|
|
1371
|
+
// under `indigo%253A...` and miss. Normalization decodes to canonical
|
|
1372
|
+
// first, then re-encodes for THIS host.
|
|
1373
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
|
|
1374
|
+
const body = "encoded-name skill";
|
|
1375
|
+
const hostDirName = encodeLocalVaultSegment("indigo:hello-world");
|
|
1376
|
+
const dir = path.join(tmp, ".claude", "skills", hostDirName);
|
|
1377
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1378
|
+
await fs.writeFile(path.join(dir, "SKILL.md"), body, "utf-8");
|
|
1379
|
+
|
|
1380
|
+
expect(await computeSkillVersion(tmp, "indigo%3Ahello-world")).toBe(sha256(body));
|
|
1381
|
+
// A decoded separator can never smuggle past the traversal guard.
|
|
1382
|
+
expect(await computeSkillVersion(tmp, "a%2Fb")).toBeUndefined();
|
|
1383
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1367
1386
|
it("falls back to .agents/skills/ (Codex bridge layout)", async () => {
|
|
1368
1387
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "skill-ver-"));
|
|
1369
1388
|
const body = "codex-bridged skill\n";
|
package/src/skill-telemetry.ts
CHANGED
|
@@ -66,6 +66,10 @@ import {
|
|
|
66
66
|
resolveCompanyForSkill,
|
|
67
67
|
type RepoCompanyMap,
|
|
68
68
|
} from "./company-resolver.js";
|
|
69
|
+
import {
|
|
70
|
+
canonicalVaultSegmentSpelling,
|
|
71
|
+
encodeLocalVaultSegment,
|
|
72
|
+
} from "./local-path-codec.js";
|
|
69
73
|
import type {
|
|
70
74
|
SkillInvocationBatch,
|
|
71
75
|
SkillInvocationIngestResult,
|
|
@@ -621,10 +625,35 @@ export async function computeSkillVersion(
|
|
|
621
625
|
if (skill.includes("/") || skill.includes("\\") || skill.includes("..")) {
|
|
622
626
|
return undefined;
|
|
623
627
|
}
|
|
628
|
+
// Codec boundary: callers hand us EITHER the canonical invocation name
|
|
629
|
+
// (`indigo:hello-world`) or an already-encoded local dir segment captured
|
|
630
|
+
// from a win32 path (`indigo%3Ahello-world`, Codex telemetry extraction).
|
|
631
|
+
// Normalize to canonical first — encoding an already-encoded name would
|
|
632
|
+
// look under `indigo%253A...` and miss the real wrapper — then encode for
|
|
633
|
+
// THIS host. Re-run the traversal guard on the decoded form: a crafted
|
|
634
|
+
// `%2F`/`%5C` escape must not smuggle a separator past the raw check.
|
|
635
|
+
const canonicalSkill = canonicalVaultSegmentSpelling(skill);
|
|
636
|
+
if (
|
|
637
|
+
canonicalSkill.includes("/") ||
|
|
638
|
+
canonicalSkill.includes("\\") ||
|
|
639
|
+
canonicalSkill.includes("..")
|
|
640
|
+
) {
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
const localSkillDirName = encodeLocalVaultSegment(canonicalSkill);
|
|
624
644
|
const candidates = [
|
|
625
|
-
path.join(hqRoot, ".claude", "skills",
|
|
626
|
-
path.join(hqRoot, ".agents", "skills",
|
|
645
|
+
path.join(hqRoot, ".claude", "skills", localSkillDirName, "SKILL.md"),
|
|
646
|
+
path.join(hqRoot, ".agents", "skills", localSkillDirName, "SKILL.md"),
|
|
627
647
|
];
|
|
648
|
+
// Colon-free names do not normalize (amplifier families are namespaced),
|
|
649
|
+
// so a win32 capture of a space-named skill's dir (`foo%20bar`) would
|
|
650
|
+
// re-encode to `foo%2520bar` and miss — also try the raw segment as-is.
|
|
651
|
+
if (skill !== localSkillDirName) {
|
|
652
|
+
candidates.push(
|
|
653
|
+
path.join(hqRoot, ".claude", "skills", skill, "SKILL.md"),
|
|
654
|
+
path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
628
657
|
for (const candidate of candidates) {
|
|
629
658
|
try {
|
|
630
659
|
const bytes = await fs.readFile(candidate);
|
package/src/watcher.test.ts
CHANGED
|
@@ -796,3 +796,36 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
796
796
|
]);
|
|
797
797
|
});
|
|
798
798
|
});
|
|
799
|
+
|
|
800
|
+
describe("TreeWatcher wire-key codec boundary (US-002)", () => {
|
|
801
|
+
it("emits the canonical vault key for an encoded win32 farm name (verbatim local name on POSIX)", () => {
|
|
802
|
+
const hqRoot = path.join(os.tmpdir(), "hq-watch-codec-test-root");
|
|
803
|
+
const clock = new FakeClock();
|
|
804
|
+
const watcher = new TreeWatcher({
|
|
805
|
+
hqRoot,
|
|
806
|
+
clock,
|
|
807
|
+
debounceMs: 100,
|
|
808
|
+
pathFilter: () => true,
|
|
809
|
+
});
|
|
810
|
+
const rels: string[] = [];
|
|
811
|
+
watcher.onChange((_first, batch) => {
|
|
812
|
+
if (batch) rels.push(...batch.paths.values());
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
watcher.handleEvent(
|
|
816
|
+
path.join(hqRoot, ".claude", "skills", "indigo%3Acall-prep-brief"),
|
|
817
|
+
);
|
|
818
|
+
clock.advance(200);
|
|
819
|
+
|
|
820
|
+
// The batch rel is a WIRE identifier (PushEvent.relativePath / targeted
|
|
821
|
+
// push route). On win32 the encoded local farm name must decode back to
|
|
822
|
+
// the canonical key or event-driven push re-mints the junk key family;
|
|
823
|
+
// on POSIX the local name IS the key verbatim (codec is identity there).
|
|
824
|
+
const expectedRel =
|
|
825
|
+
process.platform === "win32"
|
|
826
|
+
? ".claude/skills/indigo:call-prep-brief"
|
|
827
|
+
: ".claude/skills/indigo%3Acall-prep-brief";
|
|
828
|
+
expect(rels).toEqual([expectedRel]);
|
|
829
|
+
watcher.dispose();
|
|
830
|
+
});
|
|
831
|
+
});
|
package/src/watcher.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
12
12
|
import * as path from "path";
|
|
13
13
|
import { watch } from "chokidar";
|
|
14
14
|
import { createIgnoreFilter } from "./ignore.js";
|
|
15
|
+
import { vaultKeyForLocalPath } from "./local-path-codec.js";
|
|
15
16
|
import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
|
|
16
17
|
import {
|
|
17
18
|
AGENCY_SYNC_REL,
|
|
@@ -734,7 +735,15 @@ export class TreeWatcher {
|
|
|
734
735
|
// authoritative emit gate; the chokidar backend's `ignored` pre-filters
|
|
735
736
|
// but a slipped-through (or synthetic test) path is re-checked here too.
|
|
736
737
|
if (!this.shouldEmit(abs, isDir)) return;
|
|
737
|
-
|
|
738
|
+
// Codec boundary: the batch's relative path is a WIRE identifier — it
|
|
739
|
+
// becomes `PushEvent.relativePath` and the targeted-push route key — so
|
|
740
|
+
// a win32 local farm name (`indigo%3Ax`) must decode back to its
|
|
741
|
+
// canonical vault-key spelling (`indigo:x`) here. Emitting the encoded
|
|
742
|
+
// local name verbatim would mint a fresh junk key family on the wire
|
|
743
|
+
// (the mixed-version amplifier). The emit filter above still sees the
|
|
744
|
+
// raw local path: its exclusion patterns live in local-name space and
|
|
745
|
+
// none of them contain codec-reserved characters.
|
|
746
|
+
const rel = vaultKeyForLocalPath(this.hqRoot, abs);
|
|
738
747
|
if (!this.pending.has(abs)) {
|
|
739
748
|
const entryBytes = estimatePendingEntryBytes(abs, rel);
|
|
740
749
|
if (
|