mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): extensions changelog (#8790)
Addresses #5958. Adds `changelogPath` to `PiManifest`. Safe package-relative changelog resolution, with `CHANGELOG.md` fallback, and carries it through `PathMetadata`/`SourceInfo`. Added extension changelog handling like Pi’s own changelog. If the package exposes `pi.changelogPath` or has `CHANGELOG.md`, Pi stores the last seen package version in settings. On each fresh interactive launch, we compare the stored version to the installed package version. Then parses the changelog entries in between, shows an Extension Updates section, then marks the current version as seen. The first time an extension is seen, its version is recorded without showing a changelog, same as Pi's own on a fresh install. `collapseChangelog` condenses this to one line per package. Any update that bumps the extension's version shows this on the next launch, e.g.: ```bash pi update --extensions pi update --all pi update <extension-source> pi update --extension <extension-source> pi update --self --extensions pi update pi --extensions pi update self --extensions ``` <img width="798" height="626" alt="Screenshot 2026-08-28 at 16 34 15" src="https://github.com/user-attachments/assets/c882f01c-94b9-4857-b9b7-243d88c0d437" />
This commit is contained in:
parent
9628c84809
commit
b4b72a3510
12 changed files with 449 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@
|
|||
"lastChangelogVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"extensionChangelogVersions": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^.*$": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Last seen changelog version per extension package."
|
||||
},
|
||||
"defaultProvider": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string> } {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue