pi-extmgr 0.1.13 → 0.1.15

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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Then reload Pi.
28
28
  - Quick actions (`A`, `u`, `X`) and bulk update (`U`)
29
29
  - **Remote discovery and install**
30
30
  - npm search/browse with pagination
31
- - Install by source (`npm:`, `git:`, URL, local path)
31
+ - Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
32
32
  - Supports direct GitHub `.ts` installs and local standalone install mode
33
33
  - **Auto-update**
34
34
  - Interactive wizard (`t` in manager, or `/extensions auto-update`)
@@ -123,6 +123,8 @@ Examples:
123
123
  /extensions install npm:package-name
124
124
  /extensions install @scope/package
125
125
  /extensions install git:https://github.com/user/repo.git
126
+ /extensions install git:git@github.com:user/repo.git
127
+ /extensions install ssh://git@github.com/user/repo.git
126
128
  /extensions install https://github.com/user/repo/blob/main/extension.ts
127
129
  /extensions install /path/to/extension.ts
128
130
  /extensions install ./local-folder/
@@ -135,6 +137,8 @@ Examples:
135
137
  - **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache
136
138
  - **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, supports multi-file extensions
137
139
  - **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions.
140
+ - **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
141
+ - **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles.
138
142
  - **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
139
143
  - **Remove requires restart**: After removing a package, you need to fully restart Pi (not just a reload) for it to be completely unloaded.
140
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -67,11 +67,11 @@ export default function extensionsManager(pi: ExtensionAPI) {
67
67
  }
68
68
 
69
69
  async function bootstrapSession(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
70
- if (!ctx.hasUI) return;
71
-
72
70
  // Restore persisted auto-update config into session entries so sync lookups are valid.
73
71
  await hydrateAutoUpdateConfig(pi, ctx);
74
72
 
73
+ if (!ctx.hasUI) return;
74
+
75
75
  const getCtx: ContextProvider = () => ctx;
76
76
  startAutoUpdateTimer(pi, getCtx, createAutoUpdateNotificationHandler(ctx));
77
77
 
@@ -144,18 +144,7 @@ function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boole
144
144
  }
145
145
 
146
146
  function looksLikePackageSource(source: string): boolean {
147
- return (
148
- source.startsWith("npm:") ||
149
- source.startsWith("git:") ||
150
- source.startsWith("http://") ||
151
- source.startsWith("https://") ||
152
- source.startsWith("/") ||
153
- source.startsWith("./") ||
154
- source.startsWith("../") ||
155
- source.startsWith("~/") ||
156
- /^[a-zA-Z]:[\\/]/.test(source) ||
157
- source.startsWith("\\\\")
158
- );
147
+ return getPackageSourceKind(source) !== "unknown";
159
148
  }
160
149
 
