@indigoai-us/hq-cloud 5.17.0 → 5.19.0

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 (53) hide show
  1. package/.github/workflows/ci.yml +19 -0
  2. package/.github/workflows/publish.yml +53 -0
  3. package/dist/cli/invite.js +4 -1
  4. package/dist/cli/invite.js.map +1 -1
  5. package/dist/cli/invite.test.js +3 -0
  6. package/dist/cli/invite.test.js.map +1 -1
  7. package/dist/cli/promote.js +3 -0
  8. package/dist/cli/promote.js.map +1 -1
  9. package/dist/cli/share.d.ts +7 -5
  10. package/dist/cli/share.d.ts.map +1 -1
  11. package/dist/cli/share.js +189 -18
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +304 -3
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts.map +1 -1
  16. package/dist/cli/sync.js +98 -17
  17. package/dist/cli/sync.js.map +1 -1
  18. package/dist/cli/sync.test.js +314 -0
  19. package/dist/cli/sync.test.js.map +1 -1
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +107 -18
  22. package/dist/context.js.map +1 -1
  23. package/dist/context.test.js +63 -14
  24. package/dist/context.test.js.map +1 -1
  25. package/dist/journal.d.ts +26 -0
  26. package/dist/journal.d.ts.map +1 -1
  27. package/dist/journal.js +31 -0
  28. package/dist/journal.js.map +1 -1
  29. package/dist/s3.d.ts +91 -0
  30. package/dist/s3.d.ts.map +1 -1
  31. package/dist/s3.js +245 -0
  32. package/dist/s3.js.map +1 -1
  33. package/dist/s3.test.js +347 -1
  34. package/dist/s3.test.js.map +1 -1
  35. package/dist/vault-client.d.ts +24 -0
  36. package/dist/vault-client.d.ts.map +1 -1
  37. package/dist/vault-client.js +29 -0
  38. package/dist/vault-client.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/invite.test.ts +3 -0
  41. package/src/cli/invite.ts +4 -1
  42. package/src/cli/promote.ts +3 -0
  43. package/src/cli/share.test.ts +377 -3
  44. package/src/cli/share.ts +241 -28
  45. package/src/cli/sync.test.ts +357 -0
  46. package/src/cli/sync.ts +133 -24
  47. package/src/context.test.ts +73 -14
  48. package/src/context.ts +116 -20
  49. package/src/journal.ts +33 -0
  50. package/src/s3.test.ts +415 -1
  51. package/src/s3.ts +271 -0
  52. package/src/vault-client.ts +37 -0
  53. package/tsconfig.json +12 -1
package/src/s3.test.ts CHANGED
@@ -20,6 +20,16 @@ const sentCommands: Array<{ name: string; input: Record<string, unknown> }> = []
20
20
  // is an empty bucket. Reset in beforeEach so cross-test leakage is impossible.
21
21
  let nextListObjectsResponse: Record<string, unknown> = { Contents: [] };
22
22
 
