@rubytech/create-maxy-code 0.1.459 → 0.1.461

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 (189) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
  2. package/dist/__tests__/launchd-plist.test.js +26 -0
  3. package/dist/cron-registration.js +30 -8
  4. package/dist/index.js +50 -9
  5. package/dist/launchd-plist.js +1 -1
  6. package/dist/uninstall.js +9 -3
  7. package/package.json +1 -1
  8. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
  9. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
  13. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
  14. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
  15. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
  16. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
  17. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
  19. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  21. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  23. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  24. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +27 -0
  25. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
  27. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
  29. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
  30. package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
  31. package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
  32. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
  33. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
  34. package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
  35. package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
  36. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  37. package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
  38. package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
  39. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
  40. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
  41. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +220 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +204 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
  50. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
  51. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
  52. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  53. package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +125 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +119 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
  64. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
  65. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
  66. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
  67. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
  68. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
  69. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +73 -0
  70. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
  71. package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
  72. package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
  73. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
  74. package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
  75. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
  76. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
  77. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
  78. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
  79. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  80. package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
  81. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  91. package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
  92. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  93. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
  94. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
  95. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
  96. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
  97. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
  98. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
  99. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
  100. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
  101. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  103. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  104. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
  105. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  106. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
  107. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  108. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
  109. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  110. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
  111. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  112. package/payload/platform/plugins/outlook/references/auth.md +1 -1
  113. package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
  114. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
  115. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
  116. package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
  117. package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
  118. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
  119. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  120. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
  121. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
  122. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
  123. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
  124. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  125. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +111 -12
  126. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  127. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  128. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
  129. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  130. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  131. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
  132. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  133. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  134. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  135. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  136. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  137. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  138. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
  139. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
  140. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +444 -0
  141. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +18 -2
  142. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
  143. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  144. package/payload/platform/scripts/logs-rotate.sh +204 -0
  145. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  146. package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
  147. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  148. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  149. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  150. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  151. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  152. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  153. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  154. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  155. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  156. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  157. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  158. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
  159. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
  160. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
  161. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
  162. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  163. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  164. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  165. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  166. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
  167. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  168. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +36 -24
  169. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  170. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  171. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  172. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  173. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  174. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  175. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  176. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  177. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  178. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  179. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  180. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  181. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  182. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  183. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  184. package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
  185. package/payload/server/server.js +268 -73
  186. package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
  187. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
  188. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
  189. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
@@ -1,14 +1,15 @@
1
- // Acceptance gate for the restored scheduling-watcher heartbeat cron.
1
+ // Acceptance gate for the per-brand cron block.
2
2
  //
3
- // The installer registers exactly ONE install-level cron line that ticks
4
- // check-due-events.js once a minute. These invariants protect the two
5
- // regressions that the original block caused (and that its narrowed
3
+ // The installer registers exactly TWO install-level cron lines: the
4
+ // scheduling-watcher heartbeat ticking check-due-events.js once a minute, and
5
+ // (Task 1706) the log rotator every 15 minutes. These invariants protect the
6
+ // two regressions that the original block caused (and that its narrowed
6
7
  // replacement must not reintroduce), plus the marker contract the admin
7
8
  // `system-status` check reads back.
8
9
  //
9
- // (a) The block carries a single heartbeat entry — no email-fetch /
10
- // email-auto-respond line. Those dispatchers act without an operator
11
- // booking and stay unscheduled by the installer.
10
+ // (a) The block carries the heartbeat and the rotator and nothing else — no
11
+ // email-fetch / email-auto-respond line. Those dispatchers act without an
12
+ // operator booking and stay unscheduled by the installer.
12
13
  // (b) The entry never `mkdir`s an account log dir. The legacy line ran
13
14
  // `mkdir -p data/accounts/<id>/logs` every 60s, tripping
14
15
  // setup-account/seed-neo4j's stub-account-dirs guard on reinstall.
@@ -22,7 +23,7 @@
22
23
  // never stacks a second copy.
23
24
  import test from "node:test";
24
25
  import assert from "node:assert/strict";
