@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Fastify integration tests for `GET /api/pi-core/changelog`.
3
+ *
4
+ * Covers every scenario in spec
5
+ * `pi-changelog-display#Requirement: Changelog REST endpoint`.
6
+ *
7
+ * See change: pi-update-whats-new-panel.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import Fastify, { type FastifyInstance } from "fastify";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import { registerPiChangelogRoutes } from "../routes/pi-changelog-routes.js";
15
+ import { _resetChangelogCache } from "../changelog-parser.js";
16
+
17
+ // We can't easily patch findChangelogPath at import-time (vitest module
18
+ // mocks vary), so we set up a fake managed install at HOME/.pi-dashboard
19
+ // and rely on findChangelogPath's defaultManagedDir() reading $HOME.
20
+ let tmpHome: string;
21
+ let originalHome: string | undefined;
22
+
23
+ function makeManagedPkg(pkg: string, files: Record<string, string>): string {
24
+ const dir = path.join(tmpHome, ".pi-dashboard", "node_modules", pkg);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ for (const [name, content] of Object.entries(files)) {
27
+ fs.writeFileSync(path.join(dir, name), content);
28
+ }
29
+ return dir;
30
+ }
31
+
32
+ const SAMPLE_CHANGELOG = `# Changelog
33
+
34
+ ## [0.70.0] - 2026-04-23
35
+
36
+ ### Breaking Changes
37
+
38
+ - broke X
39
+
40
+ ### Fixed
41
+
42
+ - a fix
43
+
44
+ ## [0.69.0] - 2026-04-22
45
+
46
+ ### Breaking Changes
47
+
48
+ - broke Y
49
+
50
+ ## [0.68.0] - 2026-04-20
51
+
52
+ ### Fixed
53
+
54
+ - another fix
55
+ `;
56
+
57
+ describe("pi-changelog-routes", () => {
58
+ let app: FastifyInstance;
59
+ let bootstrapState: any;
60
+ let originalOffline: string | undefined;
61
+
62
+ beforeEach(async () => {
63
+ _resetChangelogCache();
64
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-cl-route-"));
65
+ originalHome = process.env.HOME;
66
+ process.env.HOME = tmpHome;
67
+ // Disable remote-CHANGELOG fetch so existing tests deterministically
68
+ // exercise the local-file path. The new remote-source tests below
69
+ // toggle PI_OFFLINE off and stub fetch explicitly. See change:
70
+ // read-changelog-from-github.
71
+ originalOffline = process.env.PI_OFFLINE;
72
+ process.env.PI_OFFLINE = "1";
73
+ bootstrapState = {
74
+ get: () => ({ status: "ready" as const }),
75
+ };
76
+ app = Fastify({ logger: false });
77
+ registerPiChangelogRoutes(app, { bootstrapState });
78
+ await app.ready();
79
+ });
80
+
81
+ afterEach(async () => {
82
+ await app.close();
83
+ if (originalHome !== undefined) process.env.HOME = originalHome;
84
+ if (originalOffline !== undefined) process.env.PI_OFFLINE = originalOffline;
85
+ else delete process.env.PI_OFFLINE;
86
+ fs.rmSync(tmpHome, { recursive: true, force: true });
87
+ });
88
+
89
+ it("returns 200 with filtered releases for a valid range", async () => {
90
+ makeManagedPkg("@mariozechner/pi-coding-agent", {
91
+ "CHANGELOG.md": SAMPLE_CHANGELOG,
92
+ "package.json": JSON.stringify({
93
+ name: "@mariozechner/pi-coding-agent",
94
+ version: "0.70.0",
95
+ repository: "https://github.com/badlogic/pi-mono.git",
96
+ }),
97
+ });
98
+ const res = await app.inject({
99
+ method: "GET",
100
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.68.0&to=0.70.0",
101
+ });
102
+ expect(res.statusCode).toBe(200);
103
+ const body = res.json();
104
+ expect(body.pkg).toBe("@mariozechner/pi-coding-agent");
105
+ expect(body.from).toBe("0.68.0");
106
+ expect(body.to).toBe("0.70.0");
107
+ // (0.68.0, 0.70.0] → 0.69.0 + 0.70.0
108
+ expect(body.releases.map((r: any) => r.version)).toEqual(["0.70.0", "0.69.0"]);
109
+ expect(body.hasBreaking).toBe(true);
110
+ expect(body.changelogUrl).toBe(
111
+ "https://github.com/badlogic/pi-mono/blob/main/CHANGELOG.md",
112
+ );
113
+ expect(typeof body.parsedAt).toBe("string");
114
+ });
115
+
116
+ it("hasBreaking is false when no release in range has breaking changes", async () => {
117
+ makeManagedPkg("@mariozechner/pi-coding-agent", {
118
+ "CHANGELOG.md": SAMPLE_CHANGELOG,
119
+ "package.json": JSON.stringify({
120
+ name: "@mariozechner/pi-coding-agent",
121
+ version: "0.68.0",
122
+ }),
123
+ });
124
+ const res = await app.inject({
125
+ method: "GET",
126
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.67.0&to=0.68.0",
127
+ });
128
+ const body = res.json();
129
+ expect(body.releases.map((r: any) => r.version)).toEqual(["0.68.0"]);
130
+ expect(body.hasBreaking).toBe(false);
131
+ });
132
+
133
+ it("rejects pkg outside the core whitelist with 400", async () => {
134
+ const res = await app.inject({
135
+ method: "GET",
136
+ url: "/api/pi-core/changelog?pkg=evil-pkg&from=0.0.1&to=0.0.2",
137
+ });
138
+ expect(res.statusCode).toBe(400);
139
+ expect(res.json().error).toMatch(/pkg must be one of/);
140
+ });
141
+
142
+ it("returns 200 with empty releases when CHANGELOG missing", async () => {
143
+ // Whitelisted package but nothing on disk.
144
+ const res = await app.inject({
145
+ method: "GET",
146
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.68.0&to=0.70.0",
147
+ });
148
+ expect(res.statusCode).toBe(200);
149
+ const body = res.json();
150
+ expect(body.releases).toEqual([]);
151
+ expect(body.hasBreaking).toBe(false);
152
+ expect(body.changelogUrl).toBeNull();
153
+ });
154
+
155
+ it("rejects missing from/to with 400", async () => {
156
+ const res = await app.inject({
157
+ method: "GET",
158
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.68.0",
159
+ });
160
+ expect(res.statusCode).toBe(400);
161
+ });
162
+
163
+ it("rejects unparseable versions with 400", async () => {
164
+ const res = await app.inject({
165
+ method: "GET",
166
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=junk&to=0.70.0",
167
+ });
168
+ expect(res.statusCode).toBe(400);
169
+ });
170
+
171
+ it("returns 503 when bootstrap is not ready", async () => {
172
+ await app.close();
173
+ bootstrapState.get = () => ({ status: "installing" as const });
174
+ app = Fastify({ logger: false });
175
+ registerPiChangelogRoutes(app, { bootstrapState });
176
+ await app.ready();
177
+ const res = await app.inject({
178
+ method: "GET",
179
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.68.0&to=0.70.0",
180
+ });
181
+ expect(res.statusCode).toBe(503);
182
+ expect(res.json().bootstrap).toBe("installing");
183
+ });
184
+
185
+ it("returns no releases when from === to", async () => {
186
+ makeManagedPkg("@mariozechner/pi-coding-agent", {
187
+ "CHANGELOG.md": SAMPLE_CHANGELOG,
188
+ "package.json": "{}",
189
+ });
190
+ const res = await app.inject({
191
+ method: "GET",
192
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.70.0&to=0.70.0",
193
+ });
194
+ const body = res.json();
195
+ // Half-open (from, to] — equal endpoints means range is empty.
196
+ expect(body.releases).toEqual([]);
197
+ expect(body.hasBreaking).toBe(false);
198
+ });
199
+
200
+ it("derives null changelogUrl when repository is missing or non-GitHub", async () => {
201
+ makeManagedPkg("@mariozechner/pi-coding-agent", {
202
+ "CHANGELOG.md": SAMPLE_CHANGELOG,
203
+ "package.json": JSON.stringify({ name: "x", version: "0.70.0" }),
204
+ });
205
+ const res = await app.inject({
206
+ method: "GET",
207
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.68.0&to=0.70.0",
208
+ });
209
+ expect(res.json().changelogUrl).toBeNull();
210
+ });
211
+ });
212
+
213
+ // ── Remote-CHANGELOG path ─────────────────────────────────────────────────────────
214
+ // See change: read-changelog-from-github.
215
+
216
+ import { vi } from "vitest";
217
+
218
+ describe("pi-changelog-routes remote source", () => {
219
+ let app: FastifyInstance;
220
+ let tmpHomeRemote: string;
221
+ let originalHomeRemote: string | undefined;
222
+ let originalOfflineRemote: string | undefined;
223
+ let originalFetch: typeof globalThis.fetch;
224
+
225
+ // SAMPLE_CHANGELOG_REMOTE simulates an upstream CHANGELOG that
226
+ // contains a release entry NEWER than what the local install knows.
227
+ const SAMPLE_CHANGELOG_REMOTE = `# Changelog
228
+
229
+ ## [0.99.0] - 2026-12-01
230
+
231
+ ### Breaking Changes
232
+
233
+ - only-on-remote breaking change
234
+
235
+ ### Fixed
236
+
237
+ - only-on-remote fix
238
+
239
+ ## [0.70.0] - 2026-04-23
240
+
241
+ ### Fixed
242
+
243
+ - old fix
244
+ `;
245
+
246
+ // SAMPLE_CHANGELOG_LOCAL simulates the locally-installed older copy.
247
+ const SAMPLE_CHANGELOG_LOCAL = `# Changelog
248
+
249
+ ## [0.70.0] - 2026-04-23
250
+
251
+ ### Fixed
252
+
253
+ - old fix
254
+ `;
255
+
256
+ function makePkg(home: string, pkg: string, files: Record<string, string>): string {
257
+ const dir = path.join(home, ".pi-dashboard", "node_modules", pkg);
258
+ fs.mkdirSync(dir, { recursive: true });
259
+ for (const [name, content] of Object.entries(files)) {
260
+ fs.writeFileSync(path.join(dir, name), content);
261
+ }
262
+ return dir;
263
+ }
264
+
265
+ beforeEach(async () => {
266
+ _resetChangelogCache();
267
+ tmpHomeRemote = fs.mkdtempSync(path.join(os.tmpdir(), "pi-cl-remote-"));
268
+ originalHomeRemote = process.env.HOME;
269
+ process.env.HOME = tmpHomeRemote;
270
+ // Remote path needs PI_OFFLINE OFF.
271
+ originalOfflineRemote = process.env.PI_OFFLINE;
272
+ delete process.env.PI_OFFLINE;
273
+ originalFetch = globalThis.fetch;
274
+ app = Fastify({ logger: false });
275
+ registerPiChangelogRoutes(app, {
276
+ bootstrapState: { get: () => ({ status: "ready" as const }) } as any,
277
+ });
278
+ await app.ready();
279
+ });
280
+
281
+ afterEach(async () => {
282
+ await app.close();
283
+ if (originalHomeRemote !== undefined) process.env.HOME = originalHomeRemote;
284
+ if (originalOfflineRemote !== undefined) process.env.PI_OFFLINE = originalOfflineRemote;
285
+ else delete process.env.PI_OFFLINE;
286
+ globalThis.fetch = originalFetch;
287
+ fs.rmSync(tmpHomeRemote, { recursive: true, force: true });
288
+ });
289
+
290
+ it("prefers remote CHANGELOG over local when both are available", async () => {
291
+ makePkg(tmpHomeRemote, "@mariozechner/pi-coding-agent", {
292
+ "CHANGELOG.md": SAMPLE_CHANGELOG_LOCAL,
293
+ "package.json": JSON.stringify({
294
+ name: "@mariozechner/pi-coding-agent",
295
+ version: "0.70.0",
296
+ repository: { type: "git", url: "git+https://github.com/badlogic/pi-mono.git", directory: "packages/coding-agent" },
297
+ }),
298
+ });
299
+ globalThis.fetch = vi.fn().mockResolvedValue({
300
+ ok: true,
301
+ status: 200,
302
+ headers: new Headers({ etag: '"v1"' }),
303
+ text: async () => SAMPLE_CHANGELOG_REMOTE,
304
+ }) as any;
305
+
306
+ const res = await app.inject({
307
+ method: "GET",
308
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.70.0&to=0.99.0",
309
+ });
310
+ expect(res.statusCode).toBe(200);
311
+ const body = res.json();
312
+ // Range (0.70.0, 0.99.0] — should include 0.99.0 from REMOTE,
313
+ // which the local file does not contain.
314
+ expect(body.releases.map((r: any) => r.version)).toEqual(["0.99.0"]);
315
+ expect(body.hasBreaking).toBe(true);
316
+ expect(body.releases[0].breaking[0].text).toBe("only-on-remote breaking change");
317
+ // changelogUrl is the human URL (the /blob/main/ form), not raw.
318
+ expect(body.changelogUrl).toBe(
319
+ "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md",
320
+ );
321
+ });
322
+
323
+ it("falls back to local CHANGELOG when remote fails", async () => {
324
+ makePkg(tmpHomeRemote, "@mariozechner/pi-coding-agent", {
325
+ "CHANGELOG.md": SAMPLE_CHANGELOG_LOCAL,
326
+ "package.json": JSON.stringify({
327
+ name: "@mariozechner/pi-coding-agent",
328
+ version: "0.70.0",
329
+ repository: { type: "git", url: "git+https://github.com/badlogic/pi-mono.git" },
330
+ }),
331
+ });
332
+ // Remote returns 503 → fetchRemoteChangelog returns null → route falls
333
+ // back to local.
334
+ globalThis.fetch = vi.fn().mockResolvedValue({
335
+ ok: false,
336
+ status: 503,
337
+ headers: new Headers(),
338
+ text: async () => "",
339
+ }) as any;
340
+
341
+ const res = await app.inject({
342
+ method: "GET",
343
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.69.0&to=0.70.0",
344
+ });
345
+ expect(res.statusCode).toBe(200);
346
+ const body = res.json();
347
+ // Range (0.69.0, 0.70.0] — local has only 0.70.0; remote returned 503.
348
+ expect(body.releases.map((r: any) => r.version)).toEqual(["0.70.0"]);
349
+ });
350
+
351
+ it("PI_OFFLINE=1 skips remote and reads local directly", async () => {
352
+ process.env.PI_OFFLINE = "1";
353
+ makePkg(tmpHomeRemote, "@mariozechner/pi-coding-agent", {
354
+ "CHANGELOG.md": SAMPLE_CHANGELOG_LOCAL,
355
+ "package.json": JSON.stringify({
356
+ name: "@mariozechner/pi-coding-agent",
357
+ version: "0.70.0",
358
+ repository: "https://github.com/badlogic/pi-mono.git",
359
+ }),
360
+ });
361
+ let fetchCalled = false;
362
+ globalThis.fetch = vi.fn().mockImplementation(() => {
363
+ fetchCalled = true;
364
+ return Promise.resolve({
365
+ ok: true,
366
+ status: 200,
367
+ headers: new Headers(),
368
+ text: async () => SAMPLE_CHANGELOG_REMOTE,
369
+ });
370
+ }) as any;
371
+
372
+ const res = await app.inject({
373
+ method: "GET",
374
+ url: "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.69.0&to=0.99.0",
375
+ });
376
+ expect(res.statusCode).toBe(200);
377
+ expect(fetchCalled).toBe(false);
378
+ // 0.99.0 only exists in remote — with PI_OFFLINE we don't see it.
379
+ const body = res.json();
380
+ expect(body.releases.map((r: any) => r.version)).toEqual(["0.70.0"]);
381
+ });
382
+
383
+ it("second request within TTL serves cached remote without re-fetching", async () => {
384
+ makePkg(tmpHomeRemote, "@mariozechner/pi-coding-agent", {
385
+ "CHANGELOG.md": SAMPLE_CHANGELOG_LOCAL,
386
+ "package.json": JSON.stringify({
387
+ name: "@mariozechner/pi-coding-agent",
388
+ version: "0.70.0",
389
+ repository: "https://github.com/badlogic/pi-mono.git",
390
+ }),
391
+ });
392
+ let fetchCalls = 0;
393
+ globalThis.fetch = vi.fn().mockImplementation(() => {
394
+ fetchCalls++;
395
+ return Promise.resolve({
396
+ ok: true,
397
+ status: 200,
398
+ headers: new Headers({ etag: '"v1"' }),
399
+ text: async () => SAMPLE_CHANGELOG_REMOTE,
400
+ });
401
+ }) as any;
402
+
403
+ const url =
404
+ "/api/pi-core/changelog?pkg=@mariozechner/pi-coding-agent&from=0.70.0&to=0.99.0";
405
+ await app.inject({ method: "GET", url });
406
+ await app.inject({ method: "GET", url });
407
+ expect(fetchCalls).toBe(1);
408
+ });
409
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import fs from "node:fs";
@@ -34,9 +34,22 @@ describe("PiCoreChecker._internal.looksLikePiEcosystem", () => {
34
34
 
35
35
  describe("PiCoreChecker.getStatus", () => {
36
36
  let tmpManagedDir: string;
37
+ let originalOffline: string | undefined;
37
38
 
38
39
  beforeEach(() => {
39
40
  tmpManagedDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-core-test-"));
41
+ // Disable the default pi.dev fetcher in this block so existing
42
+ // tests (which mock fetchLatest only) aren't surprised by live
43
+ // network calls. The pi.dev integration tests in the next describe
44
+ // block inject `fetchPiDevRelease` explicitly and therefore bypass
45
+ // this env. See change: improve-pi-update-detection.
46
+ originalOffline = process.env.PI_OFFLINE;
47
+ process.env.PI_OFFLINE = "1";
48
+ });
49
+
50
+ afterEach(() => {
51
+ if (originalOffline !== undefined) process.env.PI_OFFLINE = originalOffline;
52
+ else delete process.env.PI_OFFLINE;
40
53
  });
41
54
 
42
55
  function writeManagedPackage(managedDir: string, name: string, version: string) {
@@ -50,14 +63,14 @@ describe("PiCoreChecker.getStatus", () => {
50
63
  npmList: async () =>
51
64
  JSON.stringify({
52
65
  dependencies: {
53
- "@mariozechner/pi-coding-agent": { version: "0.67.1" },
66
+ "@earendil-works/pi-coding-agent": { version: "0.67.1" },
54
67
  "@blackbelt-technology/pi-agent-dashboard": { version: "0.4.0" },
55
68
  "pi-web-access": { version: "0.10.6" }, // NOT in whitelist → ignored
56
69
  react: { version: "19.0.0" }, // ignored
57
70
  },
58
71
  }),
59
72
  fetchLatest: async (name) => {
60
- if (name === "@mariozechner/pi-coding-agent") return "0.67.6";
73
+ if (name === "@earendil-works/pi-coding-agent") return "0.67.6";
61
74
  if (name === "@blackbelt-technology/pi-agent-dashboard") return "0.4.1";
62
75
  return null;
63
76
  },
@@ -69,7 +82,7 @@ describe("PiCoreChecker.getStatus", () => {
69
82
  expect(status.packages.length).toBe(2);
70
83
  expect(status.packages.find((p) => p.name === "pi-web-access")).toBeUndefined();
71
84
 
72
- const pi = status.packages.find((p) => p.name === "@mariozechner/pi-coding-agent")!;
85
+ const pi = status.packages.find((p) => p.name === "@earendil-works/pi-coding-agent")!;
73
86
  expect(pi.displayName).toBe("pi (core agent)");
74
87
  expect(pi.currentVersion).toBe("0.67.1");
75
88
  expect(pi.latestVersion).toBe("0.67.6");
@@ -103,13 +116,13 @@ describe("PiCoreChecker.getStatus", () => {
103
116
  });
104
117
 
105
118
  it("discovers managed packages and prefers them over global duplicates", async () => {
106
- writeManagedPackage(tmpManagedDir, "@mariozechner/pi-coding-agent", "0.67.5");
119
+ writeManagedPackage(tmpManagedDir, "@earendil-works/pi-coding-agent", "0.67.5");
107
120
 
108
121
  const checker = new PiCoreChecker({
109
122
  npmList: async () =>
110
123
  JSON.stringify({
111
124
  dependencies: {
112
- "@mariozechner/pi-coding-agent": { version: "0.67.1" },
125
+ "@earendil-works/pi-coding-agent": { version: "0.67.1" },
113
126
  },
114
127
  }),
115
128
  fetchLatest: async () => "0.67.6",
@@ -155,7 +168,7 @@ describe("PiCoreChecker.getStatus", () => {
155
168
  const err = new Error("npm warn") as Error & { stdout: string };
156
169
  err.stdout = JSON.stringify({
157
170
  dependencies: {
158
- "@mariozechner/pi-coding-agent": { version: "0.67.1" },
171
+ "@earendil-works/pi-coding-agent": { version: "0.67.1" },
159
172
  },
160
173
  });
161
174
  throw err;
@@ -165,7 +178,7 @@ describe("PiCoreChecker.getStatus", () => {
165
178
  });
166
179
  const status = await checker.getStatus();
167
180
  expect(status.packages.length).toBe(1);
168
- expect(status.packages[0].name).toBe("@mariozechner/pi-coding-agent");
181
+ expect(status.packages[0].name).toBe("@earendil-works/pi-coding-agent");
169
182
  });
170
183
 
171
184
  it("caches results within 5 minutes", async () => {
@@ -174,7 +187,7 @@ describe("PiCoreChecker.getStatus", () => {
174
187
  npmList: async () => {
175
188
  calls++;
176
189
  return JSON.stringify({
177
- dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
190
+ dependencies: { "@earendil-works/pi-coding-agent": { version: "0.67.1" } },
178
191
  });
179
192
  },
180
193
  fetchLatest: async () => "0.67.6",
@@ -191,7 +204,7 @@ describe("PiCoreChecker.getStatus", () => {
191
204
  npmList: async () => {
192
205
  calls++;
193
206
  return JSON.stringify({
194
- dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
207
+ dependencies: { "@earendil-works/pi-coding-agent": { version: "0.67.1" } },
195
208
  });
196
209
  },
197
210
  fetchLatest: async () => "0.67.6",
@@ -206,7 +219,7 @@ describe("PiCoreChecker.getStatus", () => {
206
219
  const checker = new PiCoreChecker({
207
220
  npmList: async () =>
208
221
  JSON.stringify({
209
- dependencies: { "@mariozechner/pi-coding-agent": { version: "0.67.1" } },
222
+ dependencies: { "@earendil-works/pi-coding-agent": { version: "0.67.1" } },
210
223
  }),
211
224
  fetchLatest: async () => {
212
225
  throw new Error("network down");
@@ -225,14 +238,143 @@ describe("PiCoreChecker.getStatus", () => {
225
238
  JSON.stringify({
226
239
  dependencies: {
227
240
  "@blackbelt-technology/pi-agent-dashboard": { version: "0.4.0" },
228
- "@mariozechner/pi-coding-agent": { version: "0.67.1" },
241
+ "@earendil-works/pi-coding-agent": { version: "0.67.1" },
229
242
  },
230
243
  }),
231
244
  fetchLatest: async () => null,
232
245
  managedDir: path.join(tmpManagedDir, "nope"),
233
246
  });
234
247
  const status = await checker.getStatus();
235
- expect(status.packages[0].name).toBe("@mariozechner/pi-coding-agent");
248
+ expect(status.packages[0].name).toBe("@earendil-works/pi-coding-agent");
236
249
  expect(status.packages[1].name).toBe("@blackbelt-technology/pi-agent-dashboard");
237
250
  });
238
251
  });
252
+
253
+ describe("PiCoreChecker pi.dev integration", () => {
254
+ let tmpManagedDir: string;
255
+
256
+ beforeEach(async () => {
257
+ tmpManagedDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-core-pidev-"));
258
+ const { _resetDynamicPiAliases } = await import("../pi-core-checker.js");
259
+ _resetDynamicPiAliases();
260
+ });
261
+
262
+ it("prefers pi.dev for @mariozechner/pi-coding-agent latestVersion", async () => {
263
+ let npmCalled = false;
264
+ const checker = new PiCoreChecker({
265
+ npmList: async () =>
266
+ JSON.stringify({
267
+ dependencies: {
268
+ "@mariozechner/pi-coding-agent": { version: "0.70.6" },
269
+ },
270
+ }),
271
+ fetchLatest: async () => {
272
+ npmCalled = true;
273
+ return "0.73.1"; // npm registry says 0.73.1 …
274
+ },
275
+ fetchPiDevRelease: async () => ({ version: "0.74.0" }), // … but pi.dev says 0.74.0
276
+ managedDir: tmpManagedDir,
277
+ });
278
+ const status = await checker.getStatus();
279
+ const pi = status.packages.find((p) => p.name === "@mariozechner/pi-coding-agent")!;
280
+ expect(pi.latestVersion).toBe("0.74.0");
281
+ expect(pi.updateAvailable).toBe(true);
282
+ expect(npmCalled).toBe(false);
283
+ });
284
+
285
+ it("falls back to npm registry when pi.dev returns undefined", async () => {
286
+ let npmCalled = false;
287
+ const checker = new PiCoreChecker({
288
+ npmList: async () =>
289
+ JSON.stringify({
290
+ dependencies: {
291
+ "@mariozechner/pi-coding-agent": { version: "0.70.6" },
292
+ },
293
+ }),
294
+ fetchLatest: async () => {
295
+ npmCalled = true;
296
+ return "0.73.1";
297
+ },
298
+ fetchPiDevRelease: async () => undefined, // pi.dev unreachable / skipped
299
+ managedDir: tmpManagedDir,
300
+ });
301
+ const status = await checker.getStatus();
302
+ const pi = status.packages.find((p) => p.name === "@mariozechner/pi-coding-agent")!;
303
+ expect(pi.latestVersion).toBe("0.73.1");
304
+ expect(npmCalled).toBe(true);
305
+ });
306
+
307
+ it("falls back to npm registry when pi.dev throws", async () => {
308
+ const checker = new PiCoreChecker({
309
+ npmList: async () =>
310
+ JSON.stringify({
311
+ dependencies: {
312
+ "@mariozechner/pi-coding-agent": { version: "0.70.6" },
313
+ },
314
+ }),
315
+ fetchLatest: async () => "0.73.1",
316
+ fetchPiDevRelease: async () => {
317
+ throw new Error("network down");
318
+ },
319
+ managedDir: tmpManagedDir,
320
+ });
321
+ const status = await checker.getStatus();
322
+ const pi = status.packages.find((p) => p.name === "@mariozechner/pi-coding-agent")!;
323
+ expect(pi.latestVersion).toBe("0.73.1");
324
+ });
325
+
326
+ it("does NOT call pi.dev for non-pi packages", async () => {
327
+ let piDevCalled = false;
328
+ const checker = new PiCoreChecker({
329
+ npmList: async () =>
330
+ JSON.stringify({
331
+ dependencies: {
332
+ "@blackbelt-technology/pi-agent-dashboard": { version: "0.4.0" },
333
+ },
334
+ }),
335
+ fetchLatest: async () => "0.5.0",
336
+ fetchPiDevRelease: async () => {
337
+ piDevCalled = true;
338
+ return { version: "99.99.99" };
339
+ },
340
+ managedDir: tmpManagedDir,
341
+ });
342
+ const status = await checker.getStatus();
343
+ const dash = status.packages.find((p) => p.name === "@blackbelt-technology/pi-agent-dashboard")!;
344
+ expect(dash.latestVersion).toBe("0.5.0");
345
+ expect(piDevCalled).toBe(false);
346
+ });
347
+
348
+ it("records pi.dev's packageName as a trusted alias", async () => {
349
+ const checker = new PiCoreChecker({
350
+ npmList: async () =>
351
+ JSON.stringify({
352
+ dependencies: {
353
+ "@mariozechner/pi-coding-agent": { version: "0.70.6" },
354
+ },
355
+ }),
356
+ fetchLatest: async () => "0.70.6",
357
+ fetchPiDevRelease: async () => ({
358
+ version: "0.74.0",
359
+ packageName: "@earendil-works/pi-coding-agent",
360
+ }),
361
+ managedDir: tmpManagedDir,
362
+ });
363
+ await checker.getStatus();
364
+
365
+ // After the alias is recorded, a second discovery that finds the
366
+ // renamed package should accept it through the whitelist gate.
367
+ writeManagedPackage(tmpManagedDir, "@earendil-works/pi-coding-agent", "0.74.0");
368
+ checker.invalidate();
369
+ const status2 = await checker.getStatus();
370
+ const aliased = status2.packages.find((p) => p.name === "@earendil-works/pi-coding-agent");
371
+ expect(aliased).toBeDefined();
372
+ expect(aliased!.currentVersion).toBe("0.74.0");
373
+ });
374
+
375
+ function writeManagedPackage(managedDir: string, name: string, version: string) {
376
+ const dir = path.join(managedDir, "node_modules", name);
377
+ fs.mkdirSync(dir, { recursive: true });
378
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version }));
379
+ }
380
+ });