Keep desktop skills up to date automatically (#1309)
* Keep installed desktop skills up to date * Preserve user files during skill sync
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -15,6 +16,13 @@ export interface SkillSyncResult {
|
||||
processedSkills: number;
|
||||
}
|
||||
|
||||
const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json";
|
||||
|
||||
interface ManagedFilesManifest {
|
||||
version: 1;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
async function writeFileIfChanged(srcPath: string, dstPath: string): Promise<boolean> {
|
||||
const src = await fs.readFile(srcPath);
|
||||
const dst = await fs.readFile(dstPath).catch(() => null);
|
||||
@@ -30,10 +38,12 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
const rel = path.relative(rootDir, full);
|
||||
if (rel === MANAGED_FILES_MANIFEST) continue;
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
out.push(path.relative(rootDir, full));
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,14 +51,89 @@ export async function listFilesRecursive(rootDir: string): Promise<string[]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readManagedFilesManifest(dstDir: string): Promise<ManagedFilesManifest | null> {
|
||||
const raw = await fs
|
||||
.readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8")
|
||||
.catch(() => null);
|
||||
if (!raw) return null;
|
||||
const parsed = safeParseJson(raw) as Partial<ManagedFilesManifest> | null;
|
||||
if (!parsed) return null;
|
||||
if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null)
|
||||
return null;
|
||||
return { version: 1, files: parsed.files as Record<string, string> };
|
||||
}
|
||||
|
||||
function safeParseJson(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function hashFile(filePath: string): Promise<string> {
|
||||
const buf = await fs.readFile(filePath);
|
||||
return createHash("sha256").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
async function writeManifestIfChanged(
|
||||
dstDir: string,
|
||||
files: Record<string, string>,
|
||||
): Promise<boolean> {
|
||||
const manifest: ManagedFilesManifest = { version: 1, files };
|
||||
const next = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
const manifestPath = path.join(dstDir, MANAGED_FILES_MANIFEST);
|
||||
const current = await fs.readFile(manifestPath, "utf-8").catch(() => null);
|
||||
if (current === next) return false;
|
||||
await fs.mkdir(dstDir, { recursive: true });
|
||||
await fs.writeFile(manifestPath, next);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pruneEmptyParentDirs(rootDir: string, rels: readonly string[]): Promise<void> {
|
||||
const dirs = new Set<string>();
|
||||
for (const rel of rels) {
|
||||
let dir = path.dirname(rel);
|
||||
while (dir !== ".") {
|
||||
dirs.add(dir);
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
}
|
||||
const deepestFirst = [...dirs].sort(
|
||||
(a, b) => b.split(path.sep).length - a.split(path.sep).length,
|
||||
);
|
||||
for (const rel of deepestFirst) {
|
||||
await fs.rmdir(path.join(rootDir, rel)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise<number> {
|
||||
const files = await listFilesRecursive(srcDir);
|
||||
const srcFileSet = new Set(files);
|
||||
const srcHashes: Record<string, string> = {};
|
||||
for (const rel of files) {
|
||||
srcHashes[rel] = await hashFile(path.join(srcDir, rel));
|
||||
}
|
||||
const previousManifest = await readManagedFilesManifest(dstDir);
|
||||
let changed = 0;
|
||||
for (const rel of files) {
|
||||
if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) {
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
const deletedRels: string[] = [];
|
||||
for (const [rel, previousHash] of Object.entries(previousManifest?.files ?? {})) {
|
||||
if (srcFileSet.has(rel)) continue;
|
||||
const dstPath = path.join(dstDir, rel);
|
||||
const currentHash = await hashFile(dstPath).catch(() => null);
|
||||
if (currentHash !== previousHash) continue;
|
||||
await fs.rm(dstPath, { force: true });
|
||||
deletedRels.push(rel);
|
||||
changed++;
|
||||
}
|
||||
await pruneEmptyParentDirs(dstDir, deletedRels);
|
||||
if (await writeManifestIfChanged(dstDir, srcHashes)) changed++;
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user