tuil
ReferencePackages@mwillbanks/tuil-registryAPI

RegistryInstaller

class exported by @mwillbanks/tuil-registry.

View rawEdit

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

MemberTypeRequiredDescriptionRelated types
#statePathstringYesThe #statePath member uses the string contract.
#lockPathstringYesThe #lockPath member uses the string contract.
__constructoranyYesThe __constructor member uses the any contract.
install(item: RegistryItem, options?: RegistryInstallOptions) => Promise<InstallResult>YesThe install member uses the (item: RegistryItem, options?: RegistryInstallOptions) => Promise<InstallResult> contract.InstallResult, RegistryInstallOptions, RegistryItem
verify(items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<void>YesThe verify member uses the (items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<void> contract.RegistryInstallOptions, RegistryItem
installMany(items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<readonly InstallResult[]>YesThe installMany member uses the (items: readonly RegistryItem[], options?: RegistryInstallOptions) => Promise<readonly InstallResult[]> contract.InstallResult, RegistryInstallOptions, RegistryItem
diff(item: RegistryItem, options?: RegistryTransformOptions) => Promise<readonly RegistryDiff[]>YesThe diff member uses the (item: RegistryItem, options?: RegistryTransformOptions) => Promise<readonly RegistryDiff[]> contract.RegistryDiff, RegistryItem, RegistryTransformOptions
remove(name: string, force?: boolean) => Promise<readonly string[]>YesThe remove member uses the (name: string, force?: boolean) => Promise<readonly string[]> contract.
removeMany(names: readonly string[], force?: boolean) => Promise<readonly RemoveResult[]>YesThe removeMany member uses the (names: readonly string[], force?: boolean) => Promise<readonly RemoveResult[]> contract.RemoveResult
installed() => Promise<readonly string[]>YesThe installed member uses the () => Promise<readonly string[]> contract.
#resolveTarget(target: string) => stringYesThe #resolveTarget member uses the (target: string) => string contract.
#secureTarget(target: string) => Promise<string>YesThe #secureTarget member uses the (target: string) => Promise<string> contract.
#secureAbsoluteTarget(target: string) => Promise<string>YesThe #secureAbsoluteTarget member uses the (target: string) => Promise<string> contract.
#exists(path: string) => Promise<boolean>YesThe #exists member uses the (path: string) => Promise<boolean> contract.
#readState() => Promise<InstallState>YesThe #readState member uses the () => Promise<InstallState> contract.
#readLockfile() => Promise<RegistryLockfile>YesThe #readLockfile member uses the () => Promise<RegistryLockfile> contract.RegistryLockfile
#writeMetadata(state: InstallState, lockfile: RegistryLockfile) => Promise<void>YesThe #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.

Source

View the secondary source reference

Package

@mwillbanks/tuil-registry

On this page