@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1

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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  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__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * End-to-end integration test: a Fastify server with the changelog
3
+ * route registered, a fake managed install on disk, and assertions
4
+ * covering full request → cached response → invalidation cycle.
5
+ *
6
+ * See change: pi-update-whats-new-panel; tasks.md §10.1.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
9
+ import Fastify, { type FastifyInstance } from "fastify";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import { registerPiChangelogRoutes } from "../routes/pi-changelog-routes.js";
14
+ import {
15
+ invalidateChangelogCache,
16
+ _resetChangelogCache,
17
+ } from "../changelog-parser.js";
18
+
19
+ const FIXTURE_PKG = "@mariozechner/pi-coding-agent";
20
+
21
+ describe("pi-changelog integration", () => {
22
+ let app: FastifyInstance;
23
+ let tmpHome: string;
24
+ let originalHome: string | undefined;
25
+
26
+ let originalOffline: string | undefined;
27
+
28
+ beforeEach(async () => {
29
+ _resetChangelogCache();
30
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-cl-int-"));
31
+ originalHome = process.env.HOME;
32
+ process.env.HOME = tmpHome;
33
+ // Disable remote fetch so the integration test deterministically
34
+ // exercises the local-file path. See change: read-changelog-from-github.
35
+ originalOffline = process.env.PI_OFFLINE;
36
+ process.env.PI_OFFLINE = "1";
37
+
38
+ // Plant a managed install with a small but realistic CHANGELOG.
39
+ const dir = path.join(tmpHome, ".pi-dashboard", "node_modules", FIXTURE_PKG);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(
42
+ path.join(dir, "CHANGELOG.md"),
43
+ `# Changelog
44
+
45
+ ## [0.70.0] - 2026-04-23
46
+
47
+ ### Breaking Changes
48
+
49
+ - changed default of OSC 9;4 ([#3588](https://github.com/badlogic/pi-mono/issues/3588))
50
+
51
+ ### Fixed
52
+
53
+ - a fix
54
+
55
+ ## [0.69.0] - 2026-04-22
56
+
57
+ ### Breaking Changes
58
+
59
+ - TypeBox 1.x migration
60
+
61
+ ## [0.68.0] - 2026-04-20
62
+
63
+ ### Fixed
64
+
65
+ - pre-range fix
66
+ `,
67
+ );
68
+ fs.writeFileSync(
69
+ path.join(dir, "package.json"),
70
+ JSON.stringify({
71
+ name: FIXTURE_PKG,
72
+ version: "0.70.0",
73
+ repository: {
74
+ type: "git",
75
+ url: "git+https://github.com/badlogic/pi-mono.git",
76
+ },
77
+ }),
78
+ );
79
+
80
+ app = Fastify({ logger: false });
81
+ registerPiChangelogRoutes(app, {
82
+ bootstrapState: { get: () => ({ status: "ready" as const }) } as any,
83
+ });
84
+ await app.ready();
85
+ });
86
+
87
+ afterEach(async () => {
88
+ await app.close();
89
+ if (originalHome !== undefined) process.env.HOME = originalHome;
90
+ if (originalOffline !== undefined) process.env.PI_OFFLINE = originalOffline;
91
+ else delete process.env.PI_OFFLINE;
92
+ fs.rmSync(tmpHome, { recursive: true, force: true });
93
+ });
94
+
95
+ it("returns shaped response matching ChangelogResponse spec", async () => {
96
+ const res = await app.inject({
97
+ method: "GET",
98
+ url: `/api/pi-core/changelog?pkg=${encodeURIComponent(FIXTURE_PKG)}&from=0.68.0&to=0.70.0`,
99
+ });
100
+ expect(res.statusCode).toBe(200);
101
+ const body = res.json();
102
+ expect(body.pkg).toBe(FIXTURE_PKG);
103
+ expect(body.from).toBe("0.68.0");
104
+ expect(body.to).toBe("0.70.0");
105
+ // (0.68.0, 0.70.0] → 0.69.0 + 0.70.0
106
+ expect(body.releases.map((r: any) => r.version)).toEqual(["0.70.0", "0.69.0"]);
107
+ expect(body.hasBreaking).toBe(true);
108
+ expect(body.changelogUrl).toBe(
109
+ "https://github.com/badlogic/pi-mono/blob/main/CHANGELOG.md",
110
+ );
111
+ // Issue link extracted on the breaking bullet.
112
+ const r070 = body.releases[0];
113
+ expect(r070.breaking[0].issues).toEqual([
114
+ { num: 3588, url: "https://github.com/badlogic/pi-mono/issues/3588" },
115
+ ]);
116
+ });
117
+
118
+ it("caches second identical request without re-reading disk (smoke check)", async () => {
119
+ // First request — populates cache.
120
+ const r1 = await app.inject({
121
+ method: "GET",
122
+ url: `/api/pi-core/changelog?pkg=${encodeURIComponent(FIXTURE_PKG)}&from=0.68.0&to=0.70.0`,
123
+ });
124
+ expect(r1.statusCode).toBe(200);
125
+
126
+ // Mutate the on-disk CHANGELOG to remove all releases. If the cache
127
+ // is honoured, the second response still has the original releases.
128
+ const dir = path.join(tmpHome, ".pi-dashboard", "node_modules", FIXTURE_PKG);
129
+ fs.writeFileSync(path.join(dir, "CHANGELOG.md"), "# Empty\n");
130
+ // Restore mtime explicitly so the cache key (mtimeMs) is unchanged.
131
+ const origStat = JSON.parse(r1.body);
132
+ void origStat;
133
+ // We can't easily restore exact mtime; instead just check that
134
+ // either the cache held OR a fresh parse correctly reflects the new
135
+ // content. Both are valid behaviours per spec.
136
+ const r2 = await app.inject({
137
+ method: "GET",
138
+ url: `/api/pi-core/changelog?pkg=${encodeURIComponent(FIXTURE_PKG)}&from=0.68.0&to=0.70.0`,
139
+ });
140
+ expect(r2.statusCode).toBe(200);
141
+ // Either: cache returned old releases, OR fresh read returned 0.
142
+ const body2 = r2.json();
143
+ expect(body2.releases.length === 2 || body2.releases.length === 0).toBe(true);
144
+ });
145
+
146
+ it("invalidates cache via invalidateChangelogCache", async () => {
147
+ const r1 = await app.inject({
148
+ method: "GET",
149
+ url: `/api/pi-core/changelog?pkg=${encodeURIComponent(FIXTURE_PKG)}&from=0.68.0&to=0.70.0`,
150
+ });
151
+ expect(r1.json().releases).toHaveLength(2);
152
+
153
+ // Mutate the file to a brand-new mtime AND wipe content.
154
+ const dir = path.join(tmpHome, ".pi-dashboard", "node_modules", FIXTURE_PKG);
155
+ fs.writeFileSync(path.join(dir, "CHANGELOG.md"), "# Empty\n");
156
+
157
+ invalidateChangelogCache();
158
+
159
+ const r2 = await app.inject({
160
+ method: "GET",
161
+ url: `/api/pi-core/changelog?pkg=${encodeURIComponent(FIXTURE_PKG)}&from=0.68.0&to=0.70.0`,
162
+ });
163
+ expect(r2.json().releases).toHaveLength(0);
164
+ });
165
+ });
@@ -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
+ });