import { Command } from "clipanion"; import { Filename, npath, ppath, xfs } from "@yarnpkg/fslib"; import { getPnpPath } from "@yarnpkg/plugin-pnp"; import { Cache, CommandContext, Configuration, Hooks, LocatorHash, Plugin, Project, Report, StreamReport, structUtils, } from "@yarnpkg/core"; import binTmpl from "./bin-wrapper.sh.in"; import defaultExprTmpl from "./default.nix.in"; import projectExprTmpl from "./yarn-project.nix.in"; // Generator function that runs after `yarn install`. const generate = async (project: Project, cache: Cache, report: Report) => { const { configuration, cwd } = project; const yarnPathAbs = configuration.get(`yarnPath`); const cacheFolderAbs = configuration.get(`cacheFolder`); const lockfileFilename = configuration.get(`lockfileFilename`); // TODO: Should try to remove this. Our binary wrappers currently do // `node -r .pnp.js `, but not even sure if that's supported. if (configuration.get(`nodeLinker`) !== `pnp`) { report.reportWarning( 0, `Currently, yarn-plugin-nixify only supports 'pnp' for the 'nodeLinker' setting.` ); return; } let yarnPath = ppath.relative(cwd, yarnPathAbs); if (yarnPath.startsWith(`../`)) { yarnPath = yarnPathAbs; report.reportWarning( 0, `The Yarn path ${yarnPathAbs} is outside the project - it may not be reachable by the Nix build` ); } let cacheFolder = ppath.relative(cwd, cacheFolderAbs); if (cacheFolder.startsWith(`../`)) { cacheFolder = cacheFolderAbs; report.reportWarning( 0, `The cache folder ${cacheFolderAbs} is outside the project - it may not be reachable by the Nix build` ); } // List files the derivation will depend on. const yarnClosureInput = new Set([ `package.json`, lockfileFilename, yarnPath, ]); for (const source of configuration.sources.values()) { if (!source.startsWith(`<`)) { yarnClosureInput.add(source); } } // TODO: Better way to find plugins? Re-parse rcfiles maybe? const pluginsDir = ppath.join(cwd, `.yarn/plugins` as Filename); if (xfs.existsSync(pluginsDir)) { for (const filename of xfs.readdirSync(pluginsDir)) { yarnClosureInput.add(ppath.join(pluginsDir, filename)); } } // Build Nix `filterSource` entries to match on. const yarnClosureEntries = new Set(); for (const inputPath of yarnClosureInput) { // Filter paths not reachable during the build and warn. (These are often // just user global configuration files, but the warnings can help highlight // dependencies on private registries.) const relativePath = ppath.relative(cwd, inputPath); if (relativePath.startsWith(`../`)) { if (inputPath !== yarnPath) { report.reportWarning( 0, `The path ${inputPath} is outside the project and was ignored - it may not be reachable in the Nix build` ); } continue; } // Add the file itself. yarnClosureEntries.add(`regular:${relativePath}`); // Add directories leading up to the file. let dir = ppath.dirname(relativePath); while (dir !== `.`) { yarnClosureEntries.add(`directory:${dir}`); dir = ppath.dirname(dir); } } // Build a list of cache entries so Nix can fetch them. let cacheEntries = []; const cacheFiles = new Set(xfs.readdirSync(cache.cwd)); for (const pkg of project.storedPackages.values()) { const checksum = project.storedChecksums.get(pkg.locatorHash); if (!checksum) continue; const cachePath = cache.getLocatorPath(pkg, checksum); if (!cachePath) continue; const filename = ppath.basename(cachePath); if (!cacheFiles.has(filename)) continue; const sha512 = checksum.split(`/`).pop(); cacheEntries.push([ `filename = ${JSON.stringify(filename)};`, `sha512 = ${JSON.stringify(sha512)};`, `locator-hash = ${JSON.stringify(pkg.locatorHash)};`, ]); } // Render the Nix expression. const ident = project.topLevelWorkspace.manifest.name; const projectName = ident ? structUtils.stringifyIdent(ident) : `workspace`; const projectExpr = projectExprTmpl .replace(`@@PROJECT_NAME@@`, JSON.stringify(projectName)) .replace(`@@CACHE_FOLDER@@`, JSON.stringify(cacheFolder)) .replace( `@@CACHE_ENTRIES@@`, `[\n` + [...cacheEntries] .map((entry) => ` { ${entry.join(` `)} }\n`) .join(``) + ` ]` ) .replace(`@@YARN_PATH@@`, JSON.stringify(yarnPath)) .replace( `@@YARN_CLOSURE_ENTRIES@@`, `[\n` + [...yarnClosureEntries] .map((entry) => ` ${JSON.stringify(entry)}\n`) .join(``) + ` ]` ); const projectExprPath = ppath.join(cwd, `yarn-project.nix` as Filename); xfs.writeFileSync(projectExprPath, projectExpr); // Create a wrapper if it does not exist yet. const defaultExprPath = ppath.join(cwd, `default.nix` as Filename); if (!xfs.existsSync(defaultExprPath)) { xfs.writeFileSync(defaultExprPath, defaultExprTmpl); report.reportInfo( 0, `A minimal default.nix was created. You may want to customize it.` ); } }; // Internal command that fetches a single locator. // Used from within Nix to build the cache for the project. class FetchOneCommand extends Command { @Command.String() locatorHash: string = ``; @Command.Path(`nixify`, `fetch-one`) async execute() { const configuration = await Configuration.find( this.context.cwd, this.context.plugins ); const { project } = await Project.find(configuration, this.context.cwd); const cache = await Cache.find(configuration); const fetcher = configuration.makeFetcher(); const report = await StreamReport.start( { configuration, stdout: this.context.stdout }, async (report) => { const pkg = project.originalPackages.get( this.locatorHash as LocatorHash ); if (!pkg) { report.reportError(0, `Invalid locator hash`); return; } await fetcher.fetch(pkg, { checksums: project.storedChecksums, project, cache, fetcher, report, }); } ); return report.exitCode(); } } // Internal command that creates wrappers for binaries. // Used inside the Nix install phase. class InstallBinCommand extends Command { @Command.String() binDir: string = ``; @Command.Path(`nixify`, `install-bin`) async execute() { const configuration = await Configuration.find( this.context.cwd, this.context.plugins ); const { project, workspace } = await Project.find( configuration, this.context.cwd ); if (workspace) { const binDir = npath.toPortablePath(this.binDir); const pnpPath = getPnpPath(project).main; for (const [name, scriptInput] of workspace.manifest.bin) { const binPath = ppath.join(binDir, name as Filename); const scriptPath = ppath.join( project.cwd, npath.toPortablePath(scriptInput) ); const script = binTmpl .replace(`@@PNP_PATH@@`, pnpPath) .replace(`@@SCRIPT_PATH@@`, scriptPath); xfs.writeFileSync(binPath, script); xfs.chmodSync(binPath, 0o755); } } } } const plugin: Plugin = { commands: [FetchOneCommand, InstallBinCommand], hooks: { afterAllInstalled: async (project, opts) => { if (opts.persistProject !== false) { await generate(project, opts.cache, opts.report); } }, }, }; export default plugin;