23
+ // Per-test override for the GetObjectCommand response. The default fake
24
+ // doesn't model GET (downloadFile previously had no s3.test.ts coverage);
25
+ // downloadFile tests push the Body + Metadata shape they want returned.
26
+ let nextGetObjectResponse: Record<string, unknown> = {
27
+ Body: (async function* () {
28
+ yield new Uint8Array();
29
+ })(),
30
+ Metadata: {},
31
+ };
32
+
23
33
  vi.mock("@aws-sdk/client-s3", () => {
24
34
  class FakeS3Client {
25
35
  async send(command: { constructor: { name: string }; input: Record<string, unknown> }): Promise<Record<string, unknown>> {
@@ -35,6 +45,9 @@ vi.mock("@aws-sdk/client-s3", () => {
35
45
  if (command.constructor.name === "ListObjectsV2Command") {
36
46
  return nextListObjectsResponse;
37
47
  }
48
+ if (command.constructor.name === "GetObjectCommand") {
49
+ return nextGetObjectResponse;
50
+ }
38
51
  return {};
39
52
  }
40
53
  }
@@ -66,7 +79,16 @@ vi.mock("@aws-sdk/client-s3", () => {
66
79
  };
67
80
  });
68
81
 
69
- import { uploadFile, listRemoteFiles } from "./s3.js";
82
+ import {
83
+ uploadFile,
84
+ uploadSymlink,
85
+ downloadFile,
86
+ listRemoteFiles,
87
+ SYMLINK_BODY_PREFIX,
88
+ SYMLINK_MARKER_META_VALUE,
89
+ encodeSymlinkMetadataValue,
90
+ decodeSymlinkMetadataValue,
91
+ } from "./s3.js";
70
92
  import type { EntityContext } from "./types.js";
71
93
 
72
94
  function makeCtx(): EntityContext {
@@ -215,4 +237,396 @@ describe("listRemoteFiles", () => {
215
237
  expect(files).toHaveLength(1);
216
238
  expect(files[0].key).toBe("real.md");
217
239
  });
240
+
241
+ // Regression: pre-fix, the 0-byte-guard relaxation (`if (!obj.Key) continue;`
242
+ // replacing `if (!obj.Key || !obj.Size) continue;`) was correct for
243
+ // legitimate placeholder files like `.gitkeep` but accidentally also let
244
+ // S3 directory-marker objects (0-byte, key ends in `/`) through to the
245
+ // planner. Two downstream sites blow up on those:
246
+ // 1. pull planner (cli/sync.ts `computePullPlan`): for any marker whose
247
+ // `path.join(root, key)` resolves to an existing local directory,
248
+ // `localExists=true` → `hashFile(localPath)` → `readFileSync` on dir
249
+ // → EISDIR "read". The runner reported this for `personal:(company)`.
250
+ // 2. download path (s3.ts `downloadFile`): for any marker whose local
251
+ // path doesn't exist, the planner classifies it `download`;
252
+ // `mkdirSync(path.dirname(localPath), recursive)` strips the trailing
253
+ // slash and creates the leaf as a directory, then `writeFileSync`
254
+ // on the trailing-slash path → EISDIR "open". The runner reported
255
+ // this for `indigo:lambda/`.
256
+ // The fix drops the *definitional* marker shape (trailing-slash AND
257
+ // size 0) at this site so neither path ever sees them. Real 0-byte
258
+ // placeholders (`.gitkeep`) never end in `/` and continue to flow
259
+ // through — covered by the test immediately above.
260
+ it("drops S3 directory-marker keys (trailing '/', size 0) from list results", async () => {
261
+ nextListObjectsResponse = {
262
+ Contents: [
263
+ { Key: "real.md", Size: 42, LastModified: new Date(), ETag: '"e1"' },
264
+ { Key: "projects/.gitkeep", Size: 0, LastModified: new Date(), ETag: '"e2"' },
265
+ { Key: "lambda/", Size: 0, LastModified: new Date(), ETag: '"e3"' },
266
+ { Key: "core/", Size: 0, LastModified: new Date(), ETag: '"e4"' },
267
+ { Key: "deeply/nested/marker/", Size: 0, LastModified: new Date(), ETag: '"e5"' },
268
+ ],
269
+ };
270
+
271
+ const files = await listRemoteFiles(makeCtx());
272
+
273
+ // Real files (including the 0-byte .gitkeep placeholder) pass through;
274
+ // every 0-byte trailing-slash marker is dropped.
275
+ expect(files.map((f) => f.key).sort()).toEqual([
276
+ "projects/.gitkeep",
277
+ "real.md",
278
+ ]);
279
+ });
280
+
281
+ // Codex P2 follow-up: the marker filter must be definitionally
282
+ // constrained to size-0 trailing-slash keys. A hypothetical non-empty
283
+ // object whose key ends in `/` (no hq-cloud code path creates one,
284
+ // but `ListObjectsV2` returns whatever lives in the bucket) must
285
+ // NOT be silently hidden — that would mask real bucket state from
286
+ // the operator. Such an object will still surface EISDIR "open" at
287
+ // downloadFile when the local path is materialized, which is the
288
+ // signal needed to reconcile.
289
+ it("does NOT drop non-empty trailing-slash keys (preserves operator visibility)", async () => {
290
+ nextListObjectsResponse = {
291
+ Contents: [
292
+ { Key: "real.md", Size: 42, LastModified: new Date(), ETag: '"e1"' },
293
+ // 0-byte marker — filtered.
294
+ { Key: "marker/", Size: 0, LastModified: new Date(), ETag: '"e2"' },
295
+ // Non-zero, trailing slash — pathological but unfiltered so it
296
+ // stays visible. (Cannot be materialized locally; downloadFile
297
+ // will raise an explicit error pointing at this exact key.)
298
+ { Key: "exports/report/", Size: 1024, LastModified: new Date(), ETag: '"e3"' },
299
+ ],
300
+ };
301
+
302
+ const files = await listRemoteFiles(makeCtx());
303
+
304
+ expect(files.map((f) => f.key).sort()).toEqual([
305
+ "exports/report/",
306
+ "real.md",
307
+ ]);
308
+ // Spot-check that the non-empty marker carried its real size.
309
+ expect(files.find((f) => f.key === "exports/report/")!.size).toBe(1024);
310
+ });
311
+ });
312
+
313
+ describe("uploadSymlink", () => {
314
+ beforeEach(() => {
315
+ sentCommands.length = 0;
316
+ });
317
+
318
+ it("PUTs the prefixed target as the body bytes and a marker-only metadata flag", async () => {
319
+ // Body = SYMLINK_BODY_PREFIX + target is the source of truth for
320
+ // the target string and is load-bearing for two distinct
321
+ // drift-detection properties:
322
+ // 1. S3 ETag = MD5(body) varies with target → cross-machine
323
+ // target-change propagation works through LIST etags.
324
+ // 2. The prefix means a symlink to "bar.md" doesn't share an
325
+ // ETag with a regular file containing "bar.md" → symlink ↔
326
+ // regular-file transitions on the same key are visible to
327
+ // peers via LIST.
328
+ // The metadata is now a fixed marker only — we used to store the
329
+ // base64'd target there, but S3's 2 KiB header limit made that
330
+ // brittle for long POSIX targets. Marker-only is bounded.
331
+ const target = "../real-target.md";
332
+ await uploadSymlink(makeCtx(), target, "policies/link.md");
333
+
334
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
335
+ expect(put).toBeDefined();
336
+ const meta = put!.input.Metadata as Record<string, string>;
337
+ expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
338
+ expect(put!.input.Body).toBeDefined();
339
+ const body = put!.input.Body as Buffer | Uint8Array;
340
+ expect(Buffer.from(body).toString("utf-8")).toBe(SYMLINK_BODY_PREFIX + target);
341
+ expect(put!.input.Key).toBe("policies/link.md");
342
+ });
343
+
344
+ it("handles arbitrary Unicode targets via the body (metadata stays marker-only)", async () => {
345
+ // Codex round-9 P2 follow-up: S3 user-metadata is HTTP-header-
346
+ // bound. A POSIX symlink target can include any Unicode bytes;
347
+ // storing the target in metadata risked PutObject rejecting it.
348
+ // Now the body carries the target — UTF-8 bytes pass through
349
+ // S3 object content unmolested, no ASCII restriction, no header
350
+ // limit. Metadata stays as the constant marker.
351
+ sentCommands.length = 0;
352
+ const unicodeTarget = "café/🌳/Übung.md";
353
+ await uploadSymlink(makeCtx(), unicodeTarget, "weird-link.md");
354
+
355
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
356
+ expect(put).toBeDefined();
357
+ const meta = put!.input.Metadata as Record<string, string>;
358
+ expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
359
+ const body = Buffer.from(put!.input.Body as Buffer | Uint8Array).toString(
360
+ "utf-8",
361
+ );
362
+ expect(body).toBe(SYMLINK_BODY_PREFIX + unicodeTarget);
363
+ });
364
+
365
+ it("handles long targets without exceeding S3's 2 KiB user-metadata limit", async () => {
366
+ // Codex round-13 P2 follow-up: pre-fix, the metadata value was
367
+ // base64(target) plus author fields plus the metadata key name.
368
+ // A target around 1.5 KiB after base64 inflation could push the
369
+ // total over S3's 2 KiB user-metadata cap and PutObject would
370
+ // reject the upload. With marker-only metadata, the target's
371
+ // size only contributes to the body — bounded by S3's 5 GB
372
+ // object size, not the header limit. Verify a 4 KiB target (well
373
+ // beyond the old breaking point) round-trips cleanly.
374
+ sentCommands.length = 0;
375
+ const longTarget = "deeply/nested/" + "x".repeat(4096);
376
+ await uploadSymlink(makeCtx(), longTarget, "long-link.md");
377
+
378
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
379
+ const meta = put!.input.Metadata as Record<string, string>;
380
+ expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
381
+ const body = Buffer.from(put!.input.Body as Buffer | Uint8Array).toString(
382
+ "utf-8",
383
+ );
384
+ expect(body).toBe(SYMLINK_BODY_PREFIX + longTarget);
385
+ // Sanity: marker fits in the header limit by an enormous margin
386
+ // (1 byte vs 2048).
387
+ expect(meta["hq-symlink-target"].length).toBeLessThan(10);
388
+ });
389
+
390
+ it("encodeSymlinkMetadataValue / decodeSymlinkMetadataValue round-trip arbitrary UTF-8", async () => {
391
+ // Direct property test of the helpers — covers the legacy-fallback
392
+ // path via the round-trip check inside decodeSymlinkMetadataValue.
393
+ for (const sample of [
394
+ "",
395
+ "real.md",
396
+ "../sibling/file.md",
397
+ "café/🌳/Übung.md",
398
+ "/absolute/path/with spaces.md",
399
+ "deeply/nested/path/" + "x".repeat(200),
400
+ ]) {
401
+ expect(decodeSymlinkMetadataValue(encodeSymlinkMetadataValue(sample))).toBe(sample);
402
+ }
403
+ // Legacy-fallback: a value that isn't valid base64 of UTF-8 (e.g.
404
+ // a raw target written by a pre-PR uploader) round-trips as-is.
405
+ // "real.md" happens to NOT be valid base64 (length not multiple
406
+ // of 4), so the decoder falls through to returning it raw.
407
+ expect(decodeSymlinkMetadataValue("real.md")).toBe("real.md");
408
+ });
409
+
410
+ it("produces a different body (and therefore a different S3 ETag) when target changes", async () => {
411
+ // Direct regression for the cross-machine target-propagation bug:
412
+ // two uploads with different targets must produce different bodies
413
+ // so S3-side etag comparison can detect the change. We assert on
414
+ // the body bytes rather than asking S3 for an ETag (the fake
415
+ // returns a constant ETag) — equality of body bytes is a necessary
416
+ // and sufficient property for ETag equality on PutObject.
417
+ sentCommands.length = 0;
418
+ await uploadSymlink(makeCtx(), "old-target.md", "policies/link.md");
419
+ await uploadSymlink(makeCtx(), "new-target.md", "policies/link.md");
420
+ const puts = sentCommands.filter((c) => c.name === "PutObjectCommand");
421
+ expect(puts).toHaveLength(2);
422
+ const bodies = puts.map((p) =>
423
+ Buffer.from(p.input.Body as Buffer | Uint8Array).toString("utf-8"),
424
+ );
425
+ expect(bodies[0]).not.toBe(bodies[1]);
426
+ expect(bodies[0]).toBe(SYMLINK_BODY_PREFIX + "old-target.md");
427
+ expect(bodies[1]).toBe(SYMLINK_BODY_PREFIX + "new-target.md");
428
+ });
429
+
430
+ it("produces a different body from a regular file containing the same target string (ETag distinguishability)", async () => {
431
+ // Codex round-4 P2 follow-up: pre-fix, a symlink whose target
432
+ // string equaled some regular file's exact contents produced an
433
+ // identical S3 ETag (since ETag = MD5(body) and bodies were equal).
434
+ // The LIST-based pull planner can't see per-object metadata, so
435
+ // it would classify a symlink ↔ regular-file transition on the
436
+ // same key as "no change" and never replace the local
437
+ // representation. The SYMLINK_BODY_PREFIX makes the bodies
438
+ // distinguishable for any realistic regular-file content.
439
+ sentCommands.length = 0;
440
+ const sharedString = "real.md";
441
+ // Make a regular file whose contents are exactly the target string
442
+ // — the worst-case collision shape pre-prefix.
443
+ const localFile = path.join(
444
+ os.tmpdir(),
445
+ `etag-distinguishability-${Date.now()}-${Math.random()}.md`,
446
+ );
447
+ fs.writeFileSync(localFile, sharedString);
448
+
449
+ await uploadSymlink(makeCtx(), sharedString, "case-key.md");
450
+ await uploadFile(makeCtx(), localFile, "regular-key.md");
451
+ fs.rmSync(localFile, { force: true });
452
+
453
+ const puts = sentCommands.filter((c) => c.name === "PutObjectCommand");
454
+ expect(puts).toHaveLength(2);
455
+ const symlinkBody = Buffer.from(
456
+ puts[0].input.Body as Buffer | Uint8Array,
457
+ ).toString("utf-8");
458
+ const regularBody = Buffer.from(
459
+ puts[1].input.Body as Buffer | Uint8Array,
460
+ ).toString("utf-8");
461
+
462
+ // The point of the prefix: symlink record body ≠ regular file body
463
+ // even when they describe the same string. Different bytes ⇒
464
+ // different MD5 ⇒ different S3 ETag ⇒ pull planner detects the
465
+ // transition via LIST without needing a per-object HEAD.
466
+ expect(symlinkBody).not.toBe(regularBody);
467
+ expect(regularBody).toBe(sharedString);
468
+ expect(symlinkBody).toBe(SYMLINK_BODY_PREFIX + sharedString);
469
+ });
470
+
471
+ it("forwards UploadAuthor alongside the symlink-target metadata", async () => {
472
+ await uploadSymlink(makeCtx(), "real.md", "policies/link.md", {
473
+ userSub: "abc-123",
474
+ email: "alice@example.com",
475
+ });
476
+
477
+ const put = sentCommands.find((c) => c.name === "PutObjectCommand");
478
+ const meta = put!.input.Metadata as Record<string, string>;
479
+ // Marker-only metadata; target lives in the body.
480
+ expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
481
+ const body = Buffer.from(put!.input.Body as Buffer | Uint8Array).toString(
482
+ "utf-8",
483
+ );
484
+ expect(body).toBe(SYMLINK_BODY_PREFIX + "real.md");
485
+ expect(meta["created-by"]).toBe("alice@example.com");
486
+ expect(meta["created-by-sub"]).toBe("abc-123");
487
+ });
488
+ });
489
+
490
+ describe("downloadFile", () => {
491
+ let tmpRoot: string;
492
+
493
+ beforeEach(() => {
494
+ sentCommands.length = 0;
495
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "s3-download-test-"));
496
+ });
497
+
498
+ it("materializes a symlink record as a real symlink, sourcing the target from the body", async () => {
499
+ // Round-13 contract: metadata is just a marker; the body
500
+ // (after stripping SYMLINK_BODY_PREFIX) is the source of truth
501
+ // for the target string. downloadFile must consume the body
502
+ // and use the post-prefix slice as the link target.
503
+ const target = "../real-target.md";
504
+ nextGetObjectResponse = {
505
+ Body: (async function* () {
506
+ yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + target);
507
+ })(),
508
+ Metadata: { "hq-symlink-target": SYMLINK_MARKER_META_VALUE },
509
+ };
510
+
511
+ const localPath = path.join(tmpRoot, "policies", "link.md");
512
+ await downloadFile(makeCtx(), "policies/link.md", localPath);
513
+
514
+ const lstat = fs.lstatSync(localPath);
515
+ expect(lstat.isSymbolicLink()).toBe(true);
516
+ expect(fs.readlinkSync(localPath)).toBe(target);
517
+ });
518
+
519
+ it("recovers the target from legacy metadata-only uploads (body lacks the prefix)", async () => {
520
+ // Backward-compat: an in-flight upload from earlier in this
521
+ // PR's lifetime stored the target in metadata (raw or base64'd)
522
+ // rather than in the body. downloadFile must fall back to
523
+ // decoding the metadata value when the body doesn't start with
524
+ // SYMLINK_BODY_PREFIX. Tested with a base64'd metadata value
525
+ // and an empty body — the round-trip-validating decoder
526
+ // should recover the original target.
527
+ const target = "legacy/target.md";
528
+ const legacyMetadata = encodeSymlinkMetadataValue(target);
529
+ nextGetObjectResponse = {
530
+ Body: (async function* () {
531
+ yield new Uint8Array(); // empty body, like a v1-of-this-PR upload
532
+ })(),
533
+ Metadata: { "hq-symlink-target": legacyMetadata },
534
+ };
535
+
536
+ const localPath = path.join(tmpRoot, "legacy-link.md");
537
+ await downloadFile(makeCtx(), "legacy-link.md", localPath);
538
+
539
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
540
+ expect(fs.readlinkSync(localPath)).toBe(target);
541
+ });
542
+
543
+ it("recovers a target longer than the legacy 2 KiB metadata limit from the body", async () => {
544
+ // Codex round-13 P2 motivating case: targets between ~1.5 KiB
545
+ // (after base64 inflation) and ~5 GiB (S3 object cap) used to
546
+ // fail PutObject under the metadata-based design but now
547
+ // round-trip cleanly through the body. We pick 600 chars: well
548
+ // past the old metadata-only break point (~1500 base64 bytes ≈
549
+ // ~1100 raw, plus header overhead crosses 2 KiB), and well
550
+ // under any sane filesystem's PATH_MAX symlink-target limit
551
+ // (Linux: 4096, macOS: 1024) so the test creates a real link.
552
+ const longTarget = "deeply/nested/path/" + "x".repeat(600);
553
+ nextGetObjectResponse = {
554
+ Body: (async function* () {
555
+ yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + longTarget);
556
+ })(),
557
+ Metadata: { "hq-symlink-target": SYMLINK_MARKER_META_VALUE },
558
+ };
559
+
560
+ const localPath = path.join(tmpRoot, "long-link.md");
561
+ await downloadFile(makeCtx(), "long-link.md", localPath);
562
+
563
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
564
+ expect(fs.readlinkSync(localPath)).toBe(longTarget);
565
+ });
566
+
567
+ it("falls back to writing a regular file when hq-symlink-target metadata is absent", async () => {
568
+ nextGetObjectResponse = {
569
+ Body: (async function* () {
570
+ yield new Uint8Array([104, 105]); // "hi"
571
+ })(),
572
+ Metadata: {},
573
+ };
574
+
575
+ const localPath = path.join(tmpRoot, "regular.md");
576
+ await downloadFile(makeCtx(), "regular.md", localPath);
577
+
578
+ const lstat = fs.lstatSync(localPath);
579
+ expect(lstat.isSymbolicLink()).toBe(false);
580
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("hi");
581
+ });
582
+
583
+ it("replaces a stale local symlink with a regular file when remote transitions symlink → regular", async () => {
584
+ // Codex P2 follow-up: pre-fix, the regular-file branch went
585
+ // straight to writeFileSync. If localPath was a stale symlink from
586
+ // a prior sync (when this key was a symlink in cloud), writeFileSync
587
+ // would FOLLOW the link and overwrite the link's target file
588
+ // contents — leaving the link in place and the new "regular file at
589
+ // localPath" never materializing. The fix: lstat before write; if
590
+ // the existing entry is a symlink, unlink it first.
591
+ const localPath = path.join(tmpRoot, "transitioned.md");
592
+ const targetFile = path.join(tmpRoot, "stale-target.md");
593
+ fs.writeFileSync(targetFile, "do-not-clobber-me");
594
+ fs.symlinkSync(targetFile, localPath);
595
+
596
+ nextGetObjectResponse = {
597
+ Body: (async function* () {
598
+ yield new Uint8Array([110, 101, 119]); // "new"
599
+ })(),
600
+ Metadata: {}, // no symlink metadata → regular file path
601
+ };
602
+
603
+ await downloadFile(makeCtx(), "transitioned.md", localPath);
604
+
605
+ // localPath is now a regular file with the new content...
606
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
607
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("new");
608
+ // ...and the link's former target was NOT clobbered.
609
+ expect(fs.readFileSync(targetFile, "utf-8")).toBe("do-not-clobber-me");
610
+ });
611
+
612
+ it("replaces an existing entry when the link is rewritten (target change)", async () => {
613
+ // Pre-existing regular file at the target location must not block
614
+ // creation of a symlink — fs.symlinkSync would EEXIST otherwise.
615
+ // Same applies to a stale symlink whose target has changed: the new
616
+ // target string must win.
617
+ const localPath = path.join(tmpRoot, "rewrite.md");
618
+ fs.writeFileSync(localPath, "stale regular file");
619
+
620
+ nextGetObjectResponse = {
621
+ Body: (async function* () {
622
+ yield new Uint8Array();
623
+ })(),
624
+ Metadata: { "hq-symlink-target": "fresh-target.md" },
625
+ };
626
+
627
+ await downloadFile(makeCtx(), "rewrite.md", localPath);
628
+
629
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
630
+ expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
631
+ });
218
632
  });