RegistryInstaller
class exported by @mwillbanks/tuil-registry.
class
Public class exported by @mwillbanks/tuil-registry.
export class RegistryInstaller {
readonly #statePath: string;
readonly #lockPath: string;
constructor(readonly root: string) {
this.root = resolve(root);
this.#statePath = join(this.root, ".tuil", "registry.json");
this.#lockPath = join(this.root, ".tuil", "registry-lock.json");
}
async install(
item: RegistryItem,
options: RegistryInstallOptions = {},
): Promise<InstallResult> {
return (await this.installMany([item], options))[0] as InstallResult;
}
async verify(
items: readonly RegistryItem[],
options: RegistryInstallOptions = {},
): Promise<void> {
if (options.frozenLockfile) {
const failures = verifyRegistryLockfile(
await this.#readLockfile(),
items,
);
if (failures.length > 0) {
throw new Error(
`Registry lockfile verification failed:\n${failures.join("\n")}`,
);
}
}
for (const item of items) {
if (item.integrity && item.integrity !== registryIntegrity(item)) {
throw new Error(
`Registry item "${registryIdentity(item)}" failed integrity verification`,
);
}
const compatibility = registryCompatibilityIssues(
item,
options.environment ?? {
renderer: "unknown",
capabilities: new Set(),
},
).filter((issue) => !issue.startsWith("deprecated:"));
if (compatibility.length > 0) {
throw new Error(
`Registry item "${registryIdentity(item)}" is incompatible: ${compatibility.join("; ")}`,
);
}
}
}
async installMany(
items: readonly RegistryItem[],
options: RegistryInstallOptions = {},
): Promise<readonly InstallResult[]> {
if (items.length === 0) {
return [];
}
const [state, lockfile] = await Promise.all([
this.#readState(),
this.#readLockfile(),
]);
await this.verify(items, options);
interface MutableInstallResult {
readonly created: string[];
readonly updated: string[];
readonly unchanged: string[];
readonly removed: string[];
readonly hashes: Record<string, string>;
}
interface IncomingPlan {
readonly target: string;
readonly relativeTarget: string;
readonly content: string;
readonly incomingHash: string;
readonly owners: Set<string>;
local?: string;
}
const results = new Map<string, MutableInstallResult>();
const plans = new Map<string, IncomingPlan>();
for (const item of items) {
const identity = registryIdentity(item);
if (results.has(identity)) {
throw new Error(`Duplicate registry item "${identity}" in transaction`);
}
const result: MutableInstallResult = {
created: [] as string[],
updated: [] as string[],
unchanged: [] as string[],
removed: [] as string[],
hashes: {} as Record<string, string>,
};
results.set(identity, result);
for (const file of item.files) {
if (file.target in result.hashes) {
throw new Error(
`Registry item "${identity}" declares "${file.target}" more than once`,
);
}
const target = await this.#secureTarget(file.target);
const content = await prepareRegistryFile(item, file, options);
const incomingHash = hash(content);
result.hashes[file.target] = incomingHash;
const existingPlan = plans.get(target);
if (existingPlan && existingPlan.content !== content) {
throw new Error(
`Registry items provide conflicting content for "${file.target}"`,
);
}
if (existingPlan) {
existingPlan.owners.add(identity);
} else {
plans.set(target, {
target,
relativeTarget: file.target,
content,
incomingHash,
owners: new Set([identity]),
});
}
}
}
const transactionItems = new Set(results.keys());
for (const plan of plans.values()) {
try {
plan.local = await readFile(plan.target, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const existingOwners = Object.entries(state.items).flatMap(
([owner, record]) => {
const ownerHash = record.files[plan.relativeTarget];
return ownerHash ? [{ owner, ownerHash }] : [];
},
);
for (const { owner, ownerHash } of existingOwners) {
const ownerResult = results.get(owner);
const nextOwnerHash = ownerResult?.hashes[plan.relativeTarget];
if (!transactionItems.has(owner) && ownerHash !== plan.incomingHash) {
throw new Error(
`Cannot update shared registry file "${plan.relativeTarget}" without also updating owner "${owner}"`,
);
}
if (
nextOwnerHash !== undefined &&
nextOwnerHash !== plan.incomingHash
) {
throw new Error(
`Registry owners provide conflicting updates for "${plan.relativeTarget}"`,
);
}
}
const localHash = plan.local === undefined ? undefined : hash(plan.local);
if (
localHash !== undefined &&
localHash !== plan.incomingHash &&
!options.force
) {
if (existingOwners.length === 0) {
throw new Error(
`Refusing to overwrite untracked file "${plan.relativeTarget}". Use force explicitly.`,
);
}
if (existingOwners.some(({ ownerHash }) => ownerHash !== localHash)) {
throw new Error(
`Refusing to overwrite locally modified registry file "${plan.relativeTarget}". Run diff first or use force explicitly.`,
);
}
}
for (const owner of plan.owners) {
const result = results.get(owner);
if (!result) continue;
if (plan.local === undefined) {
result.created.push(plan.relativeTarget);
} else if (localHash === plan.incomingHash) {
result.unchanged.push(plan.relativeTarget);
} else {
result.updated.push(plan.relativeTarget);
}
}
}
const removalPlans = new Map<
string,
{
readonly file: string;
readonly target: string;
readonly local: string;
}
>();
for (const [identity, result] of results) {
const previous = state.items[identity];
for (const [file, installedHash] of Object.entries(
previous?.files ?? {},
)) {
if (file in result.hashes) continue;
result.removed.push(file);
if ([...plans.values()].some((plan) => plan.relativeTarget === file)) {
continue;
}
const survivingOwners = Object.entries(state.items).flatMap(
([owner, record]) => {
if (owner === identity) return [];
const ownerResult = results.get(owner);
const nextHash = ownerResult
? ownerResult.hashes[file]
: record.files[file];
return nextHash ? [{ owner, ownerHash: nextHash }] : [];
},
);
if (survivingOwners.length > 0) {
if (
survivingOwners.some(({ ownerHash }) => ownerHash !== installedHash)
) {
throw new Error(
`Registry state has conflicting owners for "${file}"`,
);
}
continue;
}
const target = await this.#secureTarget(file);
try {
const local = await readFile(target, "utf8");
if (!options.force && hash(local) !== installedHash) {
throw new Error(
`Refusing to remove locally modified registry file "${file}"`,
);
}
removalPlans.set(target, { file, target, local });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
const nextState: InstallState = {
version: 1,
items: { ...state.items },
};
const incomingLock = createRegistryLockfile(items);
const nextLock: RegistryLockfile = {
version: 1,
items: { ...lockfile.items, ...incomingLock.items },
};
for (const item of items) {
const identity = registryIdentity(item);
const result = results.get(identity);
if (result) {
nextState.items[identity] = {
files: result.hashes,
dependencies: registryDependencyIdentities(item),
installedAt: new Date().toISOString(),
};
}
}
const transaction = crypto.randomUUID();
const staged: {
readonly target: string;
readonly stage: string;
readonly backup: string;
readonly existed: boolean;
}[] = [];
const removed: {
readonly target: string;
readonly backup: string;
}[] = [];
try {
for (const plan of plans.values()) {
if (plan.local === plan.content) continue;
await mkdir(dirname(plan.target), { recursive: true });
await this.#secureTarget(plan.relativeTarget);
const stage = `${plan.target}.tuil-stage-${transaction}`;
const backup = `${plan.target}.tuil-backup-${transaction}`;
await writeFile(stage, plan.content, { encoding: "utf8", flag: "wx" });
staged.push({
target: plan.target,
stage,
backup,
existed: plan.local !== undefined,
});
}
for (const file of staged) {
await this.#secureAbsoluteTarget(file.target);
if (file.existed) {
await rename(file.target, file.backup);
}
await rename(file.stage, file.target);
}
for (const plan of removalPlans.values()) {
await this.#secureAbsoluteTarget(plan.target);
const backup = `${plan.target}.tuil-remove-${transaction}`;
await rename(plan.target, backup);
removed.push({ target: plan.target, backup });
}
await this.#writeMetadata(nextState, nextLock);
} catch (error) {
const rollbackErrors: unknown[] = [];
for (const file of [...removed].reverse()) {
try {
if (await this.#exists(file.backup)) {
await rename(file.backup, file.target);
}
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
for (const file of [...staged].reverse()) {
try {
await rm(file.stage, { force: true });
if (await this.#exists(file.backup)) {
await rm(file.target, { force: true });
await rename(file.backup, file.target);
} else if (!file.existed) {
await rm(file.target, { force: true });
}
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length > 0) {
throw new AggregateError(
[error, ...rollbackErrors],
"Registry transaction and rollback failed",
);
}
throw error;
}
await Promise.allSettled(
[
...staged.flatMap((file) => (file.existed ? [file.backup] : [])),
...removed.map((file) => file.backup),
].map((backup) => rm(backup, { force: true })),
);
return items.map((item) => {
const identity = registryIdentity(item);
const result = results.get(identity);
if (!result) {
throw new Error(`Missing install result for "${identity}"`);
}
return {
item: identity,
created: result.created,
updated: result.updated,
unchanged: result.unchanged,
removed: result.removed,
};
});
}
async diff(
item: RegistryItem,
options: RegistryTransformOptions = {},
): Promise<readonly RegistryDiff[]> {
return Promise.all(
item.files.map(async (file) => {
const incoming = await prepareRegistryFile(item, file, options);
try {
const local = await readFile(
await this.#secureTarget(file.target),
"utf8",
);
return {
path: file.target,
status: local === incoming ? "unchanged" : "modified",
diff: local === incoming ? "" : simpleDiff(local, incoming),
} as RegistryDiff;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {
path: file.target,
status: "missing",
diff: simpleDiff("", incoming),
} as RegistryDiff;
}
throw error;
}
}),
);
}
async remove(name: string, force = false): Promise<readonly string[]> {
return (await this.removeMany([name], force))[0]?.removed ?? [];
}
async removeMany(
names: readonly string[],
force = false,
): Promise<readonly RemoveResult[]> {
if (names.length === 0) return [];
const uniqueNames = [...new Set(names)];
if (uniqueNames.length !== names.length) {
throw new Error("Duplicate registry item in removal transaction");
}
const [state, lockfile] = await Promise.all([
this.#readState(),
this.#readLockfile(),
]);
for (const name of names) {
if (!state.items[name]) {
throw new Error(`Registry item "${name}" is not installed`);
}
}
const removalSet = new Set(names);
for (const [survivor, installed] of Object.entries(state.items)) {
if (removalSet.has(survivor)) continue;
const removedDependency = (installed.dependencies ?? []).find(
(dependency) => removalSet.has(dependency),
);
if (removedDependency) {
throw new Error(
`Cannot remove registry item "${removedDependency}" while dependent "${survivor}" remains installed`,
);
}
}
const results = new Map<string, string[]>(names.map((name) => [name, []]));
const planned: {
readonly file: string;
readonly target: string;
readonly backup: string;
}[] = [];
const plannedTargets = new Set<string>();
const transaction = crypto.randomUUID();
for (const name of names) {
const item = state.items[name];
if (!item) continue;
for (const [file, installedHash] of Object.entries(item.files)) {
const survivingHashes = Object.entries(state.items).flatMap(
([owner, installed]) => {
if (removalSet.has(owner)) return [];
const ownerHash = installed.files[file];
return ownerHash ? [ownerHash] : [];
},
);
if (survivingHashes.length > 0) {
if (
survivingHashes.some((ownerHash) => ownerHash !== installedHash)
) {
throw new Error(
`Registry state has conflicting owners for "${file}"`,
);
}
continue;
}
const target = await this.#secureTarget(file);
try {
const local = await readFile(target, "utf8");
if (!force && hash(local) !== installedHash) {
throw new Error(
`Refusing to remove locally modified registry file "${file}"`,
);
}
results.get(name)?.push(file);
if (!plannedTargets.has(target)) {
plannedTargets.add(target);
planned.push({
file,
target,
backup: `${target}.tuil-remove-${transaction}`,
});
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
const nextItems = { ...state.items };
const nextLockItems = { ...lockfile.items };
for (const name of names) delete nextItems[name];
for (const name of names) delete nextLockItems[name];
const moved: typeof planned = [];
try {
for (const file of planned) {
await this.#secureAbsoluteTarget(file.target);
await rename(file.target, file.backup);
moved.push(file);
}
await this.#writeMetadata(
{ version: 1, items: nextItems },
{ version: 1, items: nextLockItems },
);
} catch (error) {
const rollbackErrors: unknown[] = [];
for (const file of [...moved].reverse()) {
try {
if (await this.#exists(file.backup)) {
await rename(file.backup, file.target);
}
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length > 0) {
throw new AggregateError(
[error, ...rollbackErrors],
"Registry removal and rollback failed",
);
}
throw error;
}
await Promise.allSettled(
moved.map((file) => rm(file.backup, { force: true })),
);
return names.map((name) => ({
item: name,
removed: results.get(name) ?? [],
}));
}
async installed(): Promise<readonly string[]> {
return Object.keys((await this.#readState()).items).sort();
}
#resolveTarget(target: string): string {
if (isAbsolute(target)) {
throw new Error(`Registry target must be relative: "${target}"`);
}
const resolved = resolve(this.root, target);
const relativeTarget = relative(resolve(this.root), resolved);
if (
relativeTarget === ".." ||
relativeTarget.startsWith(`..${sep}`) ||
isAbsolute(relativeTarget)
) {
throw new Error(`Registry target escapes project root: "${target}"`);
}
return resolved;
}
async #secureTarget(target: string): Promise<string> {
return this.#secureAbsoluteTarget(this.#resolveTarget(target));
}
async #secureAbsoluteTarget(target: string): Promise<string> {
const rootInfo = await lstat(this.root);
if (rootInfo.isSymbolicLink()) {
throw new Error(
`Registry project root cannot be a symbolic link: "${this.root}"`,
);
}
const rootReal = await realpath(this.root);
const relativeTarget = relative(this.root, target);
let current = this.root;
for (const segment of relativeTarget.split(sep).filter(Boolean)) {
current = join(current, segment);
try {
const info = await lstat(current);
if (info.isSymbolicLink()) {
throw new Error(
`Registry target contains symbolic link "${relative(this.root, current)}"`,
);
}
const currentReal = await realpath(current);
const fromRoot = relative(rootReal, currentReal);
if (
fromRoot === ".." ||
fromRoot.startsWith(`..${sep}`) ||
isAbsolute(fromRoot)
) {
throw new Error(
`Registry target resolves outside project root: "${relativeTarget}"`,
);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
break;
}
throw error;
}
}
return target;
}
async #exists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
async #readState(): Promise<InstallState> {
try {
await this.#secureAbsoluteTarget(this.#statePath);
const value = JSON.parse(
await readFile(this.#statePath, "utf8"),
) as InstallState;
if (value.version !== 1 || !value.items) {
throw new Error("Unsupported registry state version");
}
return value;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { version: 1, items: {} };
}
throw error;
}
}
async #readLockfile(): Promise<RegistryLockfile> {
try {
await this.#secureAbsoluteTarget(this.#lockPath);
const value = JSON.parse(
await readFile(this.#lockPath, "utf8"),
) as RegistryLockfile;
if (value.version !== 1 || !value.items) {
throw new Error("Unsupported registry lockfile version");
}
return value;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { version: 1, items: {} };
}
throw error;
}
}
async #writeMetadata(
state: InstallState,
lockfile: RegistryLockfile,
): Promise<void> {
await mkdir(dirname(this.#statePath), { recursive: true });
const transaction = crypto.randomUUID();
const entries = [
{ path: this.#statePath, value: state },
{ path: this.#lockPath, value: lockfile },
];
const staged = entries.map((entry) => ({
...entry,
temporary: `${entry.path}.${transaction}.tmp`,
backup: `${entry.path}.${transaction}.backup`,
existed: false,
installed: false,
}));
try {
for (const entry of staged) {
await this.#secureAbsoluteTarget(entry.path);
await writeFile(
entry.temporary,
`${JSON.stringify(entry.value, null, 2)}\n`,
{
encoding: "utf8",
flag: "wx",
},
);
}
for (const entry of staged) {
entry.existed = await this.#exists(entry.path);
if (entry.existed) await rename(entry.path, entry.backup);
await rename(entry.temporary, entry.path);
entry.installed = true;
}
} catch (error) {
for (const entry of [...staged].reverse()) {
await rm(entry.temporary, { force: true });
if (entry.installed) await rm(entry.path, { force: true });
if (entry.existed && (await this.#exists(entry.backup))) {
await rename(entry.backup, entry.path);
}
}
throw error;
}
await Promise.allSettled(
staged.map((entry) => rm(entry.backup, { force: true })),
);
}
}Members
| Member | Type | Required | Description | Related types |
|---|---|---|---|---|
#statePath | string | Yes | The #statePath member uses the string contract. | — |
#lockPath | string | Yes | The #lockPath member uses the string contract. | — |
__constructor | any | Yes | The __constructor member uses the any contract. | — |
install | (item: RegistryItem, options?: RegistryInstallOptions) => Promise<InstallResult> | Yes | The install member uses the (item: RegistryItem, options?: RegistryInstallOptions) => Promise<InstallResult> contract. | InstallResult, RegistryInstallOptions, RegistryItem |
verify | (items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<void> | Yes | The verify member uses the (items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<void> contract. | RegistryInstallOptions, RegistryItem |
installMany | (items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<readonly InstallResult[]> | Yes | The installMany member uses the (items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<readonly InstallResult[]> contract. | InstallResult, RegistryInstallOptions, RegistryItem |
diff | (item: RegistryItem, options?: RegistryTransformOptions) => Promise<readonly RegistryDiff[]> | Yes | The diff member uses the (item: RegistryItem, options?: RegistryTransformOptions) => Promise<readonly RegistryDiff[]> contract. | RegistryDiff, RegistryItem, RegistryTransformOptions |
remove | (name: string, force?: boolean) => Promise<readonly string[]> | Yes | The remove member uses the (name: string, force?: boolean) => Promise<readonly string[]> contract. | — |
removeMany | (names: readonly string[], force?: boolean) => Promise<readonly RemoveResult[]> | Yes | The removeMany member uses the (names: readonly string[], force?: boolean) => Promise<readonly RemoveResult[]> contract. | RemoveResult |
installed | () => Promise<readonly string[]> | Yes | The installed member uses the () => Promise<readonly string[]> contract. | — |
#resolveTarget | (target: string) => string | Yes | The #resolveTarget member uses the (target: string) => string contract. | — |
#secureTarget | (target: string) => Promise<string> | Yes | The #secureTarget member uses the (target: string) => Promise<string> contract. | — |
#secureAbsoluteTarget | (target: string) => Promise<string> | Yes | The #secureAbsoluteTarget member uses the (target: string) => Promise<string> contract. | — |
#exists | (path: string) => Promise<boolean> | Yes | The #exists member uses the (path: string) => Promise<boolean> contract. | — |
#readState | () => Promise<InstallState> | Yes | The #readState member uses the () => Promise<InstallState> contract. | — |
#readLockfile | () => Promise<RegistryLockfile> | Yes | The #readLockfile member uses the () => Promise<RegistryLockfile> contract. | RegistryLockfile |
#writeMetadata | (state: InstallState, lockfile: RegistryLockfile) => Promise<void> | Yes | The #writeMetadata member uses the (state: InstallState, lockfile: RegistryLockfile) => Promise<void> contract. | RegistryLockfile |
Parameters
This declaration has no public members.
Returns
This declaration does not return a value.
Throws
No thrown errors are documented for this declaration.
Related types
InstallResultRegistryDiffRegistryInstallOptionsRegistryItemRegistryLockfileRegistryTransformOptionsRemoveResult
Source
View the secondary source reference