@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.
Files changed (120) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +20 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner-events.test.js +22 -0
  5. package/dist/bin/sync-runner-events.test.js.map +1 -1
  6. package/dist/bin/sync-runner-planning.d.ts +11 -0
  7. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-planning.js +37 -2
  9. package/dist/bin/sync-runner-planning.js.map +1 -1
  10. package/dist/bin/sync-runner-planning.test.js +1 -0
  11. package/dist/bin/sync-runner-planning.test.js.map +1 -1
  12. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  13. package/dist/bin/sync-runner-watch-loop.js +17 -1
  14. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  15. package/dist/bin/sync-runner.d.ts +33 -1
  16. package/dist/bin/sync-runner.d.ts.map +1 -1
  17. package/dist/bin/sync-runner.js +28 -3
  18. package/dist/bin/sync-runner.js.map +1 -1
  19. package/dist/bin/sync-runner.test.js +206 -2
  20. package/dist/bin/sync-runner.test.js.map +1 -1
  21. package/dist/cli/doctor.d.ts +119 -0
  22. package/dist/cli/doctor.d.ts.map +1 -0
  23. package/dist/cli/doctor.js +485 -0
  24. package/dist/cli/doctor.js.map +1 -0
  25. package/dist/cli/doctor.test.d.ts +13 -0
  26. package/dist/cli/doctor.test.d.ts.map +1 -0
  27. package/dist/cli/doctor.test.js +485 -0
  28. package/dist/cli/doctor.test.js.map +1 -0
  29. package/dist/cli/index.d.ts +2 -0
  30. package/dist/cli/index.d.ts.map +1 -1
  31. package/dist/cli/index.js +4 -0
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/share.js +6 -1
  34. package/dist/cli/share.js.map +1 -1
  35. package/dist/cli/share.test.js +1 -0
  36. package/dist/cli/share.test.js.map +1 -1
  37. package/dist/cli/sync.d.ts +54 -0
  38. package/dist/cli/sync.d.ts.map +1 -1
  39. package/dist/cli/sync.js +223 -3
  40. package/dist/cli/sync.js.map +1 -1
  41. package/dist/cli/sync.test.js +394 -1
  42. package/dist/cli/sync.test.js.map +1 -1
  43. package/dist/cognito-auth.test.js +7 -0
  44. package/dist/cognito-auth.test.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/lib/conflict-file.d.ts.map +1 -1
  50. package/dist/lib/conflict-file.js +5 -1
  51. package/dist/lib/conflict-file.js.map +1 -1
  52. package/dist/lib/conflict-index.d.ts +7 -0
  53. package/dist/lib/conflict-index.d.ts.map +1 -1
  54. package/dist/lib/conflict-index.js +10 -2
  55. package/dist/lib/conflict-index.js.map +1 -1
  56. package/dist/lib/conflict.test.js +31 -0
  57. package/dist/lib/conflict.test.js.map +1 -1
  58. package/dist/lib/machine-id.test.js +14 -0
  59. package/dist/lib/machine-id.test.js.map +1 -1
  60. package/dist/local-path-codec.d.ts +18 -0
  61. package/dist/local-path-codec.d.ts.map +1 -1
  62. package/dist/local-path-codec.js +63 -0
  63. package/dist/local-path-codec.js.map +1 -1
  64. package/dist/local-path-codec.test.d.ts +14 -0
  65. package/dist/local-path-codec.test.d.ts.map +1 -0
  66. package/dist/local-path-codec.test.js +102 -0
  67. package/dist/local-path-codec.test.js.map +1 -0
  68. package/dist/machine-auth.test.js +7 -0
  69. package/dist/machine-auth.test.js.map +1 -1
  70. package/dist/s3.d.ts +5 -0
  71. package/dist/s3.d.ts.map +1 -1
  72. package/dist/s3.js +129 -3
  73. package/dist/s3.js.map +1 -1
  74. package/dist/s3.symlink-materialize.test.d.ts +18 -0
  75. package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
  76. package/dist/s3.symlink-materialize.test.js +394 -0
  77. package/dist/s3.symlink-materialize.test.js.map +1 -0
  78. package/dist/s3.test.js +19 -0
  79. package/dist/s3.test.js.map +1 -1
  80. package/dist/skill-telemetry.d.ts.map +1 -1
  81. package/dist/skill-telemetry.js +23 -2
  82. package/dist/skill-telemetry.js.map +1 -1
  83. package/dist/skill-telemetry.test.js +17 -0
  84. package/dist/skill-telemetry.test.js.map +1 -1
  85. package/dist/watcher.d.ts.map +1 -1
  86. package/dist/watcher.js +10 -1
  87. package/dist/watcher.js.map +1 -1
  88. package/dist/watcher.test.js +28 -0
  89. package/dist/watcher.test.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/bin/sync-runner-company.ts +18 -0
  92. package/src/bin/sync-runner-events.test.ts +31 -0
  93. package/src/bin/sync-runner-planning.test.ts +1 -0
  94. package/src/bin/sync-runner-planning.ts +46 -3
  95. package/src/bin/sync-runner-watch-loop.ts +19 -1
  96. package/src/bin/sync-runner.test.ts +236 -1
  97. package/src/bin/sync-runner.ts +57 -2
  98. package/src/cli/doctor.test.ts +581 -0
  99. package/src/cli/doctor.ts +640 -0
  100. package/src/cli/index.ts +14 -0
  101. package/src/cli/share.test.ts +1 -0
  102. package/src/cli/share.ts +6 -1
  103. package/src/cli/sync.test.ts +473 -1
  104. package/src/cli/sync.ts +310 -3
  105. package/src/cognito-auth.test.ts +5 -0
  106. package/src/index.ts +10 -0
  107. package/src/lib/conflict-file.ts +6 -1
  108. package/src/lib/conflict-index.ts +18 -2
  109. package/src/lib/conflict.test.ts +33 -0
  110. package/src/lib/machine-id.test.ts +10 -0
  111. package/src/local-path-codec.test.ts +138 -0
  112. package/src/local-path-codec.ts +66 -0
  113. package/src/machine-auth.test.ts +5 -0
  114. package/src/s3.symlink-materialize.test.ts +492 -0
  115. package/src/s3.test.ts +24 -0
  116. package/src/s3.ts +148 -4
  117. package/src/skill-telemetry.test.ts +19 -0
  118. package/src/skill-telemetry.ts +31 -2
  119. package/src/watcher.test.ts +33 -0
  120. 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
- // Best-effort cleanup; do not mask the transfer/materialization error.
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) => fs.unlinkSync(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: { beforeReplace?: () => void } = {},
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(symlinkTarget, tempPath);
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";
@@ -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", skill, "SKILL.md"),
626
- path.join(hqRoot, ".agents", "skills", skill, "SKILL.md"),
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);
@@ -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
- const rel = path.relative(this.hqRoot, abs).split(path.sep).join("/");
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 (