import fs from 'fs'; import path from 'path'; import semver from 'semver'; import { UsageError } from 'clipanion'; import Enquirer from 'enquirer'; import * as miscUtils from './miscUtils'; import {SupportedPackageManagers, SupportedPackageManagerSet, Descriptor, Locator} from './types'; /** * Locates the active project's package manager specification. * * If the specification exists but doesn't match the active package manager, * an error is thrown to prevent users from using the wrong package manager, * which would lead to inconsistent project layouts. * * If the project doesn't include a specification file, we just assume that * whatever the user uses is exactly what they want to use. Since the version * isn't explicited, we fallback on known good versions. * * Finally, if the project doesn't exist at all, we ask the user whether they * want to create one in the current project. If they do, we initialize a new * project using the default package managers, and configure it so that we * don't need to ask again in the future. */ export async function findProjectSpec(initialCwd: string, locator: Locator): Promise { while (true) { const result = await loadSpec(initialCwd); switch (result.type) { case `NoProject`: { await initProjectAndSpec(result.target, locator); } break; case `NoSpec`: { // A locator is a valid descriptor (but not the other way around) return {name: locator.name, range: locator.reference}; } break; case `Found`: { if (result.spec.name !== locator.name) { throw new UsageError(`This project is configured to use ${result.spec.name}`); } else { return result.spec; } } break; } } } type LoadSpecResult = | {type: `NoProject`, target: string} | {type: `NoSpec`, target: string} | {type: `Found`, spec: Descriptor}; async function loadSpec(initialCwd: string): Promise { let nextCwd = initialCwd; let currCwd = ``; let selection: any = null; while (nextCwd !== currCwd && selection === null) { currCwd = nextCwd; nextCwd = path.dirname(currCwd); const manifestPath = path.join(currCwd, `package.json`); if (!fs.existsSync(manifestPath)) continue; const content = await fs.promises.readFile(manifestPath, `utf8`); let data; try { data = JSON.parse(content); } catch {} if (typeof data !== `object` || data === null) throw new UsageError(`Invalid package.json in ${path.relative(initialCwd, manifestPath)}`); selection = {data, manifestPath}; } if (selection === null) return {type: `NoProject`, target: path.join(initialCwd, `package.json`)}; const engines = selection.data.engines; if (typeof engines === `undefined`) return {type: `NoSpec`, target: selection.manifestPath}; if (typeof engines !== `object` || engines === null) throw new UsageError(`Invalid 'engines' field in ${path.relative(initialCwd, selection.manifestPath)}; expected an object`); const pmSpec = engines.pm; if (typeof pmSpec === `undefined`) return {type: `NoSpec`, target: selection.manifestPath}; if (typeof pmSpec !== `string`) throw new UsageError(`Invalid package manager specification in ${path.relative(initialCwd, selection.manifestPath)}; expected a semver range`); const match = pmSpec.match(/^(?!_)(.+)@(.+)$/); if (match === null || !semver.validRange(match[2])) throw new UsageError(`Invalid package manager specification in ${path.relative(initialCwd, selection.manifestPath)}; expected a semver range`); if (!SupportedPackageManagerSet.has(match[1])) throw new UsageError(`Unsupported package manager specification (${match})`); return { type: `Found`, spec: { name: match[1] as SupportedPackageManagers, range: match[2], }, }; } export async function persistPmSpec(updateTarget: string, locator: Locator, message: string) { const newSpec = `${locator.name}@^${locator.reference}`; let res: boolean; try { res = await Enquirer.prompt([{ type: `confirm`, name: `confirm`, initial: true, message: message.replace(`{}`, newSpec), }]); } catch (err) { if (err === ``) { res = false; } else { throw err; } } if (!res) throw new miscUtils.Cancellation(); const content = fs.existsSync(updateTarget) ? await fs.promises.readFile(updateTarget, `utf8`) : `{}`; const data = JSON.parse(content); data.engines = data.engines || {}; data.engines.pm = newSpec; const serialized = JSON.stringify(data, null, 2); await fs.promises.writeFile(updateTarget, `${serialized}\n`); } export async function initProjectAndSpec(updateTarget: string, locator: Locator) { return await persistPmSpec(updateTarget, locator, `No configured project yet; set it to {}?`); } export async function initSpec(updateTarget: string, locator: Locator) { return await persistPmSpec(updateTarget, locator, `No configured local package manager yet; set it to {}?`); }