pi-extmgr 0.1.13 → 0.1.14
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 +5 -1
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/packages/discovery.ts +5 -12
- package/src/packages/extensions.ts +77 -10
- package/src/packages/management.ts +7 -6
- package/src/utils/auto-update.ts +2 -8
- package/src/utils/cache.ts +155 -32
- package/src/utils/format.ts +39 -16
- package/src/utils/history.ts +0 -1
- package/src/utils/package-source.ts +3 -0
- package/src/utils/settings.ts +115 -16
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:`,
|
|
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
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 (
|
|
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(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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) {
|
|
@@ -9,7 +9,7 @@ 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
14
|
import { notify, error as notifyError, success } from "../utils/notify.js";
|
|
15
15
|
import {
|
|
@@ -53,7 +53,7 @@ async function updatePackageInternal(
|
|
|
53
53
|
|
|
54
54
|
if (res.code !== 0) {
|
|
55
55
|
const errorMsg = `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`;
|
|
56
|
-
logPackageUpdate(pi, source, source, undefined,
|
|
56
|
+
logPackageUpdate(pi, source, source, undefined, false, errorMsg);
|
|
57
57
|
notifyError(ctx, errorMsg);
|
|
58
58
|
void updateExtmgrStatus(ctx, pi);
|
|
59
59
|
return NO_PACKAGE_MUTATION_OUTCOME;
|
|
@@ -62,12 +62,12 @@ async function updatePackageInternal(
|
|
|
62
62
|
const stdout = res.stdout || "";
|
|
63
63
|
if (stdout.includes("already up to date") || stdout.includes("pinned")) {
|
|
64
64
|
notify(ctx, `${source} is already up to date (or pinned).`, "info");
|
|
65
|
-
logPackageUpdate(pi, source, source, undefined,
|
|
65
|
+
logPackageUpdate(pi, source, source, undefined, true);
|
|
66
66
|
void updateExtmgrStatus(ctx, pi);
|
|
67
67
|
return NO_PACKAGE_MUTATION_OUTCOME;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
logPackageUpdate(pi, source, source, undefined,
|
|
70
|
+
logPackageUpdate(pi, source, source, undefined, true);
|
|
71
71
|
success(ctx, `Updated ${source}`);
|
|
72
72
|
void updateExtmgrStatus(ctx, pi);
|
|
73
73
|
|
|
@@ -139,8 +139,9 @@ function packageIdentity(source: string, fallbackName?: string): string {
|
|
|
139
139
|
return `npm:${npm.name}`;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
if (source
|
|
143
|
-
const
|
|
142
|
+
if (getPackageSourceKind(source) === "git") {
|
|
143
|
+
const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
|
|
144
|
+
const { repo } = splitGitRepoAndRef(gitSpec);
|
|
144
145
|
return `git:${repo}`;
|
|
145
146
|
}
|
|
146
147
|
|
package/src/utils/auto-update.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function startAutoUpdateTimer(
|
|
|
50
50
|
const interval = getScheduleInterval(config);
|
|
51
51
|
if (!interval) return;
|
|
52
52
|
|
|
53
|
-
//
|
|
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
|
-
|
|
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
|
|
package/src/utils/cache.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
414
|
+
await enqueueCacheSave();
|
|
292
415
|
}
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
+
function isGitLikeSource(source: string): boolean {
|
|
61
61
|
return (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
|
|
104
|
+
return `npm:${trimmed}`;
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
export function parseNpmSource(
|
package/src/utils/history.ts
CHANGED
|
@@ -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
|
) {
|
package/src/utils/settings.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
99
|
-
await
|
|
100
|
-
} catch
|
|
101
|
-
|
|
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,13 @@ 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
|
|
259
|
+
const fullConfig = sanitizeAutoUpdateConfig({
|
|
157
260
|
...DEFAULT_CONFIG,
|
|
158
261
|
...config,
|
|
159
|
-
};
|
|
262
|
+
});
|
|
160
263
|
|
|
161
264
|
pi.appendEntry(SETTINGS_KEY, fullConfig);
|
|
162
|
-
|
|
163
|
-
// Write to disk asynchronously (fire and forget)
|
|
164
|
-
writeConfigToDisk(fullConfig).catch((err) => {
|
|
165
|
-
console.warn("[extmgr] Failed to save settings:", err);
|
|
166
|
-
});
|
|
265
|
+
enqueueConfigWrite(fullConfig);
|
|
167
266
|
}
|
|
168
267
|
|
|
169
268
|
/**
|