diff --git a/packages/angular/cli/src/package-managers/min-release-age.ts b/packages/angular/cli/src/package-managers/min-release-age.ts new file mode 100644 index 000000000000..f86d0a8d0e9c --- /dev/null +++ b/packages/angular/cli/src/package-managers/min-release-age.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file contains the logic for reading the user-configured + * "minimum release age" (a.k.a. install cooldown) from the active package + * manager. When configured, the CLI must respect the same gate during + * automatic version selection (e.g. `ng update`, `ng add`); otherwise it can + * pick a version that the package manager itself will refuse to install. + * + * Coverage notes: + * - **npm** reads `min-release-age` from `.npmrc` (value in days). + * See https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age. + * - **pnpm 10.x** reads `minimum-release-age` from `.npmrc` (value in minutes). + * pnpm 11+ migrated the canonical setting to `minimumReleaseAge` in + * `pnpm-workspace.yaml`, which is not currently parsed by this utility. + * - **yarn-classic** has no native cooldown, but mirrors npm's `.npmrc` + * parsing, so we honor `min-release-age` when present. + * - **yarn (berry)** uses `npmMinimalAgeGate` in `.yarnrc.yml`, which is not + * currently parsed by this utility. + * - **bun** uses `install.minimumReleaseAge` in `bunfig.toml`, which is not + * currently parsed by this utility. + */ + +import * as ini from 'ini'; +import { dirname, join } from 'node:path'; +import { Host } from './host'; +import { Logger } from './logger'; +import { PackageManagerDescriptor } from './package-manager-descriptor'; + +const MS_PER_MINUTE = 60_000; +const MS_PER_DAY = 86_400_000; + +/** + * Converts a value in `unit` to milliseconds. + */ +function toMs(value: number, unit: 'days' | 'minutes'): number { + return unit === 'days' ? value * MS_PER_DAY : value * MS_PER_MINUTE; +} + +/** + * Reads and merges `.npmrc` files starting at `startDir` and walking up the + * directory tree until either a git repository root or the filesystem root is + * reached. + * + * Values defined in directories closer to `startDir` take precedence over + * those defined in ancestor directories. This mirrors how `npm` itself + * resolves project-level configuration. + * + * @returns The merged options as a plain object. Returns an empty object when + * no `.npmrc` files are found. + */ +async function readNpmrcChain( + host: Host, + startDir: string, + logger?: Logger, +): Promise> { + const directoriesToVisit: string[] = []; + + let currentDir = startDir; + while (true) { + directoriesToVisit.push(currentDir); + + // Stop walking when we reach a git repository root, mirroring `discovery.ts`. + // `.git` may be a directory (regular repo), a file (submodules and + // worktrees use a `gitdir:` pointer file), or absent. We just need to + // know whether it exists; `host.stat` rejects when it doesn't. + try { + await host.stat(join(currentDir, '.git')); + break; + } catch { + // No `.git` here; continue searching upwards. + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + // Reached the filesystem root. + break; + } + currentDir = parentDir; + } + + // Apply ancestor configs first so that closer-to-cwd values override them. + const merged: Record = {}; + for (let i = directoriesToVisit.length - 1; i >= 0; i--) { + const npmrcPath = join(directoriesToVisit[i], '.npmrc'); + let contents: string; + try { + contents = await host.readFile(npmrcPath); + } catch { + // File not present or unreadable. + continue; + } + + try { + const parsed = ini.parse(contents) as Record; + Object.assign(merged, parsed); + logger?.debug(`Loaded options from '${npmrcPath}'.`); + } catch (e) { + logger?.debug(`Failed to parse '${npmrcPath}': ${e}.`); + } + } + + return merged; +} + +/** + * Determines the minimum release age (in milliseconds) configured for the + * given package manager. + * + * @param host A `Host` instance for reading configuration files. + * @param cwd The directory from which to start the configuration search. + * @param descriptor The active package manager's descriptor. + * @param logger An optional logger instance. + * @returns A non-negative number of milliseconds. Returns `0` when the active + * package manager has no minimum release age configured (or when this + * utility does not yet support reading the relevant configuration source). + */ +export async function getMinReleaseAgeMs( + host: Host, + cwd: string, + descriptor: PackageManagerDescriptor, + logger?: Logger, +): Promise { + const config = descriptor.minReleaseAge; + if (!config) { + return 0; + } + + const npmrc = await readNpmrcChain(host, cwd, logger); + const rawValue = npmrc[config.key]; + if (rawValue === undefined || rawValue === null || rawValue === '') { + return 0; + } + + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + + return toMs(parsed, config.unit); +} diff --git a/packages/angular/cli/src/package-managers/min-release-age_spec.ts b/packages/angular/cli/src/package-managers/min-release-age_spec.ts new file mode 100644 index 000000000000..d31b4ecf3387 --- /dev/null +++ b/packages/angular/cli/src/package-managers/min-release-age_spec.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { getMinReleaseAgeMs } from './min-release-age'; +import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor'; +import { MockHost } from './testing/mock-host'; + +const MS_PER_MINUTE = 60_000; +const MS_PER_DAY = 86_400_000; + +describe('getMinReleaseAgeMs', () => { + it('returns 0 when the descriptor has no minReleaseAge configuration', async () => { + const host = new MockHost(); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.bun)).toBe(0); + }); + + it('returns 0 when no .npmrc file is present', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + }); + + it('reads npm `min-release-age` (in days) from project .npmrc', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'min-release-age=7\n'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe( + 7 * MS_PER_DAY, + ); + }); + + it('reads pnpm `minimum-release-age` (in minutes) from project .npmrc', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'minimum-release-age=1440\n'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.pnpm)).toBe( + 1440 * MS_PER_MINUTE, + ); + }); + + it('does not pick up an unrelated key', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'min-release-age=7\n'); + + // pnpm uses `minimum-release-age`, not `min-release-age`. + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.pnpm)).toBe(0); + }); + + it('walks up the directory tree until reaching the .git root', async () => { + const host = new MockHost(); + host.setDirectory('/repo/.git'); + host.setFile('/repo/.npmrc', 'min-release-age=3\n'); + + expect( + await getMinReleaseAgeMs(host, '/repo/packages/app', SUPPORTED_PACKAGE_MANAGERS.npm), + ).toBe(3 * MS_PER_DAY); + }); + + it('lets values closer to the project override ancestor values', async () => { + const host = new MockHost(); + host.setDirectory('/repo/.git'); + host.setFile('/repo/.npmrc', 'min-release-age=10\n'); + host.setFile('/repo/packages/app/.npmrc', 'min-release-age=2\n'); + + expect( + await getMinReleaseAgeMs(host, '/repo/packages/app', SUPPORTED_PACKAGE_MANAGERS.npm), + ).toBe(2 * MS_PER_DAY); + }); + + it('treats a `.git` file as a repo root (git submodules and worktrees)', async () => { + const host = new MockHost(); + // In a submodule or worktree `.git` is a regular file containing a + // `gitdir:` pointer, not a directory. The walk must still stop here. + host.setFile('/repo/.git', 'gitdir: /elsewhere/.git/modules/repo\n'); + host.setFile('/repo/.npmrc', 'min-release-age=4\n'); + + expect(await getMinReleaseAgeMs(host, '/repo', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe( + 4 * MS_PER_DAY, + ); + }); + + it('returns 0 for non-positive or non-numeric values', async () => { + const host = new MockHost(); + host.setDirectory('/project/.git'); + host.setFile('/project/.npmrc', 'min-release-age=not-a-number\n'); + + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + + host.setFile('/project/.npmrc', 'min-release-age=0\n'); + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + + host.setFile('/project/.npmrc', 'min-release-age=-5\n'); + expect(await getMinReleaseAgeMs(host, '/project', SUPPORTED_PACKAGE_MANAGERS.npm)).toBe(0); + }); +}); diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index 2dfe75ee01cd..319db387bc3a 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -120,6 +120,27 @@ export interface PackageManagerDescriptor { /** A function that checks if a structured error represents a "package not found" error. */ readonly isNotFound: (error: ErrorInfo) => boolean; + + /** + * Describes how to read the user-configured "minimum release age" (also + * known as the install cooldown) for this package manager. + * + * When set, the CLI uses this configuration to filter out versions that + * are too new during automatic version selection (e.g. `ng update`, + * `ng add`). This prevents the CLI from picking a version that the + * underlying package manager would subsequently refuse to install. + * + * Set to `undefined` for package managers whose configuration is not yet + * supported here. The cooldown filter then becomes a no-op for those + * package managers, which preserves the existing behavior. + */ + readonly minReleaseAge?: { + /** The setting name to read from `.npmrc`. */ + readonly key: string; + + /** The unit the setting value is expressed in. */ + readonly unit: 'days' | 'minutes'; + }; } /** A type that represents the name of a supported package manager. */ @@ -175,6 +196,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getError: parseNpmLikeError, }, isNotFound: isKnownNotFound, + // npm 11.10+ honors `min-release-age` (in days) from `.npmrc`. + minReleaseAge: { key: 'min-release-age', unit: 'days' }, }, yarn: { binary: 'yarn', @@ -228,6 +251,8 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getError: parseYarnClassicError, }, isNotFound: isKnownNotFound, + // Yarn classic has no native cooldown but reads `.npmrc`, so honor `min-release-age`. + minReleaseAge: { key: 'min-release-age', unit: 'days' }, }, pnpm: { binary: 'pnpm', @@ -255,6 +280,9 @@ export const SUPPORTED_PACKAGE_MANAGERS = { getError: parseNpmLikeError, }, isNotFound: isKnownNotFound, + // pnpm 10.x reads `minimum-release-age` from `.npmrc` (in minutes). + // pnpm 11+ uses `minimumReleaseAge` in `pnpm-workspace.yaml`, which is not handled here. + minReleaseAge: { key: 'minimum-release-age', unit: 'minutes' }, }, bun: { binary: 'bun', diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index 33b8b07d48e3..941e74f2c26c 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -18,6 +18,7 @@ import { maxSatisfying, valid } from 'semver'; import { PackageManagerError } from './error'; import { Host } from './host'; import { Logger } from './logger'; +import { getMinReleaseAgeMs } from './min-release-age'; import { PackageManagerDescriptor } from './package-manager-descriptor'; import { PackageManifest, PackageMetadata } from './package-metadata'; import { InstalledPackage } from './package-tree'; @@ -93,6 +94,7 @@ export class PackageManager { readonly #initializationError?: Error; #dependencyCache: Map | null = null; #version: string | undefined; + #minReleaseAgeMs: number | undefined; /** * Creates a new `PackageManager` instance. @@ -528,20 +530,27 @@ export class PackageManager { // `fetchSpec` is the version, range, or tag. let versionSpec = fetchSpec ?? 'latest'; - if (this.descriptor.requiresManifestVersionLookup) { - if (type === 'tag' || !fetchSpec) { - const metadata = await this.getRegistryMetadata(name, options); - if (!metadata) { - return null; - } - versionSpec = metadata['dist-tags'][versionSpec]; - } else if (type === 'range') { - const metadata = await this.getRegistryMetadata(name, options); - if (!metadata) { - return null; - } - versionSpec = maxSatisfying(metadata.versions, fetchSpec) ?? ''; + const minReleaseAgeMs = await this.#getMinReleaseAgeMs(); + + // We only need to resolve the version locally when: + // 1. The package manager requires it (e.g. yarn-classic), OR + // 2. A `minReleaseAge` cooldown is configured AND the spec is a `tag` + // or `range`. For an explicit version we skip the extra metadata + // lookup; if the version is too new the package manager itself + // will reject the install with a clearer error than we could give. + const needsLocalResolution = + (this.descriptor.requiresManifestVersionLookup && + (type === 'tag' || type === 'range' || !fetchSpec)) || + (minReleaseAgeMs > 0 && (type === 'tag' || type === 'range')); + + if (needsLocalResolution) { + const metadata = await this.getRegistryMetadata(name, options); + if (!metadata) { + return null; } + + versionSpec = this.#selectVersion(metadata, type, versionSpec, minReleaseAgeMs) ?? ''; + if (!versionSpec) { return null; } @@ -598,6 +607,116 @@ export class PackageManager { } } + /** + * Resolves a `tag` or `range` specifier to a concrete version, optionally + * excluding versions that are too new to satisfy the configured minimum + * release age cooldown. + * + * Behavior summary: + * - `tag`: resolves the tag, then returns it directly when it is old enough. + * When it is too new, falls back to the newest version that satisfies the + * cooldown, so commands like `ng update` and `ng add` can still proceed. + * - `range`: returns the newest version in the range that is old enough. + * + * Note: this is not called for explicit `version` specifiers when a + * cooldown is configured (the manifest endpoint is used directly and the + * package manager enforces the cooldown at install time). + * + * @param metadata The package metadata returned by the registry. + * @param type The specifier type (`tag` or `range`). `version` is only + * passed when `requiresManifestVersionLookup` is set, in which case the + * cooldown is `0` and we just echo the version back. + * @param spec The tag, range, or version requested by the caller. + * @param minReleaseAgeMs The cooldown in milliseconds. `0` disables filtering. + * @returns A concrete version string, or `null` when no version qualifies. + */ + #selectVersion( + metadata: PackageMetadata, + type: 'tag' | 'range' | 'version', + spec: string, + minReleaseAgeMs: number, + ): string | null { + let resolvedFromTag = false; + + // Resolve tags up front (e.g. `latest` -> `21.4.0`). + if (type === 'tag' || !spec) { + const resolved = metadata['dist-tags']?.[spec || 'latest']; + if (!resolved) { + return null; + } + spec = resolved; + resolvedFromTag = true; + type = 'version'; + } + + if (minReleaseAgeMs <= 0) { + // No cooldown: keep the original behavior. + return type === 'range' ? (maxSatisfying(metadata.versions, spec) ?? null) : spec; + } + + const cutoff = Date.now() - minReleaseAgeMs; + const time = metadata.time; + const isOldEnough = (version: string): boolean => { + const publishedAt = time?.[version]; + if (!publishedAt) { + // If the registry didn't provide a timestamp, err on the side of allowing + // the version. The package manager itself remains the ultimate gate. + return true; + } + const publishedMs = Date.parse(publishedAt); + + return Number.isNaN(publishedMs) ? true : publishedMs <= cutoff; + }; + + // Pass `includePrerelease` so prereleases are eligible. This matters when + // the resolved tag points at a prerelease (e.g. `next`); otherwise the + // fallback would silently downgrade to a stable release. + const semverOptions = { includePrerelease: true }; + + if (type === 'range') { + const eligible = metadata.versions.filter(isOldEnough); + + return maxSatisfying(eligible, spec, semverOptions) ?? null; + } + + if (isOldEnough(spec)) { + return spec; + } + + if (resolvedFromTag) { + // The tag pointed at a too-new version. Fall back to the newest version + // that satisfies the cooldown so the command can still complete. + const eligible = metadata.versions.filter(isOldEnough); + + return maxSatisfying(eligible, '*', semverOptions) ?? null; + } + + // Should not be reachable: callers no longer pass an explicit `version` + // with a cooldown configured. Returning `null` is the safe default. + return null; + } + + /** + * Lazily reads the configured minimum release age (in milliseconds) for + * the active package manager. + */ + async #getMinReleaseAgeMs(): Promise { + if (this.#minReleaseAgeMs === undefined) { + try { + this.#minReleaseAgeMs = await getMinReleaseAgeMs( + this.host, + this.cwd, + this.descriptor, + this.options.logger, + ); + } catch { + this.#minReleaseAgeMs = 0; + } + } + + return this.#minReleaseAgeMs; + } + private async getTemporaryDirectory(): Promise { const { tempDirectory } = this.options; diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 8d439d9b3b75..84c10285a134 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -113,4 +113,162 @@ describe('PackageManager', () => { expect(manifest).toEqual({ name: 'foo', version: '1.0.0' }); }); }); + + describe('getManifest with min-release-age', () => { + const MS_PER_DAY = 86_400_000; + let now: number; + + beforeEach(() => { + now = Date.parse('2026-05-15T12:00:00.000Z'); + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(now)); + + // Stub stat so the cooldown reader sees `/tmp` itself as the repo root. + spyOn(host, 'stat').and.callFake((path: string) => { + if (path === '/tmp/.git') { + return Promise.resolve({ isDirectory: () => true } as never); + } + + return Promise.reject(new Error('not found')); + }); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + /** + * Returns the metadata stdout the npm `view` parser expects, with one + * version that is "old enough" and one that is too new for a cooldown + * of 7 days. + */ + function mockMetadataResponse(): void { + const metadata = { + name: 'foo', + 'dist-tags': { latest: '21.4.0' }, + versions: ['21.3.0', '21.4.0'], + time: { + // 21.3.0: 30 days ago (always old enough) + '21.3.0': new Date(now - 30 * MS_PER_DAY).toISOString(), + // 21.4.0: 1 day ago (younger than the 7-day cooldown) + '21.4.0': new Date(now - 1 * MS_PER_DAY).toISOString(), + }, + }; + + runCommandSpy.and.callFake((_binary: string, args: readonly string[]) => { + // Metadata requests are scoped to the package without a version (`foo`). + // Manifest requests use a versioned specifier (`foo@`). + const versionedSpec = args.find((a) => /^foo@\S+/.test(a)); + if (!versionedSpec) { + return Promise.resolve({ stdout: JSON.stringify(metadata), stderr: '' }); + } + + const requestedVersion = /^foo@(\S+)/.exec(versionedSpec)?.[1] ?? ''; + + return Promise.resolve({ + stdout: JSON.stringify({ name: 'foo', version: requestedVersion }), + stderr: '', + }); + }); + } + + it('honors a cooldown configured in .npmrc when resolving the `latest` tag', async () => { + spyOn(host, 'readFile').and.callFake((path: string) => { + if (path === '/tmp/.npmrc') { + return Promise.resolve('min-release-age=7\n'); + } + + return Promise.reject(new Error('not found')); + }); + mockMetadataResponse(); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@latest'); + + expect(manifest?.version).toBe('21.3.0'); + }); + + it('does not fetch metadata for an explicit version even when a cooldown is configured', async () => { + spyOn(host, 'readFile').and.callFake((path: string) => { + if (path === '/tmp/.npmrc') { + return Promise.resolve('min-release-age=7\n'); + } + + return Promise.reject(new Error('not found')); + }); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '21.4.0' }), + stderr: '', + }); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@21.4.0'); + + // Hit `getRegistryManifest` directly. The package manager enforces the + // cooldown at install time; the CLI should not second-guess an explicit + // version, since that would block legitimate use cases like pinning. + expect(manifest?.version).toBe('21.4.0'); + expect(runCommandSpy).toHaveBeenCalledTimes(1); + }); + + it('does not call `getRegistryMetadata` when no cooldown is configured', async () => { + spyOn(host, 'readFile').and.rejectWith(new Error('no .npmrc')); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '21.4.0' }), + stderr: '', + }); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@latest'); + + expect(manifest?.version).toBe('21.4.0'); + // A single call: directly to `getRegistryManifest`. No metadata lookup. + expect(runCommandSpy).toHaveBeenCalledTimes(1); + }); + + it('keeps prereleases eligible when falling back from a prerelease tag', async () => { + spyOn(host, 'readFile').and.callFake((path: string) => { + if (path === '/tmp/.npmrc') { + return Promise.resolve('min-release-age=7\n'); + } + + return Promise.reject(new Error('not found')); + }); + + const metadata = { + name: 'foo', + 'dist-tags': { next: '22.0.0-next.5', latest: '21.3.0' }, + versions: ['21.3.0', '22.0.0-next.4', '22.0.0-next.5'], + time: { + // Stable older release. + '21.3.0': new Date(now - 30 * MS_PER_DAY).toISOString(), + // Older prerelease, eligible. + '22.0.0-next.4': new Date(now - 15 * MS_PER_DAY).toISOString(), + // Newest prerelease, blocked by the cooldown. + '22.0.0-next.5': new Date(now - 1 * MS_PER_DAY).toISOString(), + }, + }; + + runCommandSpy.and.callFake((_binary: string, args: readonly string[]) => { + const versionedSpec = args.find((a) => /^foo@\S+/.test(a)); + if (!versionedSpec) { + return Promise.resolve({ stdout: JSON.stringify(metadata), stderr: '' }); + } + + const requestedVersion = /^foo@(\S+)/.exec(versionedSpec)?.[1] ?? ''; + + return Promise.resolve({ + stdout: JSON.stringify({ name: 'foo', version: requestedVersion }), + stderr: '', + }); + }); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = await pm.getManifest('foo@next'); + + // Should pick the older prerelease, not silently downgrade to a stable + // version that the user never asked for. + expect(manifest?.version).toBe('22.0.0-next.4'); + }); + }); }); diff --git a/packages/angular/cli/src/package-managers/testing/mock-host.ts b/packages/angular/cli/src/package-managers/testing/mock-host.ts index 46e71be3cf60..9b76846884b9 100644 --- a/packages/angular/cli/src/package-managers/testing/mock-host.ts +++ b/packages/angular/cli/src/package-managers/testing/mock-host.ts @@ -16,6 +16,7 @@ import { Host } from '../host'; export class MockHost implements Host { readonly requiresQuoting = false; private readonly fs = new Map(); + private readonly fileContents = new Map(); constructor(files: Record = {}) { // Normalize paths to use forward slashes for consistency in tests. @@ -33,6 +34,18 @@ export class MockHost implements Host { } } + /** Registers a file with text content for later retrieval via `readFile`. */ + setFile(path: string, content: string): void { + const normalized = path.replace(/\\/g, '/'); + this.fs.set(normalized, []); + this.fileContents.set(normalized, content); + } + + /** Marks a path as a directory. */ + setDirectory(path: string): void { + this.fs.set(path.replace(/\\/g, '/'), true); + } + mkdir(path: string, options?: { recursive?: boolean }): Promise { throw new Error('Method not implemented.'); } @@ -67,8 +80,14 @@ export class MockHost implements Host { throw new Error('Method not implemented.'); } - readFile(): Promise { - throw new Error('Method not implemented.'); + readFile(path: string): Promise { + const normalized = path.replace(/\\/g, '/'); + const contents = this.fileContents.get(normalized); + if (contents === undefined) { + return Promise.reject(new Error(`File not found: ${path}`)); + } + + return Promise.resolve(contents); } copyFile(): Promise {