diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2ec3d9bd4..ae86551c1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Extension packages can now show their changelog after an update. Pi reads `pi.changelogPath` from the package's `package.json`, or `CHANGELOG.md` in the package root, and on the next fresh launch shows entries newer than the last seen version under "Extension Updates". The `collapseChangelog` setting condenses this to one line per package ([#5958](https://github.com/earendil-works/pi/issues/5958), [#8790](https://github.com/earendil-works/pi/pull/8790)) - Device-code login (for example GitHub Copilot) now opens the verification page and copies the code when you press Enter. Providers can turn either action off with `openBrowserOnConfirm: false` or `copyCodeOnConfirm: false` ([#9282](https://github.com/earendil-works/pi/issues/9282), [#9301](https://github.com/earendil-works/pi/pull/9301)) ### Changed diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index a4f609db8..6cf306318 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -75,6 +75,26 @@ Paths are relative to the package root. Arrays accept glob patterns and exclusio The `pi-package` keyword makes an npm package eligible for discovery in the [Pi package gallery](https://pi.dev/packages). Optional `pi.image` and `pi.video` fields add gallery previews. +## Show a changelog + +After a package update, Pi can show what changed. On the next fresh launch, Pi reads the package changelog and shows entries newer than the last seen version under "Extension Updates", the same way it shows its own changelog. The first time Pi sees a package, it records the version without showing anything. + +This applies to packages that load at least one extension. Packages that only provide skills, prompt templates, or themes do not show a changelog. + +By default Pi reads `CHANGELOG.md` from the package root. Point `pi.changelogPath` at another file inside the package, or set it to `""` to disable the changelog: + +```json +{ + "name": "my-pi-package", + "version": "1.2.0", + "pi": { + "changelogPath": "./docs/CHANGELOG.md" + } +} +``` + +Use version headings in the same format as Pi's changelog, such as `## [1.2.0]` or `## 1.2.0 - 2026-01-01`. Entries are matched against the `version` field in `package.json`, so a git package only shows a changelog when that field increases. The `collapseChangelog` setting condenses the section to one line per package, such as `Updated my-pi-package to v1.2.0.` Unlike Pi's own changelog, `/changelog` does not show package changelogs, so read the package's changelog file or repository for details. + ## Declare dependencies Put runtime packages imported by extensions in `dependencies`. Pi installs package dependencies when it installs an npm or git source. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 150eaf4d7..538a61392 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -166,7 +166,7 @@ The built-in extensions are named `builtin:mcp`, `builtin:llama.cpp`, `builtin:c | Setting | Type | Default | Description | |---|---|---|---| -| `collapseChangelog` | boolean | `false` | Show a condensed changelog after an update. | +| `collapseChangelog` | boolean | `false` | Show a condensed changelog after an update. Also condenses extension package changelogs to one line per package. | | `enableInstallTelemetry` | boolean | `true` | Enable anonymous install/update reporting and selected provider attribution headers. Does not control update checks. | | `enableAnalytics` | boolean | `false` | Opt in to analytics data sharing. Currently used only by the experimental first-run setup. | | `warnings.anthropicExtraUsage` | boolean | `true` | Warn when Anthropic subscription authentication may use paid extra usage. | diff --git a/packages/coding-agent/schemas/settings.schema.json b/packages/coding-agent/schemas/settings.schema.json index 4d6c23a6d..90eaaa3c0 100644 --- a/packages/coding-agent/schemas/settings.schema.json +++ b/packages/coding-agent/schemas/settings.schema.json @@ -11,6 +11,15 @@ "lastChangelogVersion": { "type": "string" }, + "extensionChangelogVersions": { + "type": "object", + "patternProperties": { + "^.*$": { + "type": "string" + } + }, + "description": "Last seen changelog version per extension package." + }, "defaultProvider": { "type": "string" }, diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 9b7a734d4..3bed70557 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -33,7 +33,7 @@ function getEnv(): NodeJS.ProcessEnv { } } -import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { Readable } from "node:stream"; import ignore from "ignore"; import { minimatch } from "minimatch"; @@ -77,6 +77,7 @@ export interface PathMetadata { scope: SourceScope; origin: "package" | "top-level"; baseDir?: string; + changelogPath?: string; packageRoot?: string; } @@ -674,6 +675,37 @@ function collectResourceFiles(dir: string, resourceType: ResourceType): string[] return collectFiles(dir, FILE_PATTERNS[resourceType]); } +// A `pi` object counts as an explicit resource manifest only when it lists resources. Packages whose +// `pi` object holds only metadata (changelogPath, gallery image/video) still use directory auto-discovery. +function hasManifestResourceFields(manifest: PiManifest): boolean { + return RESOURCE_TYPES.some((resourceType) => manifest[resourceType] !== undefined); +} + +function isPathInside(parent: string, child: string): boolean { + const relativePath = relative(parent, child); + return ( + relativePath !== "" && relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath) + ); +} + +function resolvePackageChangelogPath(packageRoot: string, manifest: PiManifest | null): string | undefined { + const changelogPath = manifest?.changelogPath ?? "CHANGELOG.md"; + if (!changelogPath) return undefined; + + const resolvedPath = resolve(packageRoot, changelogPath); + if (!existsSync(resolvedPath)) return undefined; + + const canonicalPackageRoot = canonicalizePath(packageRoot); + const canonicalChangelogPath = canonicalizePath(resolvedPath); + if (!isPathInside(canonicalPackageRoot, canonicalChangelogPath)) return undefined; + + try { + return statSync(resolvedPath).isFile() ? resolvedPath : undefined; + } catch { + return undefined; + } +} + function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean { const rel = toPosixPath(relative(baseDir, filePath)); const name = basename(filePath); @@ -2249,6 +2281,14 @@ export class DefaultPackageManager implements PackageManager { filter: PackageFilter | undefined, metadata: PathMetadata, ): boolean { + const manifest = readPiManifest(join(packageRoot, "package.json")); + const changelogPath = resolvePackageChangelogPath(packageRoot, manifest); + if (changelogPath) { + metadata.changelogPath = changelogPath; + } else { + delete metadata.changelogPath; + } + if (filter) { for (const resourceType of RESOURCE_TYPES) { const patterns = filter[resourceType]; @@ -2264,10 +2304,9 @@ export class DefaultPackageManager implements PackageManager { return true; } - const manifest = readPiManifest(join(packageRoot, "package.json")); - if (manifest) { + if (manifest && hasManifestResourceFields(manifest)) { for (const resourceType of RESOURCE_TYPES) { - const entries = manifest[resourceType as keyof PiManifest]; + const entries = manifest[resourceType]; this.addManifestEntries( entries, packageRoot, @@ -2301,7 +2340,7 @@ export class DefaultPackageManager implements PackageManager { metadata: PathMetadata, ): void { const manifest = readPiManifest(join(packageRoot, "package.json")); - const entries = manifest?.[resourceType as keyof PiManifest]; + const entries = manifest?.[resourceType]; if (entries) { this.addManifestEntries(entries, packageRoot, resourceType, target, metadata); return; @@ -2370,7 +2409,7 @@ export class DefaultPackageManager implements PackageManager { resourceType: ResourceType, ): { allFiles: string[]; enabledByManifest: Set } { const manifest = readPiManifest(join(packageRoot, "package.json")); - const entries = manifest?.[resourceType as keyof PiManifest]; + const entries = manifest?.[resourceType]; if (entries && entries.length > 0) { const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType); const manifestPatterns = entries.filter(isOverridePattern); diff --git a/packages/coding-agent/src/core/pi-manifest.ts b/packages/coding-agent/src/core/pi-manifest.ts index fd7dd5edb..0dca39385 100644 --- a/packages/coding-agent/src/core/pi-manifest.ts +++ b/packages/coding-agent/src/core/pi-manifest.ts @@ -6,6 +6,7 @@ export interface PiManifest { skills?: string[]; prompts?: string[]; themes?: string[]; + changelogPath?: string; } const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const; @@ -28,6 +29,12 @@ export function readPiManifest(packageJsonPath: string): PiManifest | null { manifest[field] = entries; } } + + const changelogPath = pkg.pi.changelogPath; + if (typeof changelogPath === "string") { + manifest.changelogPath = changelogPath; + } + return manifest; } catch { return null; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index ad8b82c9e..99bbeade0 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -687,6 +687,19 @@ export class SettingsManager { this.save(); } + getExtensionChangelogVersion(key: string): string | undefined { + return this.settings.extensionChangelogVersions?.[key]; + } + + setExtensionChangelogVersion(key: string, version: string): void { + this.globalSettings.extensionChangelogVersions = { + ...(this.globalSettings.extensionChangelogVersions ?? {}), + [key]: version, + }; + this.markModified("extensionChangelogVersions", key); + this.save(); + } + getSessionDir(): string | undefined { const sessionDir = this.settings.sessionDir; return sessionDir ? normalizePath(sessionDir) : sessionDir; diff --git a/packages/coding-agent/src/core/settings-schema.ts b/packages/coding-agent/src/core/settings-schema.ts index b17bab563..87a3b71a5 100644 --- a/packages/coding-agent/src/core/settings-schema.ts +++ b/packages/coding-agent/src/core/settings-schema.ts @@ -207,6 +207,11 @@ export const SettingsSchema = Type.Object( { $schema: Type.Optional(Type.String({ description: "JSON Schema reference." })), lastChangelogVersion: Type.Optional(Type.String()), + extensionChangelogVersions: Type.Optional( + Type.Record(Type.String(), Type.String(), { + description: "Last seen changelog version per extension package.", + }), + ), defaultProvider: Type.Optional(Type.String()), defaultModel: Type.Optional(Type.String()), defaultThinkingLevel: Type.Optional(ModelThinkingLevelSchema), diff --git a/packages/coding-agent/src/core/source-info.ts b/packages/coding-agent/src/core/source-info.ts index 765395d64..e73a0471d 100644 --- a/packages/coding-agent/src/core/source-info.ts +++ b/packages/coding-agent/src/core/source-info.ts @@ -9,6 +9,19 @@ export interface SourceInfo { scope: SourceScope; origin: SourceOrigin; baseDir?: string; + changelogPath?: string; +} + +export interface SourcePackageIdentity { + source: string; + scope: SourceScope; +} + +export function getSourcePackageKey(identity: SourcePackageIdentity): string { + return JSON.stringify({ + source: identity.source, + scope: identity.scope, + }); } /** Prefix of built-in tool and extension paths, such as `builtin:read` or `builtin:mcp`. */ @@ -35,6 +48,7 @@ export function createSourceInfo(path: string, metadata: PathMetadata): SourceIn scope: metadata.scope, origin: metadata.origin, baseDir: metadata.baseDir, + ...(metadata.changelogPath ? { changelogPath: metadata.changelogPath } : {}), }; } @@ -45,6 +59,7 @@ export function createSyntheticSourceInfo( scope?: SourceScope; origin?: SourceOrigin; baseDir?: string; + changelogPath?: string; }, ): SourceInfo { return { @@ -53,5 +68,6 @@ export function createSyntheticSourceInfo( scope: options.scope ?? "temporary", origin: options.origin ?? "top-level", baseDir: options.baseDir, + ...(options.changelogPath ? { changelogPath: options.changelogPath } : {}), }; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 57bd91f25..9707bf5dc 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -115,7 +115,7 @@ import { } from "../../core/session-manager.ts"; import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.ts"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; -import type { SourceInfo } from "../../core/source-info.ts"; +import { getSourcePackageKey, type SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import { withBuiltInRenderers } from "../../core/tools/renderers/index.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; @@ -131,6 +131,7 @@ import { getCwdRelativePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts"; +import { stripBom } from "../../utils/text.ts"; import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts"; import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts"; import { reportBug } from "./bug-report.ts"; @@ -450,6 +451,12 @@ export interface InteractiveModeOptions { terminal?: Terminal; } +interface ExtensionChangelogUpdate { + name: string; + version: string; + markdown: string; +} + export class InteractiveMode { private runtimeHost: AgentSessionRuntime; private renderer: TuiMainScreen | TuiAltScreen; @@ -493,6 +500,7 @@ export class InteractiveMode { private lastSigintTime = 0; private lastEscapeTime = 0; private changelogMarkdown: string | undefined = undefined; + private extensionChangelogUpdates: ExtensionChangelogUpdate[] = []; private startupNoticesShown = false; private anthropicSubscriptionWarningShown = false; @@ -848,7 +856,7 @@ export class InteractiveMode { } this.startupNoticesShown = true; - if (!this.changelogMarkdown) { + if (!this.changelogMarkdown && this.extensionChangelogUpdates.length === 0) { return; } @@ -856,18 +864,39 @@ export class InteractiveMode { this.chatContainer.addChild(new Spacer(1)); } this.chatContainer.addChild(new DynamicBorder()); - if (this.settingsManager.getCollapseChangelog()) { - const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); - const latestVersion = versionMatch ? versionMatch[1] : this.version; - const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; - this.chatContainer.addChild(new Text(condensedText, 1, 0)); - } else { - this.chatContainer.addChild(new ThemedText(() => theme.bold(theme.fg("accent", "What's New")), 1, 0)); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild( - new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()), - ); - this.chatContainer.addChild(new Spacer(1)); + if (this.changelogMarkdown) { + if (this.settingsManager.getCollapseChangelog()) { + const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); + const latestVersion = versionMatch ? versionMatch[1] : this.version; + const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; + this.chatContainer.addChild(new Text(condensedText, 1, 0)); + } else { + this.chatContainer.addChild(new ThemedText(() => theme.bold(theme.fg("accent", "What's New")), 1, 0)); + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()), + ); + this.chatContainer.addChild(new Spacer(1)); + } + } + if (this.extensionChangelogUpdates.length > 0) { + if (this.changelogMarkdown) { + this.chatContainer.addChild(new Spacer(1)); + } + if (this.settingsManager.getCollapseChangelog()) { + const condensedText = this.extensionChangelogUpdates + .map((update) => `Updated ${update.name} to v${update.version}.`) + .join("\n"); + this.chatContainer.addChild(new Text(condensedText, 1, 0)); + } else { + this.chatContainer.addChild( + new ThemedText(() => theme.bold(theme.fg("accent", "Extension Updates")), 1, 0), + ); + this.chatContainer.addChild(new Spacer(1)); + const markdown = this.extensionChangelogUpdates.map((update) => update.markdown).join("\n\n"); + this.chatContainer.addChild(new Markdown(markdown, 1, 0, this.getMarkdownThemeWithSettings())); + this.chatContainer.addChild(new Spacer(1)); + } } this.chatContainer.addChild(new DynamicBorder()); } @@ -947,8 +976,9 @@ export class InteractiveMode { this.registerSignalHandlers(); - // Load changelog (only show new entries, skip for resumed sessions) + // Load changelogs (only show new entries, skip for resumed sessions) this.changelogMarkdown = this.getChangelogForDisplay(); + this.extensionChangelogUpdates = this.getExtensionChangelogUpdates(); // Fullscreen renders on the alternate screen, so text printed to stdout here would stay in the shell // after exit. Fullscreen shows the notice in the header instead (#11508). @@ -1351,6 +1381,68 @@ export class InteractiveMode { return undefined; } + private getExtensionChangelogUpdates(): ExtensionChangelogUpdate[] { + // Skip changelog for resumed/continued sessions (already have messages) + if (this.session.state.messages.length > 0) { + return []; + } + + const seenKeys = new Set(); + const updates: ExtensionChangelogUpdate[] = []; + for (const { sourceInfo } of this.session.resourceLoader.getExtensions().extensions) { + if (sourceInfo.origin !== "package" || !sourceInfo.baseDir || !sourceInfo.changelogPath) { + continue; + } + + const key = getSourcePackageKey(sourceInfo); + if (seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + + const pkg = this.readPackageJson(sourceInfo.baseDir); + if (!pkg?.version) { + continue; + } + + const lastVersion = this.settingsManager.getExtensionChangelogVersion(key); + if (!lastVersion) { + // Fresh install - record the version, don't show changelog + this.settingsManager.setExtensionChangelogVersion(key, pkg.version); + continue; + } + + const newEntries = getNewEntries(parseChangelog(sourceInfo.changelogPath), lastVersion); + if (newEntries.length > 0) { + this.settingsManager.setExtensionChangelogVersion(key, pkg.version); + const name = pkg.name || sourceInfo.source; + // Prefix each "## [x.y.z]" heading with the package name so entries render like Pi's own. + const markdown = newEntries + .map((entry) => entry.content.replace(/^##\s+/, `## ${name} `)) + .join("\n\n") + .trim(); + updates.push({ name, version: pkg.version, markdown }); + } + } + + return updates; + } + + private readPackageJson(packageRoot: string): { name?: string; version?: string } | undefined { + try { + const pkg = JSON.parse(stripBom(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"))) as { + name?: unknown; + version?: unknown; + }; + return { + name: typeof pkg.name === "string" ? pkg.name : undefined, + version: typeof pkg.version === "string" ? pkg.version : undefined, + }; + } catch { + return undefined; + } + } + private reportInstallTelemetry(version: string): void { if (process.env.PI_OFFLINE) { return; diff --git a/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts b/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts new file mode 100644 index 000000000..f588ba474 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts @@ -0,0 +1,139 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts"; +import { getSourcePackageKey, type SourceInfo } from "../src/core/source-info.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +type ExtensionChangelogUpdate = { name: string; version: string; markdown: string }; + +type ExtensionChangelogContext = { + session: { + state: { messages: unknown[] }; + resourceLoader: { getExtensions(): { extensions: Array<{ sourceInfo: SourceInfo }> } }; + }; + settingsManager: SettingsManager; + readPackageJson(packageRoot: string): { name?: string; version?: string } | undefined; +}; + +const prototype = InteractiveMode.prototype as unknown as { + getExtensionChangelogUpdates(this: ExtensionChangelogContext): ExtensionChangelogUpdate[]; + readPackageJson(packageRoot: string): { name?: string; version?: string } | undefined; +}; + +const CHANGELOG = `# Changelog + +## [Unreleased] + +- Not released yet + +## [1.2.0] + +- Added feature B + +## [1.1.0] + +- Added feature A + +## [1.0.0] + +- Initial release +`; + +describe("InteractiveMode extension changelog", () => { + let tempDir: string; + let packageRoot: string; + let sourceInfo: SourceInfo; + let settingsManager: SettingsManager; + let messages: unknown[]; + + function writePackage(version: string): void { + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name: "pi-demo-extension", version })); + } + + function createContext(): ExtensionChangelogContext { + return { + session: { + state: { messages }, + resourceLoader: { + getExtensions: () => ({ + // Two extensions from the same package must only produce one changelog section. + extensions: [{ sourceInfo }, { sourceInfo: { ...sourceInfo, path: join(packageRoot, "b.ts") } }], + }), + }, + }, + settingsManager, + readPackageJson: prototype.readPackageJson, + }; + } + + function display(): ExtensionChangelogUpdate[] { + return prototype.getExtensionChangelogUpdates.call(createContext()); + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-extension-changelog-")); + packageRoot = join(tempDir, "pkg"); + mkdirSync(packageRoot); + writeFileSync(join(packageRoot, "CHANGELOG.md"), CHANGELOG); + sourceInfo = { + path: join(packageRoot, "a.ts"), + source: "npm:pi-demo-extension", + scope: "user", + origin: "package", + baseDir: packageRoot, + changelogPath: join(packageRoot, "CHANGELOG.md"), + }; + settingsManager = SettingsManager.fromStorage(new InMemorySettingsStorage()); + messages = []; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("records the version on first launch without showing the changelog", () => { + writePackage("1.0.0"); + + expect(display()).toEqual([]); + expect(settingsManager.getExtensionChangelogVersion(getSourcePackageKey(sourceInfo))).toBe("1.0.0"); + }); + + test("shows entries newer than the last seen version, headed by package name", () => { + writePackage("1.0.0"); + display(); + writePackage("1.2.0"); + + expect(display()).toEqual([ + { + name: "pi-demo-extension", + version: "1.2.0", + markdown: + "## pi-demo-extension [1.2.0]\n\n- Added feature B\n\n## pi-demo-extension [1.1.0]\n\n- Added feature A", + }, + ]); + expect(settingsManager.getExtensionChangelogVersion(getSourcePackageKey(sourceInfo))).toBe("1.2.0"); + expect(display()).toEqual([]); + }); + + test("skips resumed sessions", () => { + writePackage("1.0.0"); + display(); + writePackage("1.2.0"); + messages.push({ role: "user", content: "hi" }); + + expect(display()).toEqual([]); + expect(settingsManager.getExtensionChangelogVersion(getSourcePackageKey(sourceInfo))).toBe("1.0.0"); + }); + + test("ignores non-package extensions and packages without a changelog", () => { + writePackage("1.0.0"); + sourceInfo = { ...sourceInfo, origin: "top-level" }; + expect(display()).toEqual([]); + + sourceInfo = { ...sourceInfo, origin: "package", changelogPath: undefined }; + expect(display()).toEqual([]); + expect(settingsManager.getExtensionChangelogVersion(getSourcePackageKey(sourceInfo))).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 38d93b232..6aaeb7773 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -671,6 +671,92 @@ Content`, expect(result.skills.some((r) => r.path === slashSkillPath && r.enabled)).toBe(true); }); + it("should expose manifest changelogPath on package resources", async () => { + const pkgDir = join(tempDir, "changelog-manifest-package"); + const extensionPath = join(pkgDir, "src", "index.ts"); + const changelogPath = join(pkgDir, "docs", "CHANGELOG.md"); + mkdirSync(join(pkgDir, "src"), { recursive: true }); + mkdirSync(join(pkgDir, "docs"), { recursive: true }); + writeFileSync(extensionPath, "export default function() {}"); + writeFileSync(changelogPath, "## [1.0.0]\n\n- Initial release"); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "changelog-manifest-package", + pi: { + extensions: ["src/index.ts"], + changelogPath: "docs/CHANGELOG.md", + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + const extension = result.extensions.find((r) => r.path === extensionPath); + + expect(extension?.metadata.changelogPath).toBe(changelogPath); + }); + + it("should expose package CHANGELOG.md by convention", async () => { + const pkgDir = join(tempDir, "changelog-convention-package"); + const extensionPath = join(pkgDir, "extensions", "index.ts"); + const changelogPath = join(pkgDir, "CHANGELOG.md"); + mkdirSync(join(pkgDir, "extensions"), { recursive: true }); + writeFileSync(extensionPath, "export default function() {}"); + writeFileSync(changelogPath, "## [1.0.0]\n\n- Initial release"); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + const extension = result.extensions.find((r) => r.path === extensionPath); + + expect(extension?.metadata.changelogPath).toBe(changelogPath); + }); + + it("should still auto-discover resources when pi manifest only declares changelogPath", async () => { + const pkgDir = join(tempDir, "changelog-only-manifest-package"); + const extensionPath = join(pkgDir, "extensions", "index.ts"); + const changelogPath = join(pkgDir, "CHANGELOG.md"); + mkdirSync(join(pkgDir, "extensions"), { recursive: true }); + writeFileSync(extensionPath, "export default function() {}"); + writeFileSync(changelogPath, "## [1.0.0]\n\n- Initial release"); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "changelog-only-manifest-package", + pi: { + changelogPath: "CHANGELOG.md", + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + const extension = result.extensions.find((r) => r.path === extensionPath); + + expect(extension?.metadata.changelogPath).toBe(changelogPath); + }); + + it("should ignore manifest changelogPath outside the package root", async () => { + const pkgDir = join(tempDir, "unsafe-changelog-package"); + const extensionPath = join(pkgDir, "src", "index.ts"); + mkdirSync(join(pkgDir, "src"), { recursive: true }); + writeFileSync(extensionPath, "export default function() {}"); + writeFileSync(join(tempDir, "CHANGELOG.md"), "## [1.0.0]\n\n- Outside"); + writeFileSync(join(pkgDir, "CHANGELOG.md"), "## [1.0.0]\n\n- Fallback should not be used"); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "unsafe-changelog-package", + pi: { + extensions: ["src/index.ts"], + changelogPath: "../CHANGELOG.md", + }, + }), + ); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + const extension = result.extensions.find((r) => r.path === extensionPath); + + expect(extension?.metadata.changelogPath).toBeUndefined(); + }); + it("should handle directories with auto-discovery layout", async () => { const pkgDir = join(tempDir, "auto-pkg"); mkdirSync(join(pkgDir, "extensions"), { recursive: true });