mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 12:00:26 +00:00
feat(cli): keep your edits when you reinstall a catalog item (#3193)
* feat(cli): keep your edits when you reinstall a catalog item Running add again overwrote whatever was on disk, so a project that had tuned an installed block lost that work without being asked or told. The installer now records a hash of each file as it installs it, and compares before writing. A file that still matches is replaced as before; one that does not is left alone and reported. A file we have no record of counts as changed, which covers both a project that wrote the file itself and one that installed before the record existed. --force restores the old behaviour for when you do want the registry's version. * test(cli): cover the dependency plan install path
This commit is contained in:
@@ -1,34 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertSafeTarget } from "./installer.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RegistryItem } from "@hyperframes/core";
|
||||
|
||||
const DEST = "/tmp/hf-install-test";
|
||||
// The installer fetches over the network; the point of these tests is what it
|
||||
// does to files on disk, so the fetch is replaced by a local write.
|
||||
const remote = vi.hoisted(() => ({ contents: "REGISTRY VERSION\n" }));
|
||||
vi.mock("./remote.js", () => ({
|
||||
DEFAULT_REGISTRY_URL: "https://example.test/r",
|
||||
fetchItemFile: vi.fn(async (_item: unknown, _file: unknown, destPath: string): Promise<void> => {
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
writeFileSync(destPath, remote.contents, "utf-8");
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("assertSafeTarget", () => {
|
||||
it("allows simple relative paths", () => {
|
||||
expect(() => assertSafeTarget(DEST, "index.html")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "compositions/intro.html")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "assets/nested/deep/file.svg")).not.toThrow();
|
||||
const { hasLocalEdits, installItem } = await import("./installer.js");
|
||||
|
||||
function project(): string {
|
||||
return mkdtempSync(join(tmpdir(), "hf-installer-"));
|
||||
}
|
||||
|
||||
const item = {
|
||||
name: "data-chart",
|
||||
type: "hyperframes:component",
|
||||
files: [
|
||||
{ path: "data-chart.html", target: "components/data-chart.html", type: "hyperframes:file" },
|
||||
],
|
||||
} as unknown as RegistryItem;
|
||||
|
||||
const target = "components/data-chart.html";
|
||||
|
||||
describe("hasLocalEdits", () => {
|
||||
it("treats a file with no record as edited", () => {
|
||||
// Covers a project that wrote the file itself, and one that installed
|
||||
// before the record existed. Both would rather keep what they have.
|
||||
expect(hasLocalEdits({}, target, "anything")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects `..` path segments", () => {
|
||||
expect(() => assertSafeTarget(DEST, "../escape.html")).toThrow(/\.\./);
|
||||
expect(() => assertSafeTarget(DEST, "compositions/../../escape.html")).toThrow(/\.\./);
|
||||
expect(() => assertSafeTarget(DEST, "a/b/../../../escape.html")).toThrow();
|
||||
it("treats a file matching its record as untouched", () => {
|
||||
const contents = "exactly what we installed";
|
||||
const record = { [target]: createHash("sha256").update(contents).digest("hex") };
|
||||
expect(hasLocalEdits(record, target, contents)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects Unix absolute paths", () => {
|
||||
expect(() => assertSafeTarget(DEST, "/etc/passwd")).toThrow(/absolute/);
|
||||
expect(() => assertSafeTarget(DEST, "/home/user/file.txt")).toThrow();
|
||||
it("treats a file that no longer matches its record as edited", () => {
|
||||
const record = { [target]: createHash("sha256").update("original").digest("hex") };
|
||||
expect(hasLocalEdits(record, target, "changed")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects Windows drive-letter paths", () => {
|
||||
expect(() => assertSafeTarget(DEST, "C:/Windows/System32")).toThrow(/Windows/);
|
||||
expect(() => assertSafeTarget(DEST, "D:\\notes.txt")).toThrow();
|
||||
describe("installItem", () => {
|
||||
it("records what it installed, so a later install can tell", async () => {
|
||||
const dir = project();
|
||||
const result = await installItem(item, { destDir: dir });
|
||||
|
||||
expect(result.written).toHaveLength(1);
|
||||
expect(result.preserved).toEqual([]);
|
||||
const record = JSON.parse(readFileSync(join(dir, "hyperframes.lock.json"), "utf-8"));
|
||||
expect(record[target]).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("replaces a file the project has not touched", async () => {
|
||||
const dir = project();
|
||||
await installItem(item, { destDir: dir });
|
||||
|
||||
remote.contents = "REGISTRY VERSION 2\n";
|
||||
const again = await installItem(item, { destDir: dir });
|
||||
remote.contents = "REGISTRY VERSION\n";
|
||||
|
||||
expect(again.preserved).toEqual([]);
|
||||
expect(readFileSync(join(dir, target), "utf-8")).toBe("REGISTRY VERSION 2\n");
|
||||
});
|
||||
|
||||
it("keeps a file the project has edited", async () => {
|
||||
const dir = project();
|
||||
await installItem(item, { destDir: dir });
|
||||
writeFileSync(join(dir, target), "MY OWN COLOURS\n", "utf-8");
|
||||
|
||||
const again = await installItem(item, { destDir: dir });
|
||||
|
||||
expect(again.written).toEqual([]);
|
||||
expect(again.preserved).toHaveLength(1);
|
||||
expect(readFileSync(join(dir, target), "utf-8")).toBe("MY OWN COLOURS\n");
|
||||
});
|
||||
|
||||
it("keeps a file that was there before any install", async () => {
|
||||
const dir = project();
|
||||
mkdirSync(join(dir, "components"), { recursive: true });
|
||||
writeFileSync(join(dir, target), "PRE-EXISTING\n", "utf-8");
|
||||
|
||||
const result = await installItem(item, { destDir: dir });
|
||||
|
||||
expect(result.preserved).toHaveLength(1);
|
||||
expect(readFileSync(join(dir, target), "utf-8")).toBe("PRE-EXISTING\n");
|
||||
});
|
||||
|
||||
it("overwrites an edited file when forced", async () => {
|
||||
const dir = project();
|
||||
await installItem(item, { destDir: dir });
|
||||
writeFileSync(join(dir, target), "MY OWN COLOURS\n", "utf-8");
|
||||
|
||||
const forced = await installItem(item, { destDir: dir, force: true });
|
||||
|
||||
expect(forced.preserved).toEqual([]);
|
||||
expect(readFileSync(join(dir, target), "utf-8")).toBe("REGISTRY VERSION\n");
|
||||
});
|
||||
|
||||
it("does not read its own edit back as the project's", async () => {
|
||||
// A block composition gets a marker comment added after fetching. Recording
|
||||
// the pre-marker bytes would make every reinstall look like an edit.
|
||||
const block = {
|
||||
name: "hero",
|
||||
type: "hyperframes:block",
|
||||
files: [{ path: "hero.html", target: "blocks/hero.html", type: "hyperframes:composition" }],
|
||||
} as unknown as RegistryItem;
|
||||
|
||||
const dir = project();
|
||||
await installItem(block, { destDir: dir });
|
||||
const second = await installItem(block, { destDir: dir });
|
||||
|
||||
expect(second.preserved).toEqual([]);
|
||||
expect(second.written).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installing several items, as a dependency plan does", () => {
|
||||
const other = {
|
||||
name: "shared-caption",
|
||||
type: "hyperframes:component",
|
||||
files: [
|
||||
{
|
||||
path: "shared-caption.html",
|
||||
target: "components/shared-caption.html",
|
||||
type: "hyperframes:file",
|
||||
},
|
||||
],
|
||||
} as unknown as RegistryItem;
|
||||
|
||||
const otherTarget = "components/shared-caption.html";
|
||||
|
||||
it("keeps a record for every item, not just the last one installed", () => {
|
||||
// `add` installs dependencies first and the requested item last. A record
|
||||
// rewritten per item rather than merged would forget the dependency, and
|
||||
// the next install would read it as edited and refuse to update it.
|
||||
const dir = project();
|
||||
return installItem(item, { destDir: dir })
|
||||
.then(() => installItem(other, { destDir: dir }))
|
||||
.then(() => {
|
||||
const record = JSON.parse(readFileSync(join(dir, "hyperframes.lock.json"), "utf-8"));
|
||||
expect(Object.keys(record).sort()).toEqual([otherTarget, target].sort());
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an edit to one item while updating another", async () => {
|
||||
const dir = project();
|
||||
await installItem(item, { destDir: dir });
|
||||
await installItem(other, { destDir: dir });
|
||||
writeFileSync(join(dir, target), "EDITED DEPENDENCY\n", "utf-8");
|
||||
|
||||
const edited = await installItem(item, { destDir: dir });
|
||||
const untouched = await installItem(other, { destDir: dir });
|
||||
|
||||
it("allows `.` segments (no-op) and dotfile-like names", () => {
|
||||
expect(() => assertSafeTarget(DEST, ".hidden")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "./file.html")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "a..b/file.html")).not.toThrow();
|
||||
expect(edited.preserved).toHaveLength(1);
|
||||
expect(untouched.written).toHaveLength(1);
|
||||
expect(readFileSync(join(dir, target), "utf-8")).toBe("EDITED DEPENDENCY\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* runtime to reject traversal even if the registry JSON schema was bypassed.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, relative, isAbsolute } from "node:path";
|
||||
import type { FileTarget, RegistryItem } from "@hyperframes/core";
|
||||
import { fetchItemFile, DEFAULT_REGISTRY_URL } from "./remote.js";
|
||||
@@ -16,11 +17,72 @@ export interface InstallOptions {
|
||||
destDir: string;
|
||||
/** Base URL of the registry. Defaults to the official public registry. */
|
||||
baseUrl?: string;
|
||||
/** Overwrite files the project has changed since they were installed. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
/** Absolute paths of files actually written. */
|
||||
written: string[];
|
||||
/** Absolute paths left alone because the project had changed them. */
|
||||
preserved: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What each installed file looked like when we installed it.
|
||||
*
|
||||
* Reinstalling an item used to overwrite whatever was on disk, so a project
|
||||
* that had tuned a block's colours lost that work to the next `add`. Comparing
|
||||
* the file against the hash we recorded is what tells an untouched file, which
|
||||
* is safe to replace, apart from an edited one, which is not.
|
||||
*/
|
||||
const INSTALL_RECORD = "hyperframes.lock.json";
|
||||
|
||||
type InstallRecord = Record<string, string>;
|
||||
|
||||
function digest(contents: Buffer | string): string {
|
||||
return createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
function readInstallRecord(destDir: string): InstallRecord {
|
||||
const path = resolve(destDir, INSTALL_RECORD);
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
||||
// A hand-edited or truncated record must not take the project's files with
|
||||
// it: an unreadable record means "provenance unknown", which is the
|
||||
// cautious answer everywhere it is used.
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const record: InstallRecord = {};
|
||||
for (const [target, hash] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof hash === "string") record[target] = hash;
|
||||
}
|
||||
return record;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeInstallRecord(destDir: string, record: InstallRecord): void {
|
||||
const sorted = Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
|
||||
writeFileSync(resolve(destDir, INSTALL_RECORD), `${JSON.stringify(sorted, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the project changed this file since we installed it?
|
||||
*
|
||||
* A file we have no record of counts as changed. That covers the project that
|
||||
* wrote the file itself, and the one that installed before this record existed;
|
||||
* both would rather keep their file than have it silently replaced.
|
||||
*/
|
||||
export function hasLocalEdits(
|
||||
record: InstallRecord,
|
||||
target: string,
|
||||
onDisk: Buffer | string,
|
||||
): boolean {
|
||||
const installed = record[target];
|
||||
if (!installed) return true;
|
||||
return installed !== digest(onDisk);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,17 +140,48 @@ export async function installItem(
|
||||
assertSafeTarget(destDir, file.target);
|
||||
}
|
||||
|
||||
const written = await Promise.all(
|
||||
const record = readInstallRecord(destDir);
|
||||
|
||||
const outcomes = await Promise.all(
|
||||
item.files.map(async (file: FileTarget) => {
|
||||
const destPath = resolve(destDir, file.target);
|
||||
|
||||
// Decided before fetching rather than after: a file we are going to keep
|
||||
// should never be overwritten and then put back, because a crash in
|
||||
// between would lose it for real.
|
||||
if (
|
||||
!options.force &&
|
||||
existsSync(destPath) &&
|
||||
hasLocalEdits(record, file.target, readFileSync(destPath))
|
||||
) {
|
||||
return { destPath, target: file.target, preserved: true, hash: null };
|
||||
}
|
||||
|
||||
await fetchItemFile(item, file, destPath, baseUrl);
|
||||
if (isInstalledRegistryBlockComposition(item, file)) {
|
||||
const source = readFileSync(destPath, "utf-8");
|
||||
writeFileSync(destPath, addRegistryItemMarker(source, item), "utf-8");
|
||||
}
|
||||
return destPath;
|
||||
// Hash what actually landed, marker included, or the next install reads
|
||||
// its own marker as the project's edit.
|
||||
return {
|
||||
destPath,
|
||||
target: file.target,
|
||||
preserved: false,
|
||||
hash: digest(readFileSync(destPath)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { written };
|
||||
const written = outcomes.filter((o) => !o.preserved).map((o) => o.destPath);
|
||||
const preserved = outcomes.filter((o) => o.preserved).map((o) => o.destPath);
|
||||
|
||||
if (written.length > 0) {
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.hash) record[outcome.target] = outcome.hash;
|
||||
}
|
||||
writeInstallRecord(destDir, record);
|
||||
}
|
||||
|
||||
return { written, preserved };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user