161
150
  function parseResolvedPathLine(line: string): string | undefined {
@@ -168,6 +157,10 @@ function parseResolvedPathLine(line: string): string | undefined {
168
157
  line.startsWith("/") ||
169
158
  line.startsWith("./") ||
170
159
  line.startsWith("../") ||
160
+ line.startsWith(".\\") ||
161
+ line.startsWith("..\\") ||
162
+ line.startsWith("~/") ||
163
+ line.startsWith("file://") ||
171
164
  /^[a-zA-Z]:[\\/]/.test(line) ||
172
165
  line.startsWith("\\\\")
173
166
  ) {
@@ -1,5 +1,7 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { homedir } from "node:os";
3
5
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
6
  import type { InstalledPackage, PackageExtensionEntry, Scope, State } from "../types/index.js";
5
7
  import { fileExists, readSummary } from "../utils/fs.js";
@@ -30,6 +32,14 @@ function toPackageRoot(pkg: InstalledPackage, cwd: string): string | undefined {
30
32
  return resolve(pkg.resolvedPath);
31
33
  }
32
34
 
35
+ if (pkg.source.startsWith("file://")) {
36
+ try {
37
+ return resolve(fileURLToPath(pkg.source));
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
33
43
  if (
34
44
  pkg.source.startsWith("/") ||
35
45
  /^[a-zA-Z]:[\\/]/.test(pkg.source) ||
@@ -38,10 +48,19 @@ function toPackageRoot(pkg: InstalledPackage, cwd: string): string | undefined {
38
48
  return resolve(pkg.source);
39
49
  }
40
50
 
41
- if (pkg.source.startsWith("./") || pkg.source.startsWith("../")) {
51
+ if (
52
+ pkg.source.startsWith("./") ||
53
+ pkg.source.startsWith("../") ||
54
+ pkg.source.startsWith(".\\") ||
55
+ pkg.source.startsWith("..\\")
56
+ ) {
42
57
  return resolve(cwd, pkg.source);
43
58
  }
44
59
 
60
+ if (pkg.source.startsWith("~/")) {
61
+ return resolve(join(homedir(), pkg.source.slice(2)));
62
+ }
63
+
45
64
  return undefined;
46
65
  }
47
66
 
@@ -52,16 +71,66 @@ function getSettingsPath(scope: Scope, cwd: string): string {
52
71
  return join(getAgentDir(), "settings.json");
53
72
  }
54
73
 
55
- async function readSettingsFile(path: string): Promise<SettingsFile> {
74
+ async function readSettingsFile(
75
+ path: string,
76
+ options?: { strict?: boolean }
77
+ ): Promise<SettingsFile> {
56
78
  try {
57
79
  const raw = await readFile(path, "utf8");
58
- const parsed = JSON.parse(raw) as SettingsFile;
59
- return parsed && typeof parsed === "object" ? parsed : {};
60
- } catch {
80
+ if (!raw.trim()) {
81
+ return {};
82
+ }
83
+
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(raw) as unknown;
87
+ } catch (error) {
88
+ if (options?.strict) {
89
+ throw new Error(
90
+ `Invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`
91
+ );
92
+ }
93
+ return {};
94
+ }
95
+
96
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
97
+ if (options?.strict) {
98
+ throw new Error(`Invalid settings format in ${path}: expected a JSON object`);
99
+ }
100
+ return {};
101
+ }
102
+
103
+ return parsed as SettingsFile;
104
+ } catch (error) {
105
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
106
+ return {};
107
+ }
108
+
109
+ if (options?.strict) {
110
+ throw error;
111
+ }
112
+
61
113
  return {};
62
114
  }
63
115
  }
64
116
 
117
+ async function writeSettingsFile(path: string, settings: SettingsFile): Promise<void> {
118
+ const settingsDir = dirname(path);
119
+ await mkdir(settingsDir, { recursive: true });
120
+
121
+ const content = `${JSON.stringify(settings, null, 2)}\n`;
122
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
123
+
124
+ try {
125
+ await writeFile(tmpPath, content, "utf8");
126
+ await rename(tmpPath, path);
127
+ } catch {
128
+ await writeFile(path, content, "utf8");
129
+ } finally {
130
+ await rm(tmpPath, { force: true }).catch(() => undefined);
131
+ }
132
+ }
133
+
65
134
  function getPackageFilterState(filters: string[] | undefined, extensionPath: string): State {
66
135
  if (!filters || filters.length === 0) {
67
136
  return "enabled";
@@ -187,8 +256,7 @@ export async function setPackageExtensionState(
187
256
  ): Promise<{ ok: true } | { ok: false; error: string }> {
188
257
  try {
189
258
  const settingsPath = getSettingsPath(scope, cwd);
190
- const settingsDir = dirname(settingsPath);
191
- const settings = await readSettingsFile(settingsPath);
259
+ const settings = await readSettingsFile(settingsPath, { strict: true });
192
260
 
193
261
  const normalizedSource = normalizeSource(packageSource);
194
262
  const normalizedPath = normalizeRelativePath(extensionPath);
@@ -231,8 +299,7 @@ export async function setPackageExtensionState(
231
299
 
232
300
  settings.packages = packages;
233
301
 
234
- await mkdir(settingsDir, { recursive: true });
235
- await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
302
+ await writeSettingsFile(settingsPath, settings);
236
303
 
237
304
  return { ok: true };
238
305
  } catch (error) {
@@ -8,6 +8,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-cod
8
8
  import { normalizePackageSource } from "../utils/format.js";
9
9
  import { clearSearchCache } from "./discovery.js";
10
10
  import { logPackageInstall } from "../utils/history.js";
11
+ import { clearUpdatesAvailable } from "../utils/settings.js";
11
12
  import { notify, error as notifyError, success } from "../utils/notify.js";
12
13
  import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
13
14
  import { tryOperation } from "../utils/mode.js";
@@ -126,6 +127,7 @@ export async function installPackage(
126
127
  clearSearchCache();
127
128
  logPackageInstall(pi, normalized, normalized, undefined, scope, true);
128
129
  success(ctx, `Installed ${normalized} (${scope})`);
130
+ clearUpdatesAvailable(pi, ctx);
129
131
 
130
132
  void updateExtmgrStatus(ctx, pi);
131
133
  await confirmReload(ctx, "Package installed.");
@@ -186,6 +188,7 @@ export async function installFromUrl(
186
188
  const { fileName: name, destPath } = result;
187
189
  logPackageInstall(pi, url, name, undefined, scope, true);
188
190
  success(ctx, `Installed ${name} to:\n${destPath}`);
191
+ clearUpdatesAvailable(pi, ctx);
189
192
  void updateExtmgrStatus(ctx, pi);
190
193
  await confirmReload(ctx, "Extension installed.");
191
194
  }
@@ -401,6 +404,7 @@ export async function installPackageLocally(
401
404
  clearSearchCache();
402
405
  logPackageInstall(pi, `npm:${packageName}`, packageName, version, scope, true);
403
406
  success(ctx, `Installed ${packageName}@${version} locally to:\n${destResult}/index.ts`);
407
+ clearUpdatesAvailable(pi, ctx);
404
408
  void updateExtmgrStatus(ctx, pi);
405
409
  await confirmReload(ctx, "Extension installed.");
406
410
  }
@@ -9,8 +9,9 @@ import {
9
9
  parseInstalledPackagesOutputAllScopes,
10
10
  } from "./discovery.js";
11
11
  import { formatInstalledPackageLabel, formatBytes, parseNpmSource } from "../utils/format.js";
12
- import { splitGitRepoAndRef } from "../utils/package-source.js";
12
+ import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
13
13
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
14
+ import { clearUpdatesAvailable } from "../utils/settings.js";
14
15
  import { notify, error as notifyError, success } from "../utils/notify.js";
15
16
  import {
16
17
  confirmAction,
@@ -53,7 +54,7 @@ async function updatePackageInternal(
53
54
 
54
55
  if (res.code !== 0) {
55
56
  const errorMsg = `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`;
56
- logPackageUpdate(pi, source, source, undefined, undefined, false, errorMsg);
57
+ logPackageUpdate(pi, source, source, undefined, false, errorMsg);
57
58
  notifyError(ctx, errorMsg);
58
59
  void updateExtmgrStatus(ctx, pi);
59
60
  return NO_PACKAGE_MUTATION_OUTCOME;
@@ -62,13 +63,14 @@ async function updatePackageInternal(
62
63
  const stdout = res.stdout || "";
63
64
  if (stdout.includes("already up to date") || stdout.includes("pinned")) {
64
65
  notify(ctx, `${source} is already up to date (or pinned).`, "info");
65
- logPackageUpdate(pi, source, source, undefined, undefined, true);
66
+ logPackageUpdate(pi, source, source, undefined, true);
66
67
  void updateExtmgrStatus(ctx, pi);
67
68
  return NO_PACKAGE_MUTATION_OUTCOME;
68
69
  }
69
70
 
70
- logPackageUpdate(pi, source, source, undefined, undefined, true);
71
+ logPackageUpdate(pi, source, source, undefined, true);
71
72
  success(ctx, `Updated ${source}`);
73
+ clearUpdatesAvailable(pi, ctx);
72
74
  void updateExtmgrStatus(ctx, pi);
73
75
 
74
76
  const reloaded = await confirmReload(ctx, "Package updated.");
@@ -97,6 +99,7 @@ async function updatePackagesInternal(
97
99
  }
98
100
 
99
101
  success(ctx, "Packages updated");
102
+ clearUpdatesAvailable(pi, ctx);
100
103
  void updateExtmgrStatus(ctx, pi);
101
104
 
102
105
  const reloaded = await confirmReload(ctx, "Packages updated.");
@@ -139,8 +142,9 @@ function packageIdentity(source: string, fallbackName?: string): string {
139
142
  return `npm:${npm.name}`;
140
143
  }
141
144
 
142
- if (source.startsWith("git:")) {
143
- const { repo } = splitGitRepoAndRef(source.slice(4));
145
+ if (getPackageSourceKind(source) === "git") {
146
+ const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
147
+ const { repo } = splitGitRepoAndRef(gitSpec);
144
148
  return `git:${repo}`;
145
149
  }
146
150
 
@@ -327,6 +331,9 @@ async function removePackageInternal(
327
331
  );
328
332
  notifyRemovalSummary(source, remaining, failures, ctx);
329
333
 
334
+ if (failures.length === 0) {
335
+ clearUpdatesAvailable(pi, ctx);
336
+ }
330
337
  void updateExtmgrStatus(ctx, pi);
331
338
 
332
339
  const restartRequested = await confirmRestart(
@@ -50,7 +50,7 @@ export function startAutoUpdateTimer(
50
50
  const interval = getScheduleInterval(config);
51
51
  if (!interval) return;
52
52
 
53
- // Check immediately if it's time
53
+ // Run an initial check immediately.
54
54
  void (async () => {
55
55
  const checkCtx = getCtx();
56
56
  if (!checkCtx) return;
@@ -266,13 +266,7 @@ export function enableAutoUpdate(
266
266
 
267
267
  saveAutoUpdateConfig(pi, config);
268
268
 
269
- // Create a context provider that returns the current context
270
- const getCtx: ContextProvider = () => {
271
- // In a real implementation, this would need to be updated
272
- // when the session changes. For now, we return the current context
273
- // and rely on the interval checking for valid context.
274
- return ctx;
275
- };
269
+ const getCtx: ContextProvider = () => ctx;
276
270
 
277
271
  startAutoUpdateTimer(pi, getCtx, onUpdateAvailable);
278
272
 
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Persistent cache for package metadata to reduce npm API calls
3
3
  */
4
- import { readFile, writeFile, mkdir, access } from "node:fs/promises";
4
+ import { readFile, writeFile, mkdir, access, rename, rm } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import type { NpmPackage, InstalledPackage } from "../types/index.js";
8
8
  import { CACHE_LIMITS } from "../constants.js";
9
9
 
10
- const CACHE_DIR = join(homedir(), ".pi", "agent", ".extmgr-cache");
10
+ const CACHE_DIR = process.env.PI_EXTMGR_CACHE_DIR
11
+ ? process.env.PI_EXTMGR_CACHE_DIR
12
+ : join(homedir(), ".pi", "agent", ".extmgr-cache");
11
13
  const CACHE_FILE = join(CACHE_DIR, "metadata.json");
12
14
 
13
15
  interface CachedPackageData {
@@ -31,6 +33,88 @@ interface CacheData {
31
33
  }
32
34
 
33
35
  let memoryCache: CacheData | null = null;
36
+ let cacheWriteQueue: Promise<void> = Promise.resolve();
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
40
+ }
41
+
42
+ function normalizeCachedPackageEntry(key: string, value: unknown): CachedPackageData | undefined {
43
+ if (!isRecord(value)) return undefined;
44
+
45
+ const timestamp = value.timestamp;
46
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0) {
47
+ return undefined;
48
+ }
49
+
50
+ const name = typeof value.name === "string" && value.name.trim() ? value.name.trim() : key;
51
+ const entry: CachedPackageData = {
52
+ name,
53
+ timestamp,
54
+ };
55
+
56
+ if (typeof value.description === "string") {
57
+ entry.description = value.description;
58
+ }
59
+
60
+ if (typeof value.version === "string") {
61
+ entry.version = value.version;
62
+ }
63
+
64
+ if (typeof value.size === "number" && Number.isFinite(value.size) && value.size >= 0) {
65
+ entry.size = value.size;
66
+ }
67
+
68
+ return entry;
69
+ }
70
+
71
+ function normalizeCacheFromDisk(input: unknown): CacheData {
72
+ if (!isRecord(input)) {
73
+ return { version: 1, packages: new Map() };
74
+ }
75
+
76
+ const version =
77
+ typeof input.version === "number" && Number.isFinite(input.version) ? input.version : 1;
78
+
79
+ const packages = new Map<string, CachedPackageData>();
80
+ const rawPackages = isRecord(input.packages) ? input.packages : {};
81
+
82
+ for (const [name, value] of Object.entries(rawPackages)) {
83
+ const normalized = normalizeCachedPackageEntry(name, value);
84
+ if (normalized) {
85
+ packages.set(name, normalized);
86
+ }
87
+ }
88
+
89
+ let lastSearch: CacheData["lastSearch"];
90
+ if (isRecord(input.lastSearch)) {
91
+ const query = input.lastSearch.query;
92
+ const timestamp = input.lastSearch.timestamp;
93
+ const results = input.lastSearch.results;
94
+
95
+ if (
96
+ typeof query === "string" &&
97
+ typeof timestamp === "number" &&
98
+ Number.isFinite(timestamp) &&
99
+ Array.isArray(results)
100
+ ) {
101
+ const normalizedResults = results.filter(
102
+ (value): value is string => typeof value === "string"
103
+ );
104
+ lastSearch = {
105
+ query,
106
+ timestamp,
107
+ results: normalizedResults,
108
+ };
109
+ }
110
+ }
111
+
112
+ return {
113
+ version,
114
+ packages,
115
+ lastSearch,
116
+ };
117
+ }
34
118
 
35
119
  /**
36
120
  * Ensure cache directory exists
@@ -43,6 +127,18 @@ async function ensureCacheDir(): Promise<void> {
43
127
  }
44
128
  }
45
129
 
130
+ async function backupCorruptCacheFile(): Promise<void> {
131
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
132
+ const backupPath = join(CACHE_DIR, `metadata.invalid-${stamp}.json`);
133
+
134
+ try {
135
+ await rename(CACHE_FILE, backupPath);
136
+ console.warn(`[extmgr] Invalid metadata cache JSON. Backed up to ${backupPath}.`);
137
+ } catch (error) {
138
+ console.warn("[extmgr] Failed to backup invalid cache file:", error);
139
+ }
140
+ }
141
+
46
142
  /**
47
143
  * Load cache from disk
48
144
  */
@@ -52,21 +148,29 @@ async function loadCache(): Promise<CacheData> {
52
148
  try {
53
149
  await ensureCacheDir();
54
150
  const data = await readFile(CACHE_FILE, "utf8");
55
- const parsed = JSON.parse(data) as {
56
- version: number;
57
- packages: Record<string, CachedPackageData>;
58
- lastSearch?: CacheData["lastSearch"];
59
- };
151
+ const trimmed = data.trim();
152
+
153
+ if (!trimmed) {
154
+ memoryCache = {
155
+ version: 1,
156
+ packages: new Map(),
157
+ };
158
+ return memoryCache;
159
+ }
60
160
 
61
- memoryCache = {
62
- version: parsed.version,
63
- packages: new Map(Object.entries(parsed.packages)),
64
- lastSearch: parsed.lastSearch ?? undefined,
65
- };
161
+ try {
162
+ const parsed = JSON.parse(trimmed) as unknown;
163
+ memoryCache = normalizeCacheFromDisk(parsed);
164
+ } catch {
165
+ await backupCorruptCacheFile();
166
+ memoryCache = {
167
+ version: 1,
168
+ packages: new Map(),
169
+ };
170
+ }
66
171
  } catch (error) {
67
- // Cache doesn't exist or is corrupted, start fresh
172
+ // Cache doesn't exist or is unreadable, start fresh
68
173
  if (error instanceof Error && "code" in error && error.code !== "ENOENT") {
69
- // Only log actual errors, not missing file
70
174
  console.warn("[extmgr] Cache load failed, resetting:", error.message);
71
175
  }
72
176
  memoryCache = {
@@ -84,24 +188,43 @@ async function loadCache(): Promise<CacheData> {
84
188
  async function saveCache(): Promise<void> {
85
189
  if (!memoryCache) return;
86
190
 
87
- try {
88
- await ensureCacheDir();
89
- const data: {
90
- version: number;
91
- packages: Record<string, CachedPackageData>;
92
- lastSearch?: { query: string; results: string[]; timestamp: number } | undefined;
93
- } = {
94
- version: memoryCache.version,
95
- packages: Object.fromEntries(memoryCache.packages),
96
- lastSearch: memoryCache.lastSearch,
97
- };
191
+ await ensureCacheDir();
98
192
 
99
- await writeFile(CACHE_FILE, JSON.stringify(data, null, 2), "utf8");
100
- } catch (error) {
101
- console.warn("[extmgr] Cache save failed:", error instanceof Error ? error.message : error);
193
+ const data: {
194
+ version: number;
195
+ packages: Record<string, CachedPackageData>;
196
+ lastSearch?: { query: string; results: string[]; timestamp: number } | undefined;
197
+ } = {
198
+ version: memoryCache.version,
199
+ packages: Object.fromEntries(memoryCache.packages),
200
+ lastSearch: memoryCache.lastSearch,
201
+ };
202
+
203
+ const content = `${JSON.stringify(data, null, 2)}\n`;
204
+ const tmpPath = join(CACHE_DIR, `metadata.${process.pid}.${Date.now()}.tmp`);
205
+
206
+ try {
207
+ await writeFile(tmpPath, content, "utf8");
208
+ await rename(tmpPath, CACHE_FILE);
209
+ } catch {
210
+ // Fallback for filesystems where rename-overwrite can fail.
211
+ await writeFile(CACHE_FILE, content, "utf8");
212
+ } finally {
213
+ await rm(tmpPath, { force: true }).catch(() => undefined);
102
214
  }
103
215
  }
104
216
 
217
+ async function enqueueCacheSave(): Promise<void> {
218
+ cacheWriteQueue = cacheWriteQueue
219
+ .catch(() => undefined)
220
+ .then(() => saveCache())
221
+ .catch((error) => {
222
+ console.warn("[extmgr] Cache save failed:", error instanceof Error ? error.message : error);
223
+ });
224
+
225
+ return cacheWriteQueue;
226
+ }
227
+
105
228
  /**
106
229
  * Check if cached data is still valid (within TTL)
107
230
  */
@@ -135,7 +258,7 @@ export async function setCachedPackage(
135
258
  ...data,
136
259
  timestamp: Date.now(),
137
260
  });
138
- await saveCache();
261
+ await enqueueCacheSave();
139
262
  }
140
263
 
141
264
  /**
@@ -191,7 +314,7 @@ export async function setCachedSearch(query: string, packages: NpmPackage[]): Pr
191
314
  timestamp: Date.now(),
192
315
  };
193
316
 
194
- await saveCache();
317
+ await enqueueCacheSave();
195
318
  }
196
319
 
197
320
  /**
@@ -202,7 +325,7 @@ export async function clearCache(): Promise<void> {
202
325
  version: 1,
203
326
  packages: new Map(),
204
327
  };
205
- await saveCache();
328
+ await enqueueCacheSave();
206
329
  }
207
330
 
208
331
  /**
@@ -288,5 +411,5 @@ export async function setCachedPackageSize(name: string, size: number): Promise<
288
411
  });
289
412
  }
290
413
 
291
- await saveCache();
414
+ await enqueueCacheSave();
292
415
  }
@@ -57,28 +57,51 @@ export function formatBytes(bytes: number): string {
57
57
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
58
58
  }
59
59
 
60
- export function isPackageSource(str: string): boolean {
60
+ function isGitLikeSource(source: string): boolean {
61
61
  return (
62
- str.startsWith("npm:") ||
63
- str.startsWith("git:") ||
64
- str.startsWith("http") ||
65
- str.startsWith("/") ||
66
- str.startsWith("./") ||
67
- str.startsWith("../")
62
+ source.startsWith("git:") ||
63
+ source.startsWith("http://") ||
64
+ source.startsWith("https://") ||
65
+ source.startsWith("ssh://") ||
66
+ source.startsWith("git://") ||
67
+ /^git@[^\s:]+:.+/.test(source)
68
68
  );
69
69
  }
70
70
 
71
- export function normalizePackageSource(source: string): string {
72
- if (
73
- source.startsWith("npm:") ||
74
- source.startsWith("git:") ||
75
- source.startsWith("http") ||
71
+ function isLocalPathSource(source: string): boolean {
72
+ return (
76
73
  source.startsWith("/") ||
77
- source.startsWith(".")
78
- ) {
79
- return source;
74
+ source.startsWith("./") ||
75
+ source.startsWith("../") ||
76
+ source.startsWith(".\\") ||
77
+ source.startsWith("..\\") ||
78
+ source.startsWith("~/") ||
79
+ source.startsWith("file://") ||
80
+ /^[a-zA-Z]:[\\/]/.test(source) ||
81
+ source.startsWith("\\\\")
82
+ );
83
+ }
84
+
85
+ export function isPackageSource(str: string): boolean {
86
+ const source = str.trim();
87
+ if (!source) return false;
88
+
89
+ return source.startsWith("npm:") || isGitLikeSource(source) || isLocalPathSource(source);
90
+ }
91
+
92
+ export function normalizePackageSource(source: string): string {
93
+ const trimmed = source.trim();
94
+ if (!trimmed) return trimmed;
95
+
96
+ if (/^git@[^\s:]+:.+/.test(trimmed)) {
97
+ return `git:${trimmed}`;
98
+ }
99
+
100
+ if (isPackageSource(trimmed)) {
101
+ return trimmed;
80
102
  }
81
- return `npm:${source}`;
103
+
104
+ return `npm:${trimmed}`;
82
105
  }
83
106
 
84
107
  export function parseNpmSource(
@@ -110,7 +110,6 @@ export function logPackageUpdate(
110
110
  pi: ExtensionAPI,
111
111
  source: string,
112
112
  name: string,
113
- _fromVersion: string | undefined,
114
113
  toVersion: string | undefined,
115
114
  success: boolean,
116
115
  error?: string
@@ -30,7 +30,10 @@ export function getPackageSourceKind(source: string): PackageSourceKind {
30
30
  normalized.startsWith("/") ||
31
31
  normalized.startsWith("./") ||
32
32
  normalized.startsWith("../") ||
33
+ normalized.startsWith(".\\") ||
34
+ normalized.startsWith("..\\") ||
33
35
  normalized.startsWith("~/") ||
36
+ normalized.startsWith("file://") ||
34
37
  /^[a-zA-Z]:[\\/]/.test(normalized) ||
35
38
  normalized.startsWith("\\\\")
36
39
  ) {
@@ -7,7 +7,7 @@ import type {
7
7
  ExtensionCommandContext,
8
8
  ExtensionContext,
9
9
  } from "@mariozechner/pi-coding-agent";
10
- import { readFile, writeFile, mkdir, access } from "node:fs/promises";
10
+ import { readFile, writeFile, mkdir, access, rename, rm } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
 
@@ -32,6 +32,68 @@ const SETTINGS_DIR = process.env.PI_EXTMGR_CACHE_DIR
32
32
  : join(homedir(), ".pi", "agent", ".extmgr-cache");
33
33
  const SETTINGS_FILE = join(SETTINGS_DIR, "auto-update.json");
34
34
 
35
+ let settingsWriteQueue: Promise<void> = Promise.resolve();
36
+
37
+ function isRecord(value: unknown): value is Record<string, unknown> {
38
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
39
+ }
40
+
41
+ function sanitizeAutoUpdateConfig(input: unknown): AutoUpdateConfig {
42
+ if (!isRecord(input)) {
43
+ return { ...DEFAULT_CONFIG };
44
+ }
45
+
46
+ const config: AutoUpdateConfig = { ...DEFAULT_CONFIG };
47
+
48
+ const intervalMs = input.intervalMs;
49
+ if (typeof intervalMs === "number" && Number.isFinite(intervalMs) && intervalMs >= 0) {
50
+ config.intervalMs = Math.floor(intervalMs);
51
+ }
52
+
53
+ if (typeof input.enabled === "boolean") {
54
+ config.enabled = input.enabled;
55
+ }
56
+
57
+ if (typeof input.displayText === "string" && input.displayText.trim()) {
58
+ config.displayText = input.displayText.trim();
59
+ }
60
+
61
+ if (
62
+ typeof input.lastCheck === "number" &&
63
+ Number.isFinite(input.lastCheck) &&
64
+ input.lastCheck >= 0
65
+ ) {
66
+ config.lastCheck = input.lastCheck;
67
+ }
68
+
69
+ if (
70
+ typeof input.nextCheck === "number" &&
71
+ Number.isFinite(input.nextCheck) &&
72
+ input.nextCheck >= 0
73
+ ) {
74
+ config.nextCheck = input.nextCheck;
75
+ }
76
+
77
+ if (Array.isArray(input.updatesAvailable)) {
78
+ const updates = input.updatesAvailable
79
+ .filter((value): value is string => typeof value === "string")
80
+ .map((value) => value.trim())
81
+ .filter(Boolean);
82
+
83
+ if (updates.length > 0) {
84
+ config.updatesAvailable = updates;
85
+ }
86
+ }
87
+
88
+ if (!config.enabled || config.intervalMs === 0) {
89
+ config.enabled = false;
90
+ config.intervalMs = 0;
91
+ config.displayText = "off";
92
+ }
93
+
94
+ return config;
95
+ }
96
+
35
97
  function getSessionConfig(
36
98
  ctx: ExtensionCommandContext | ExtensionContext
37
99
  ): AutoUpdateConfig | undefined {
@@ -43,7 +105,7 @@ function getSessionConfig(
43
105
  for (let i = entries.length - 1; i >= 0; i--) {
44
106
  const entry = entries[i];
45
107
  if (entry?.type === "custom" && entry.customType === SETTINGS_KEY && entry.data) {
46
- return { ...DEFAULT_CONFIG, ...(entry.data as AutoUpdateConfig) };
108
+ return sanitizeAutoUpdateConfig(entry.data);
47
109
  }
48
110
  }
49
111
 
@@ -73,6 +135,20 @@ async function ensureSettingsDir(): Promise<void> {
73
135
  }
74
136
  }
75
137
 
138
+ async function backupCorruptSettingsFile(): Promise<void> {
139
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
140
+ const backupPath = join(SETTINGS_DIR, `auto-update.invalid-${stamp}.json`);
141
+
142
+ try {
143
+ await rename(SETTINGS_FILE, backupPath);
144
+ console.warn(
145
+ `[extmgr] Invalid auto-update settings JSON. Backed up to ${backupPath} and reset to defaults.`
146
+ );
147
+ } catch (error) {
148
+ console.warn("[extmgr] Failed to backup invalid auto-update settings file:", error);
149
+ }
150
+ }
151
+
76
152
  /**
77
153
  * Reads config from disk asynchronously
78
154
  */
@@ -81,9 +157,19 @@ async function readConfigFromDisk(): Promise<AutoUpdateConfig | undefined> {
81
157
  if (!(await fileExists(SETTINGS_FILE))) {
82
158
  return undefined;
83
159
  }
160
+
84
161
  const raw = await readFile(SETTINGS_FILE, "utf8");
85
- const parsed = JSON.parse(raw) as Partial<AutoUpdateConfig>;
86
- return { ...DEFAULT_CONFIG, ...parsed };
162
+ if (!raw.trim()) {
163
+ return undefined;
164
+ }
165
+
166
+ try {
167
+ const parsed = JSON.parse(raw) as unknown;
168
+ return sanitizeAutoUpdateConfig(parsed);
169
+ } catch {
170
+ await backupCorruptSettingsFile();
171
+ return undefined;
172
+ }
87
173
  } catch (error) {
88
174
  console.warn("[extmgr] Failed to read settings:", error);
89
175
  return undefined;
@@ -91,17 +177,34 @@ async function readConfigFromDisk(): Promise<AutoUpdateConfig | undefined> {
91
177
  }
92
178
 
93
179
  /**
94
- * Writes config to disk asynchronously
180
+ * Writes config to disk asynchronously (serialized + best-effort atomic)
95
181
  */
96
182
  async function writeConfigToDisk(config: AutoUpdateConfig): Promise<void> {
183
+ await ensureSettingsDir();
184
+
185
+ const content = `${JSON.stringify(config, null, 2)}\n`;
186
+ const tmpPath = join(SETTINGS_DIR, `auto-update.${process.pid}.${Date.now()}.tmp`);
187
+
97
188
  try {
98
- await ensureSettingsDir();
99
- await writeFile(SETTINGS_FILE, JSON.stringify(config, null, 2), "utf8");
100
- } catch (error) {
101
- console.warn("[extmgr] Failed to write settings:", error);
189
+ await writeFile(tmpPath, content, "utf8");
190
+ await rename(tmpPath, SETTINGS_FILE);
191
+ } catch {
192
+ // Fallback for filesystems where rename-overwrite can fail.
193
+ await writeFile(SETTINGS_FILE, content, "utf8");
194
+ } finally {
195
+ await rm(tmpPath, { force: true }).catch(() => undefined);
102
196
  }
103
197
  }
104
198
 
199
+ function enqueueConfigWrite(config: AutoUpdateConfig): void {
200
+ settingsWriteQueue = settingsWriteQueue
201
+ .catch(() => undefined)
202
+ .then(() => writeConfigToDisk(config))
203
+ .catch((error) => {
204
+ console.warn("[extmgr] Failed to write settings:", error);
205
+ });
206
+ }
207
+
105
208
  /**
106
209
  * Hydrate session state from persisted disk settings.
107
210
  * This ensures sync reads can still work after startup/session switch.
@@ -153,17 +256,30 @@ export function getAutoUpdateConfig(
153
256
  * Save auto-update config to session + disk.
154
257
  */
155
258
  export function saveAutoUpdateConfig(pi: ExtensionAPI, config: Partial<AutoUpdateConfig>): void {
156
- const fullConfig: AutoUpdateConfig = {
259
+ const fullConfig = sanitizeAutoUpdateConfig({
157
260
  ...DEFAULT_CONFIG,
158
261
  ...config,
159
- };
262
+ });
160
263
 
161
264
  pi.appendEntry(SETTINGS_KEY, fullConfig);
265
+ enqueueConfigWrite(fullConfig);
266
+ }
162
267
 
163
- // Write to disk asynchronously (fire and forget)
164
- writeConfigToDisk(fullConfig).catch((err) => {
165
- console.warn("[extmgr] Failed to save settings:", err);
166
- });
268
+ /**
269
+ * Clear the updates available list after package mutations.
270
+ * Call this after install/update/remove to prevent stale update notifications.
271
+ */
272
+ export function clearUpdatesAvailable(
273
+ pi: ExtensionAPI,
274
+ ctx: ExtensionCommandContext | ExtensionContext
275
+ ): void {
276
+ const config = getAutoUpdateConfig(ctx);
277
+ if (config.updatesAvailable && config.updatesAvailable.length > 0) {
278
+ saveAutoUpdateConfig(pi, {
279
+ ...config,
280
+ updatesAvailable: [],
281
+ });
282
+ }
167
283
  }
168
284
 
169
285
  /**
@@ -8,7 +8,15 @@ import type {
8
8
  } from "@mariozechner/pi-coding-agent";
9
9
  import { getInstalledPackages } from "../packages/discovery.js";
10
10
  import { getAutoUpdateStatus } from "./auto-update.js";
11
- import { getAutoUpdateConfigAsync } from "./settings.js";
11
+ import { getAutoUpdateConfigAsync, saveAutoUpdateConfig } from "./settings.js";
12
+
13
+ function filterStaleUpdates(
14
+ knownUpdates: string[],
15
+ installedPackages: Awaited<ReturnType<typeof getInstalledPackages>>
16
+ ): string[] {
17
+ const installedNames = new Set(installedPackages.map((p) => p.name));
18
+ return knownUpdates.filter((name) => installedNames.has(name));
19
+ }
12
20
 
13
21
  export async function updateExtmgrStatus(
14
22
  ctx: ExtensionCommandContext | ExtensionContext,
@@ -32,9 +40,20 @@ export async function updateExtmgrStatus(
32
40
  statusParts.push(autoUpdateStatus);
33
41
  }
34
42
 
43
+ // Validate updates against actually installed packages (handles external pi update)
35
44
  const knownUpdates = autoUpdateConfig.updatesAvailable ?? [];
36
- if (knownUpdates.length > 0) {
37
- statusParts.push(`${knownUpdates.length} update${knownUpdates.length === 1 ? "" : "s"}`);
45
+ const validUpdates = filterStaleUpdates(knownUpdates, packages);
46
+
47
+ // If stale updates were filtered, persist the correction
48
+ if (validUpdates.length !== knownUpdates.length) {
49
+ saveAutoUpdateConfig(pi, {
50
+ ...autoUpdateConfig,
51
+ updatesAvailable: validUpdates,
52
+ });
53
+ }
54
+
55
+ if (validUpdates.length > 0) {
56
+ statusParts.push(`${validUpdates.length} update${validUpdates.length === 1 ? "" : "s"}`);
38
57
  }
39
58
 
40
59
  if (statusParts.length > 0) {