25
- import { cronBlockMarkers, buildHeartbeatCronBlock, stripCronBlock, mergeHeartbeatBlock, } from "../cron-registration.js";
26
+ import { cronBlockMarkers, buildBrandCronBlock, stripCronBlock, mergeBrandCronBlock, } from "../cron-registration.js";
26
27
  const OPTS = {
27
28
  productName: "maxy-code",
28
29
  platformRoot: "/home/me/maxy-code/platform",
@@ -32,7 +33,7 @@ const OPTS = {
32
33
  uiPort: 19201,
33
34
  };
34
35
  test("heartbeat entry carries MAXY_UI_INTERNAL_PORT so the dispatcher can reach the inject route (Task 1378)", () => {
35
- const block = buildHeartbeatCronBlock(OPTS);
36
+ const block = buildBrandCronBlock(OPTS);
36
37
  assert.match(block, /MAXY_UI_INTERNAL_PORT=19201 /, "entry must export the UI internal port for schedule-inject");
37
38
  });
38
39
  test("markers are # BEGIN/END <PRODUCT_UPPER> CRONS (admin system-status contract)", () => {
@@ -40,41 +41,69 @@ test("markers are # BEGIN/END <PRODUCT_UPPER> CRONS (admin system-status contrac
40
41
  assert.equal(begin, "# BEGIN MAXY-CODE CRONS");
41
42
  assert.equal(end, "# END MAXY-CODE CRONS");
42
43
  });
43
- test("block has exactly one heartbeat entry, no email lines", () => {
44
- const block = buildHeartbeatCronBlock(OPTS);
44
+ test("block has exactly two entries — heartbeat and logs-rotate, no email lines", () => {
45
+ const block = buildBrandCronBlock(OPTS);
45
46
  const entries = block.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
46
- assert.equal(entries.length, 1, "expected exactly one cron entry");
47
- assert.match(entries[0], / # heartbeat$/, "entry must be tagged # heartbeat");
47
+ assert.equal(entries.length, 2, "expected exactly two cron entries");
48
+ assert.match(entries[0], / # heartbeat$/, "first entry must be tagged # heartbeat");
49
+ assert.match(entries[1], / # logs-rotate$/, "second entry must be tagged # logs-rotate");
48
50
  assert.doesNotMatch(block, /email-fetch/, "email-fetch must not be scheduled");
49
51
  assert.doesNotMatch(block, /email-auto-respond/, "email-auto-respond must not be scheduled");
50
52
  });
51
53
  test("heartbeat entry never mkdirs an account log dir (Task 039 guard)", () => {
52
- const block = buildHeartbeatCronBlock(OPTS);
54
+ const block = buildBrandCronBlock(OPTS);
53
55
  assert.doesNotMatch(block, /mkdir/, "no mkdir in the cron line");
54
56
  assert.doesNotMatch(block, /data\/accounts/, "must not touch the seed-guard account dir");
55
57
  assert.match(block, />> \/home\/me\/\.maxy-code\/logs\/check-due-events\.log 2>&1/, "log must redirect to <persistDir>/logs");
56
58
  });
57
59
  test("NEO4J_URI is pinned to the brand bolt port, never defaulting 7687 (Task 571 guard)", () => {
58
- const block = buildHeartbeatCronBlock(OPTS);
60
+ const block = buildBrandCronBlock(OPTS);
59
61
  assert.match(block, /NEO4J_URI=bolt:\/\/localhost:17687 /, "NEO4J_URI must pin the brand port");
60
62
  assert.doesNotMatch(block, /7687(?!.)/, "must not reference the default 7687 port");
61
63
  });
62
64
  test("entry runs check-due-events.js with PLATFORM_ROOT and no ACCOUNT_ID", () => {
63
- const block = buildHeartbeatCronBlock(OPTS);
65
+ const block = buildBrandCronBlock(OPTS);
64
66
  assert.match(block, /PLATFORM_ROOT=\/home\/me\/maxy-code\/platform /);
65
67
  assert.match(block, /\/plugins\/scheduling\/mcp\/dist\/scripts\/check-due-events\.js/);
66
68
  assert.doesNotMatch(block, /ACCOUNT_ID=/, "watcher scans all accounts itself; no per-account scoping");
67
69
  });
68
70
  test("entry fires every minute", () => {
69
- const block = buildHeartbeatCronBlock(OPTS);
71
+ const block = buildBrandCronBlock(OPTS);
70
72
  const entry = block.split("\n").find((l) => l.includes("check-due-events"));
71
73
  assert.match(entry, /^\* \* \* \* \* /, "must be a once-a-minute schedule");
72
74
  });
75
+ test("Task 1706 — rotator entry ticks every 15 minutes with the logs dir as its argument", () => {
76
+ const block = buildBrandCronBlock(OPTS);
77
+ const entry = block.split("\n").find((l) => l.includes("logs-rotate.sh"));
78
+ assert.match(entry, /^\*\/15 \* \* \* \* /, "must be a quarter-hourly schedule");
79
+ assert.match(entry, /\/home\/me\/maxy-code\/platform\/scripts\/logs-rotate\.sh \/home\/me\/\.maxy-code\/logs /, "the logs dir must be passed explicitly — a co-resident brand must never rotate a sibling's logs");
80
+ });
81
+ test("Task 1706 — the rotator's own lines go to server.log, the file it bounds", () => {
82
+ const block = buildBrandCronBlock(OPTS);
83
+ const entry = block.split("\n").find((l) => l.includes("logs-rotate.sh"));
84
+ assert.match(entry, />> \/home\/me\/\.maxy-code\/logs\/server\.log 2>&1/);
85
+ });
86
+ test("Task 1706 — rotator entry inherits the heartbeat's guards: no mkdir, no account dir, no env", () => {
87
+ const block = buildBrandCronBlock(OPTS);
88
+ const entry = block.split("\n").find((l) => l.includes("logs-rotate.sh"));
89
+ assert.doesNotMatch(entry, /mkdir/, "no mkdir in the cron line (Task 039 guard)");
90
+ assert.doesNotMatch(entry, /data\/accounts/, "must not touch the seed-guard account dir");
91
+ assert.doesNotMatch(entry, /NEO4J_URI=/, "the rotator reads no env — its logs dir is an argument");
92
+ });
93
+ test("Task 1706 — system-status can name both entries from their trailing comments", () => {
94
+ // platform/plugins/admin/mcp/src/index.ts:466 matches /#\s*(.+)$/ per entry.
95
+ const block = buildBrandCronBlock(OPTS);
96
+ const names = block
97
+ .split("\n")
98
+ .filter((l) => l.trim() && !l.trim().startsWith("#"))
99
+ .map((l) => l.match(/#\s*(.+)$/)?.[1].trim());
100
+ assert.deepEqual(names, ["heartbeat", "logs-rotate"]);
101
+ });
73
102
  test("stripCronBlock removes a present block and counts its entries", () => {
74
- const tab = `MAILTO=""\n${buildHeartbeatCronBlock(OPTS)}\n`;
103
+ const tab = `MAILTO=""\n${buildBrandCronBlock(OPTS)}\n`;
75
104
  const { cleaned, removedEntries, matched } = stripCronBlock(tab, "maxy-code");
76
105
  assert.equal(matched, true);
77
- assert.equal(removedEntries, 1);
106
+ assert.equal(removedEntries, 2);
78
107
  assert.equal(cleaned, 'MAILTO=""');
79
108
  });
80
109
  test("stripCronBlock is a no-op signal when no block is present", () => {
@@ -85,20 +114,20 @@ test("stripCronBlock is a no-op signal when no block is present", () => {
85
114
  assert.equal(cleaned, "0 3 * * * /usr/bin/backup.sh");
86
115
  });
87
116
  test("stripCronBlock escapes the brand name (hyphen/dot safe)", () => {
88
- const block = buildHeartbeatCronBlock({ ...OPTS, productName: "real.agent-code" });
117
+ const block = buildBrandCronBlock({ ...OPTS, productName: "real.agent-code" });
89
118
  const { matched, removedEntries } = stripCronBlock(block, "real.agent-code");
90
119
  assert.equal(matched, true);
91
- assert.equal(removedEntries, 1);
120
+ assert.equal(removedEntries, 2);
92
121
  });
93
- test("mergeHeartbeatBlock appends to an empty crontab", () => {
94
- const block = buildHeartbeatCronBlock(OPTS);
95
- const merged = mergeHeartbeatBlock("", block, "maxy-code");
122
+ test("mergeBrandCronBlock appends to an empty crontab", () => {
123
+ const block = buildBrandCronBlock(OPTS);
124
+ const merged = mergeBrandCronBlock("", block, "maxy-code");
96
125
  assert.equal(merged, `${block}\n`);
97
126
  });
98
- test("mergeHeartbeatBlock replaces an existing block — idempotent re-install", () => {
99
- const block = buildHeartbeatCronBlock(OPTS);
100
- const once = mergeHeartbeatBlock(`0 3 * * * /usr/bin/backup.sh\n`, block, "maxy-code");
101
- const twice = mergeHeartbeatBlock(once, block, "maxy-code");
127
+ test("mergeBrandCronBlock replaces an existing block — idempotent re-install", () => {
128
+ const block = buildBrandCronBlock(OPTS);
129
+ const once = mergeBrandCronBlock(`0 3 * * * /usr/bin/backup.sh\n`, block, "maxy-code");
130
+ const twice = mergeBrandCronBlock(once, block, "maxy-code");
102
131
  assert.equal(once, twice, "re-applying the merge must not stack a second block");
103
132
  const blockCount = (twice.match(/# BEGIN MAXY-CODE CRONS/g) || []).length;
104
133
  assert.equal(blockCount, 1, "exactly one brand block after re-install");
@@ -280,3 +280,29 @@ test("session-manager plist renders bash wrapper as sole ProgramArguments", () =
280
280
  assert.match(xml, /<key>KeepAlive<\/key>\s*<true\/>/);
281
281
  assert.match(xml, /<key>RunAtLoad<\/key>\s*<true\/>/);
282
282
  });
283
+ test("Task 1706 — logs-rotate plist is a 15-minute one-shot taking the logs dir as an argument", () => {
284
+ const xml = renderPlist({
285
+ label: "com.rubytech.maxy-code-logs-rotate",
286
+ programArguments: [
287
+ "/Users/x/maxy-code/platform/scripts/logs-rotate.sh",
288
+ "/Users/x/.maxy-code/logs",
289
+ ],
290
+ stdoutPath: "/Users/x/.maxy-code/logs/server.log",
291
+ stderrPath: "/Users/x/.maxy-code/logs/server.log",
292
+ keepAlive: false,
293
+ runAtLoad: false,
294
+ startInterval: 900,
295
+ });
296
+ assert.match(xml, /<string>com\.rubytech\.maxy-code-logs-rotate<\/string>/);
297
+ // The logs dir is passed explicitly — a co-resident brand must never rotate a
298
+ // sibling's logs.
299
+ assert.match(xml, /<string>\/Users\/x\/maxy-code\/platform\/scripts\/logs-rotate\.sh<\/string>\s*<string>\/Users\/x\/\.maxy-code\/logs<\/string>/);
300
+ assert.match(xml, /<key>StartInterval<\/key>\s*<integer>900<\/integer>/);
301
+ // A periodic one-shot, not a supervised daemon — same shape as the heartbeat.
302
+ assert.match(xml, /<key>KeepAlive<\/key>\s*<false\/>/);
303
+ assert.match(xml, /<key>RunAtLoad<\/key>\s*<false\/>/);
304
+ // No wrapper: unlike the heartbeat the rotator sources no .env.
305
+ assert.doesNotMatch(xml, /\/bin\/bash/);
306
+ // The rotator's own lines go to server.log, the file it itself bounds.
307
+ assert.match(xml, /<key>StandardOutPath<\/key>\s*<string>\/Users\/x\/\.maxy-code\/logs\/server\.log<\/string>/);
308
+ });
@@ -1,11 +1,21 @@
1
- // Scheduling-watcher heartbeat cron — pure string builders.
1
+ // Per-brand cron block — pure string builders.
2
2
  //
3
- // The installer registers ONE install-level cron line that ticks
4
- // `scheduling/mcp/dist/scripts/check-due-events.js` once a minute. The watcher
3
+ // The installer registers TWO install-level cron lines inside one
4
+ // BEGIN/END-delimited per-brand block.
5
+ //
6
+ // 1. The scheduling-watcher heartbeat, once a minute, ticking
7
+ // `scheduling/mcp/dist/scripts/check-due-events.js`. The watcher
5
8
  // scans every account itself (listLocalAccountIds → data/accounts/*/account.json)
6
9
  // and only dispatches operator-created bookings whose time has come, so a
7
10
  // per-minute tick creates no activity of its own.
8
11
  //
12
+ // 2. The log rotator (Task 1706), every 15 minutes, ticking
13
+ // `platform/scripts/logs-rotate.sh`. cron is the mechanism because the platform
14
+ // ships zero systemd timers — this block is the established per-brand periodic
15
+ // surface, already merged idempotently here, already stripped by the
16
+ // uninstaller, already read back by admin `system-status`. darwin has no cron;
17
+ // its analogue is a StartInterval LaunchAgent authored in index.ts.
18
+ //
9
19
  // Narrowed from the block removed in Task 039 (commit 08e64c1f7), which also
10
20
  // reintroduces neither regression that removal fixed:
11
21
  // - No `mkdir` of an account log dir. Logs go to <persistDir>/logs, outside
@@ -29,16 +39,28 @@ export function cronBlockMarkers(productName) {
29
39
  const upper = productName.toUpperCase();
30
40
  return { begin: `# BEGIN ${upper} CRONS`, end: `# END ${upper} CRONS` };
31
41
  }
32
- /** The three-line cron block: BEGIN marker, the single heartbeat entry, END marker. */
33
- export function buildHeartbeatCronBlock(opts) {
42
+ /** The four-line cron block: BEGIN marker, the heartbeat entry, the logs-rotate
43
+ * entry, END marker. Each entry carries a trailing `# <name>` comment — admin
44
+ * `system-status` names entries by exactly that (admin/mcp/src/index.ts:466),
45
+ * so the block self-reports as "2 registered: heartbeat, logs-rotate". */
46
+ export function buildBrandCronBlock(opts) {
34
47
  const { begin, end } = cronBlockMarkers(opts.productName);
35
48
  const script = `${opts.platformRoot}/plugins/scheduling/mcp/dist/scripts/check-due-events.js`;
36
49
  const log = `${opts.logDir}/check-due-events.log`;
37
- const entry = `* * * * * PLATFORM_ROOT=${opts.platformRoot} ` +
50
+ const heartbeat = `* * * * * PLATFORM_ROOT=${opts.platformRoot} ` +
38
51
  `NEO4J_URI=bolt://localhost:${opts.neo4jPort} ` +
39
52
  `MAXY_UI_INTERNAL_PORT=${opts.uiPort} ` +
40
53
  `${opts.nodeBin} ${script} >> ${log} 2>&1 # heartbeat`;
41
- return [begin, entry, end].join("\n");
54
+ // Task 1706. Every 15 minutes: at the ~1 MB/hour measured on sitedesk-code the
55
+ // live file stays within a megabyte of its 64 MiB bound. The rotator reads no
56
+ // env — its logs dir is a positional argument, so a co-resident brand can
57
+ // never rotate a sibling's logs. Its own lines go to server.log, the file it
58
+ // itself bounds; a dedicated rotator log would be one more unbounded file.
59
+ // cron's `>>` is open-append-close per tick, so it is unaffected by the
60
+ // rotator truncating that same file underneath it.
61
+ const rotateScript = `${opts.platformRoot}/scripts/logs-rotate.sh`;
62
+ const rotate = `*/15 * * * * ${rotateScript} ${opts.logDir} >> ${opts.logDir}/server.log 2>&1 # logs-rotate`;
63
+ return [begin, heartbeat, rotate, end].join("\n");
42
64
  }
43
65
  /** Escape a brand name for safe embedding in a RegExp (hyphens, dots). */
44
66
  function escapeRegExp(s) {
@@ -67,7 +89,7 @@ export function stripCronBlock(crontab, productName) {
67
89
  * fresh block. Re-running with the same block is idempotent — a re-install
68
90
  * replaces the block, never stacks a second copy.
69
91
  */
70
- export function mergeHeartbeatBlock(existing, block, productName) {
92
+ export function mergeBrandCronBlock(existing, block, productName) {
71
93
  const { cleaned } = stripCronBlock(existing, productName);
72
94
  return cleaned ? `${cleaned}\n${block}\n` : `${block}\n`;
73
95
  }
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { seedBypassPermissionsSettings, assertBypassPermissionsSeed } from "./pe
6
6
  import { resolve, join, dirname } from "node:path";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { resolveInstallPortFromFs, buildMaxyUnitFile, buildClaudeSessionManagerUnitFile, buildClaudePtysSliceUnitFile, buildCloudflaredSliceUnitFile, buildDarwinServiceEnvPath } from "./port-resolution.js";
9
- import { buildHeartbeatCronBlock, mergeHeartbeatBlock } from "./cron-registration.js";
9
+ import { buildBrandCronBlock, mergeBrandCronBlock } from "./cron-registration.js";
10
10
  import { validateTierFlag, validateEntitlementBase64, applyTierToAccountConfig, entitlementPath } from "./tier-flag.js";
11
11
  import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
12
12
  import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
@@ -3503,10 +3503,11 @@ function resolveInstallAccountId() {
3503
3503
  // ---------------------------------------------------------------------------
3504
3504
  // Cron registration — scheduling-watcher heartbeat
3505
3505
  //
3506
- // Registers ONE install-level cron line that ticks check-due-events.js every
3507
- // minute (see cron-registration.ts for the line shape and the two regression
3508
- // guards). Idempotent across re-installs via the BEGIN/END markers. Linux-only
3509
- // — darwin runs under launchd and registers no cron. The email dispatchers are
3506
+ // Registers the per-brand cron block: check-due-events.js every minute, and
3507
+ // (Task 1706) logs-rotate.sh every 15 minutes. See cron-registration.ts for the
3508
+ // line shapes and the regression guards. Idempotent across re-installs via the
3509
+ // BEGIN/END markers. Linux-only — darwin runs under launchd and registers no
3510
+ // cron, so it gets LaunchAgent analogues for both. The email dispatchers are
3510
3511
  // deliberately NOT registered here.
3511
3512
  //
3512
3513
  // The log dir <persistDir>/logs is created earlier in installServiceLinux
@@ -3518,7 +3519,7 @@ function installCrons() {
3518
3519
  const nodeBin = spawnSync("which", ["node"], { encoding: "utf-8" }).stdout.trim() || "/usr/bin/node";
3519
3520
  const platformRoot = join(INSTALL_DIR, "platform");
3520
3521
  const logDir = join(resolve(process.env.HOME ?? "/root", BRAND.configDir), "logs");
3521
- const block = buildHeartbeatCronBlock({
3522
+ const block = buildBrandCronBlock({
3522
3523
  productName: BRAND.productName,
3523
3524
  platformRoot,
3524
3525
  neo4jPort: NEO4J_PORT,
@@ -3531,14 +3532,14 @@ function installCrons() {
3531
3532
  });
3532
3533
  const existing = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
3533
3534
  const currentCrontab = existing.status === 0 ? existing.stdout : "";
3534
- const newCrontab = mergeHeartbeatBlock(currentCrontab, block, BRAND.productName);
3535
+ const newCrontab = mergeBrandCronBlock(currentCrontab, block, BRAND.productName);
3535
3536
  const write = spawnSync("crontab", ["-"], {
3536
3537
  input: newCrontab,
3537
3538
  encoding: "utf-8",
3538
3539
  stdio: ["pipe", "pipe", "pipe"],
3539
3540
  });
3540
3541
  if (write.status === 0) {
3541
- console.log(" Cron jobs: registered (heartbeat)");
3542
+ console.log(" Cron jobs: registered (heartbeat, logs-rotate)");
3542
3543
  }
3543
3544
  else {
3544
3545
  console.error(` Cron jobs: failed to register — ${(write.stderr || "").trim()}`);
@@ -3846,7 +3847,7 @@ function installServiceDarwin() {
3846
3847
  console.log(` Session manager not yet healthy on ${BRAND.claudeSessionManagerPort} — check ${join(logsDir, "server.log")}.`);
3847
3848
  }
3848
3849
  // Task 1403 — the scheduling heartbeat. On Linux a per-minute cron ticks
3849
- // check-due-events.js (installCrons/buildHeartbeatCronBlock); darwin has no
3850
+ // check-due-events.js (installCrons/buildBrandCronBlock); darwin has no
3850
3851
  // cron and installCrons early-returns, so operator-created bookings would
3851
3852
  // never dispatch. Author a StartInterval=60 LaunchAgent as the timer analogue:
3852
3853
  // keepAlive=false (a periodic one-shot, not a supervised daemon). A bootstrap
@@ -3884,6 +3885,46 @@ function installServiceDarwin() {
3884
3885
  console.error(` WARNING: heartbeat LaunchAgent ${hbLabel} bootstrap returned ${hbBoot.bootstrapStatus} after ${hbBoot.attempts} attempt(s): ${hbBoot.stderr} — scheduling ticks will not fire until a re-install succeeds.`);
3885
3886
  logFile(` [create-maxy] launchd-plist=${hbLabel} loaded=false exit=${hbBoot.bootstrapStatus} attempts=${hbBoot.attempts}`);
3886
3887
  }
3888
+ // Task 1706 — the log rotator. On Linux a */15 entry in the per-brand cron
3889
+ // block ticks logs-rotate.sh (installCrons/buildBrandCronBlock); darwin has no
3890
+ // cron and installCrons early-returns, so brand logs would grow unbounded.
3891
+ // Author a StartInterval=900 LaunchAgent as the timer analogue: keepAlive=false
3892
+ // (a periodic one-shot, not a supervised daemon). No wrapper script — unlike
3893
+ // the heartbeat the rotator reads no env; its logs dir is a positional arg,
3894
+ // which also means a co-resident brand can never rotate a sibling's logs. Its
3895
+ // own lines go to server.log, the file it itself bounds. A bootstrap failure
3896
+ // warns rather than aborting install — logs growing is a slow problem and the
3897
+ // next installer run retries.
3898
+ const lrLabel = `${launchdLabel()}-logs-rotate`;
3899
+ const lrPlistPath = join(launchAgentsDir(), `${lrLabel}.plist`);
3900
+ const lrScript = join(INSTALL_DIR, "platform", "scripts", "logs-rotate.sh");
3901
+ const lrPlist = renderPlist({
3902
+ label: lrLabel,
3903
+ programArguments: [lrScript, logsDir],
3904
+ stdoutPath: join(logsDir, "server.log"),
3905
+ stderrPath: join(logsDir, "server.log"),
3906
+ keepAlive: false,
3907
+ runAtLoad: false,
3908
+ startInterval: 900,
3909
+ });
3910
+ writeFileSync(lrPlistPath, lrPlist);
3911
+ logFile(` ${lrPlistPath} written (${lrPlist.length} bytes)`);
3912
+ const lrBoot = bootstrapLaunchAgent((args) => {
3913
+ const r = spawnSync("launchctl", args, { stdio: "pipe", encoding: "utf-8", timeout: 15_000 });
3914
+ return { status: r.status, stderr: (r.stderr ?? "").toString() };
3915
+ }, {
3916
+ gui: gui(),
3917
+ label: lrLabel,
3918
+ plistPath: lrPlistPath,
3919
+ logger: (line) => { console.log(` ${line}`); logFile(` ${line}`); },
3920
+ });
3921
+ if (lrBoot.ok) {
3922
+ logFile(` [create-maxy] launchd-plist=${lrLabel} loaded=true attempts=${lrBoot.attempts}`);
3923
+ }
3924
+ else {
3925
+ console.error(` WARNING: logs-rotate LaunchAgent ${lrLabel} bootstrap returned ${lrBoot.bootstrapStatus} after ${lrBoot.attempts} attempt(s): ${lrBoot.stderr} — brand logs will not be rotated until a re-install succeeds.`);
3926
+ logFile(` [create-maxy] launchd-plist=${lrLabel} loaded=false exit=${lrBoot.bootstrapStatus} attempts=${lrBoot.attempts}`);
3927
+ }
3887
3928
  // Wait for the server to come up.
3888
3929
  console.log(" Waiting for web server...");
3889
3930
  let webServerUp = false;
@@ -55,7 +55,7 @@ export function buildSessionManagerWrapper(o) {
55
55
  /**
56
56
  * Bash wrapper for the darwin scheduling-heartbeat LaunchAgent (Task 1403).
57
57
  * launchd's StartInterval timer is the analogue of the Linux per-minute cron
58
- * (buildHeartbeatCronBlock) that ticks `check-due-events.js`. The wrapper
58
+ * (buildBrandCronBlock) that ticks `check-due-events.js`. The wrapper
59
59
  * sources the brand `.env` for NEO4J_URI + MAXY_UI_INTERNAL_PORT, exports
60
60
  * PLATFORM_ROOT (the tick's own env contract, matching the cron line's
61
61
  * `PLATFORM_ROOT=` prefix), and execs the tick once. launchd re-invokes it
package/dist/uninstall.js CHANGED
@@ -79,14 +79,20 @@ function launchdLabel() {
79
79
  return `com.rubytech.${BRAND.hostname}`;
80
80
  }
81
81
  /** Every LaunchAgent label installServiceDarwin() creates for this brand: the
82
- * main server, the claude-session-manager (Task 1395), and the scheduling
83
- * heartbeat (Task 1403). A non-default (dedicated) brand also runs the
84
- * per-brand Neo4j LaunchAgent (Task 1396). Uninstall boots out and removes all. */
82
+ * main server, the claude-session-manager (Task 1395), the scheduling
83
+ * heartbeat (Task 1403), and the log rotator (Task 1706). A non-default
84
+ * (dedicated) brand also runs the per-brand Neo4j LaunchAgent (Task 1396).
85
+ * Uninstall boots out and removes all.
86
+ *
87
+ * This list is explicit, not a glob, so a LaunchAgent added to
88
+ * installServiceDarwin() and not added here survives uninstall and keeps
89
+ * ticking against a deleted install. Add both together, always. */
85
90
  function darwinLaunchdLabels() {
86
91
  const labels = [
87
92
  launchdLabel(),
88
93
  `${launchdLabel()}-claude-session-manager`,
89
94
  `${launchdLabel()}-heartbeat`,
95
+ `${launchdLabel()}-logs-rotate`,
90
96
  ];
91
97
  if ((BRAND.neo4jPort ?? SHARED_NEO4J_PORT) !== SHARED_NEO4J_PORT) {
92
98
  labels.push(`${launchdLabel()}-neo4j`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.459",
3
+ "version": "0.1.461",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -328,12 +328,15 @@ function objectPage(keys, opts = {}) {
328
328
  success: true,
329
329
  errors: [],
330
330
  messages: [],
331
- result: keys.map((key) => ({
332
- key,
333
- size: 10,
334
- etag: "e1",
335
- last_modified: "2026-01-01T00:00:00Z",
336
- })),
331
+ result: keys.map((k) => {
332
+ const entry = typeof k === "string" ? { key: k, size: 10 } : k;
333
+ return {
334
+ key: entry.key,
335
+ size: entry.size,
336
+ etag: "e1",
337
+ last_modified: "2026-01-01T00:00:00Z",
338
+ };
339
+ }),
337
340
  result_info: { cursor: opts.cursor ?? "", is_truncated: opts.truncated ?? false },
338
341
  });
339
342
  }
@@ -489,4 +492,111 @@ function objectPage(keys, opts = {}) {
489
492
  strict_1.default.deepEqual((await cf.r2ObjectList("b")).map((o) => o.key), ["a.jpg", "b.jpg"]);
490
493
  strict_1.default.equal(calls.length, 2);
491
494
  });
495
+ // --- r2ObjectFind (Task 1697) ---------------------------------------------
496
+ //
497
+ // Sizing a get needs one record, not the prefix. These tests pin the two
498
+ // properties that make the early exit safe: it matches the exact key, and it
499
+ // stops at the page carrying it.
500
+ (0, node_test_1.default)("r2ObjectFind returns the exact key when a prefix neighbour is listed first", async () => {
501
+ // Not hypothetical. Measured 2026-07-16 against the live v4 API: prefix=
502
+ // a/photo.jpg really does return a/photo.jpg.bak BEFORE a/photo.jpg, because
503
+ // the listing is not in ascending UTF-8 binary order. Taking the first element
504
+ // would size the neighbour, which either rejects a legal get or admits an
505
+ // oversized one.
506
+ const { fetchFn } = recordingFetch({
507
+ ok: true,
508
+ status: 200,
509
+ body: objectPage([
510
+ { key: "a/photo.jpg.bak", size: 999 },
511
+ { key: "a/photo.jpg", size: 3 },
512
+ ]),
513
+ });
514
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
515
+ const found = await cf.r2ObjectFind("b", "a/photo.jpg");
516
+ strict_1.default.equal(found?.key, "a/photo.jpg");
517
+ strict_1.default.equal(found?.size, 3);
518
+ });
519
+ (0, node_test_1.default)("r2ObjectFind sends the key as the prefix, encoded", async () => {
520
+ const { fetchFn, calls } = recordingFetch({ ok: true, status: 200, body: objectPage([]) });
521
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
522
+ await cf.r2ObjectFind("b", "person a/x.jpg");
523
+ strict_1.default.ok(calls[0].url.endsWith("/objects?prefix=person+a%2Fx.jpg"), calls[0].url);
524
+ });
525
+ (0, node_test_1.default)("r2ObjectFind stops at the page carrying the match and fetches no further page", async () => {
526
+ // The page says truncated with a cursor, so r2ObjectList would fetch page 2.
527
+ // sequenceFetch throws on an unexpected call, so a second fetch fails here.
528
+ const { fetchFn, calls } = sequenceFetch([
529
+ {
530
+ ok: true,
531
+ status: 200,
532
+ body: objectPage([{ key: "k", size: 7 }], { cursor: "c1", truncated: true }),
533
+ },
534
+ ]);
535
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
536
+ const found = await cf.r2ObjectFind("b", "k");
537
+ strict_1.default.equal(found?.size, 7);
538
+ strict_1.default.equal(calls.length, 1);
539
+ });
540
+ (0, node_test_1.default)("r2ObjectFind finds a match on a later page, never assuming position", async () => {
541
+ const { fetchFn, calls } = sequenceFetch([
542
+ { ok: true, status: 200, body: objectPage(["x.jpg"], { cursor: "c1", truncated: true }) },
543
+ { ok: true, status: 200, body: objectPage([{ key: "k", size: 5 }], { truncated: false }) },
544
+ ]);
545
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
546
+ const found = await cf.r2ObjectFind("b", "k");
547
+ strict_1.default.equal(found?.size, 5);
548
+ strict_1.default.equal(calls.length, 2);
549
+ });
550
+ (0, node_test_1.default)("r2ObjectFind returns null when the key is absent after exhausting pages", async () => {
551
+ const { fetchFn, calls } = sequenceFetch([
552
+ { ok: true, status: 200, body: objectPage(["k.bak"], { cursor: "c1", truncated: true }) },
553
+ { ok: true, status: 200, body: objectPage(["k.zzz"], { truncated: false }) },
554
+ ]);
555
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
556
+ strict_1.default.equal(await cf.r2ObjectFind("b", "k"), null);
557
+ strict_1.default.equal(calls.length, 2);
558
+ });
559
+ (0, node_test_1.default)("r2ObjectFind throws on a repeated cursor instead of looping forever", async () => {
560
+ const { fetchFn } = sequenceFetch([
561
+ { ok: true, status: 200, body: objectPage(["x"], { cursor: "c1", truncated: true }) },
562
+ { ok: true, status: 200, body: objectPage(["y"], { cursor: "c1", truncated: true }) },
563
+ ]);
564
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
565
+ await strict_1.default.rejects(() => cf.r2ObjectFind("b", "k"), /repeated pagination cursor/);
566
+ });
567
+ (0, node_test_1.default)("r2ObjectFind throws when truncated but no cursor is given", async () => {
568
+ const { fetchFn } = recordingFetch({
569
+ ok: true,
570
+ status: 200,
571
+ body: objectPage(["x"], { cursor: "", truncated: true }),
572
+ });
573
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
574
+ await strict_1.default.rejects(() => cf.r2ObjectFind("b", "k"), /truncated but gave no pagination cursor/);
575
+ });
576
+ (0, node_test_1.default)("r2ObjectFind retains the body on a non-2xx", async () => {
577
+ const { fetchFn } = recordingFetch({ ok: false, status: 403, body: '{"errors":[{"code":10000}]}' });
578
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
579
+ await strict_1.default.rejects(() => cf.r2ObjectFind("b", "k"), /10000/);
580
+ });
581
+ // Code review — the early exit changes observable behaviour on an anomalous
582
+ // page, and an unpinned behaviour change is how a regression starts. Pinned as a
583
+ // decision rather than left implicit.
584
+ //
585
+ // A page that reports truncated with no cursor is an anomaly r2ObjectList throws
586
+ // on, because a partial listing would corrupt the audit. r2ObjectFind returns at
587
+ // the match and never reaches that check, so the same response yields the size
588
+ // instead of an error. That is correct: the size is Cloudflare's own record for
589
+ // the exact key, the cap is enforced on it, and the anomaly concerns pages the
590
+ // sizing does not need. The audit's behaviour on the same response is unchanged
591
+ // and is pinned by "r2ObjectList throws when truncated but no cursor is given".
592
+ (0, node_test_1.default)("r2ObjectFind returns a match found before an anomalous page's guard is reached", async () => {
593
+ const { fetchFn } = recordingFetch({
594
+ ok: true,
595
+ status: 200,
596
+ body: objectPage([{ key: "k", size: 4 }], { cursor: "", truncated: true }),
597
+ });
598
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
599
+ const found = await cf.r2ObjectFind("b", "k");
600
+ strict_1.default.equal(found?.size, 4);
601
+ });
492
602
  //# sourceMappingURL=cf-exec.test.js.map