diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 36af238b5..839fe6ae4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `pi update` now lists each updated package with its npm version or git commit change, for example `npm:foo 1.2.0 -> 1.3.0`, and prints `Packages are already up to date` when nothing changed. `PackageManager.update()` reports each changed package as a new `result` progress event whose `message` holds that line ([#11707](https://github.com/earendil-works/pi/pull/11707)) + ## [1.5.0] - 2026-11-02 ### New Features diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 5f4229b24..b83381a9e 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -108,7 +108,13 @@ export interface PackageInstallFailure { export type MissingSourceAction = "install" | "skip" | "error"; export interface ProgressEvent { - type: "start" | "progress" | "complete" | "error"; + /** + * `start` is followed by exactly one `complete` or `error` for the same source. + * `progress` carries an intermediate message or warning. + * `result` reports an outcome outside that pairing, e.g. one changed package + * after update(): `npm:foo 1.2.0 -> 1.3.0`. + */ + type: "start" | "progress" | "complete" | "error" | "result"; action: "install" | "remove" | "update" | "clone" | "pull"; source: string; message?: string; @@ -193,6 +199,15 @@ interface GitUpdateTarget extends ConfiguredUpdateSource { parsed: GitSource; } +/** A configured package whose installed npm version or git commit changed during update(). */ +interface UpdatedPackage extends ConfiguredUpdateSource { + type: "npm" | "git"; + /** npm version or git commit before the update; undefined if it was not installed. */ + from?: string; + /** npm version or git commit after the update. */ + to?: string; +} + interface ResourceAccumulator { extensions: Map; skills: Map; @@ -1204,10 +1219,8 @@ export class DefaultPackageManager implements PackageManager { const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY); const userNpmUpdates: NpmUpdateTarget[] = []; const projectNpmUpdates: NpmUpdateTarget[] = []; - for (const result of npmCheckResults) { - if (!result.shouldUpdate) { - continue; - } + const npmUpdates = npmCheckResults.filter((result) => result.shouldUpdate); + for (const result of npmUpdates) { if (result.entry.scope === "user") { userNpmUpdates.push(result.entry); } else { @@ -1222,17 +1235,64 @@ export class DefaultPackageManager implements PackageManager { if (projectNpmUpdates.length > 0) { tasks.push(this.updateNpmBatch(projectNpmUpdates, "project")); } + const gitUpdates: UpdatedPackage[] = []; if (gitCandidates.length > 0) { const gitTasks = gitCandidates.map( (entry) => async () => this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => { + const targetDir = this.getGitInstallPath(entry.parsed, entry.scope); + const from = await this.getGitHead(targetDir); await this.updateGit(entry.parsed, entry.scope); + const to = await this.getGitHead(targetDir); + if (from !== to) { + gitUpdates.push({ source: entry.source, type: "git", scope: entry.scope, from, to }); + } }), ); tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => {})); } await Promise.all(tasks); + + const updated: UpdatedPackage[] = []; + for (const { entry } of npmUpdates) { + const installedPath = this.getManagedNpmInstallPath(entry.parsed, entry.scope); + const to = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined; + if (to !== entry.installedVersion) { + updated.push({ source: entry.source, type: "npm", scope: entry.scope, from: entry.installedVersion, to }); + } + } + // Keep git results in configured order, independent of completion order. + for (const entry of gitCandidates) { + const update = gitUpdates.find((u) => u.source === entry.source && u.scope === entry.scope); + if (update) updated.push(update); + } + + // Report each changed package, e.g. "npm:foo 1.2.0 -> 1.3.0". + for (const pkg of updated) { + const format = (value: string | undefined) => + value === undefined ? "(none)" : pkg.type === "git" ? value.slice(0, 7) : value; + const scope = pkg.scope === "project" ? " (project)" : ""; + this.emitProgress({ + type: "result", + action: "update", + source: pkg.source, + message: `${pkg.source}${scope} ${format(pkg.from)} -> ${format(pkg.to)}`, + }); + } + } + + private async getGitHead(targetDir: string): Promise { + if (!existsSync(targetDir)) return undefined; + try { + const head = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { + cwd: targetDir, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + return head.trim() || undefined; + } catch { + return undefined; + } } private async shouldUpdateNpmSource( diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index eecf962ed..911d0c84f 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1027,8 +1027,12 @@ export async function handlePackageCommand( const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + // Update result events describe a changed package, e.g. "npm:foo 1.2.0 -> 1.3.0". + const updatedPackages: string[] = []; packageManager.setProgressCallback((event) => { - if (event.type === "start") { + if (event.type === "result" && event.action === "update" && event.message) { + updatedPackages.push(event.message); + } else if (event.type === "start") { process.stdout.write(chalk.dim(`${event.message}\n`)); } else if (event.type === "progress" && event.message) { process.stdout.write(chalk.yellow(`${event.message}\n`)); @@ -1099,10 +1103,18 @@ export async function handlePackageCommand( if (updateTargetIncludesExtensions(target)) { const updateSource = target.type === "extensions" ? target.source : undefined; await packageManager.update(updateSource); - if (updateSource) { - console.log(chalk.green(`Updated ${updateSource}`)); + if (updatedPackages.length === 0) { + console.log( + chalk.green( + updateSource ? `${updateSource} is already up to date` : "Packages are already up to date", + ), + ); } else { - console.log(chalk.green("Updated packages")); + const count = updatedPackages.length; + console.log(chalk.green(`Updated ${count} package${count === 1 ? "" : "s"}:`)); + for (const message of updatedPackages) { + console.log(` ${message}`); + } } } if (updateTargetIncludesSelf(target)) { diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 8b771f688..849383aa2 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -150,8 +150,13 @@ describe("DefaultPackageManager git update", () => { } }; + const updated: string[] = []; + packageManager.setProgressCallback((event) => { + if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message); + }); await packageManager.update(); + expect(updated).toEqual([]); expect(executedCommands).toContain( "git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main", ); @@ -165,14 +170,20 @@ describe("DefaultPackageManager git update", () => { it("should update to latest commit when remote has new commits", async () => { setupRemoteAndInstall(); expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); + const oldCommit = getCurrentCommit(installedDir); // Add a new commit to remote const newCommit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); // Update via package manager (no args = uses settings) + const updated: string[] = []; + packageManager.setProgressCallback((event) => { + if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message); + }); await packageManager.update(); // Verify update succeeded + expect(updated).toEqual([`${gitSource} ${oldCommit.slice(0, 7)} -> ${newCommit.slice(0, 7)}`]); expect(getCurrentCommit(installedDir)).toBe(newCommit); expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); }); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index b5895b331..7d01a4ece 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -2612,10 +2612,21 @@ export default function(api) { api.registerTool({ name: "test", description: "te const runCommandCaptureSpy = vi .spyOn(packageManager as any, "runCommandCapture") .mockResolvedValue('["1.0.0","1.2.0"]'); - const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockImplementation(async () => { + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.0" })); + }); + const events: ProgressEvent[] = []; + packageManager.setProgressCallback((event) => events.push(event)); await packageManager.update("npm:example"); + // The result is a separate event, so each start still has exactly one complete. + expect(events.map((event) => [event.type, event.source, event.message])).toEqual([ + ["start", "npm:example@^1.0.0", "Updating npm:example@^1.0.0..."], + ["complete", "npm:example@^1.0.0", undefined], + ["result", "npm:example@^1.0.0", "npm:example@^1.0.0 (project) 1.0.0 -> 1.2.0"], + ]); + expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", ["view", "example@^1.0.0", "version", "--json"], @@ -2647,8 +2658,14 @@ export default function(api) { api.registerTool({ name: "test", description: "te .mockResolvedValue('["1.0.0","1.3.1","1.0.2"]'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + const updated: string[] = []; + packageManager.setProgressCallback((event) => { + if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message); + }); await packageManager.update("npm:example"); + expect(updated).toEqual([]); + expect(runCommandCaptureSpy).toHaveBeenCalledWith( "npm", ["view", "example@^1.0.0", "version", "--json"],