mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 01:45:30 -04:00
refactor: rename the agent client and agent setting to pnpr (#12155)
* refactor: rename the agent client + setting to pnpr The pnpm-side client and its config setting still carried the old "agent" name after the server moved to pnpr. Align both with pnpr (and with pacquet, which already uses `pnprServer`): - Move `agent/client` → `pnpr/client` and rename the package `@pnpm/agent.client` → `@pnpm/pnpr.client` (exported `AgentProject` type → `PnprProject`). - Rename the config setting `agent` → `pnprServer` (`--pnpr-server` CLI flag), matching pacquet's setting name. - Rename the internal install-path symbols and the user-facing log / error strings that mentioned "pnpm agent" to "pnpr". No behavioral change — only names. The e2e suite now drives `--config.pnprServer`. * fix: forward optionalDependencies to the pnpr server `PnprProject` and the install-request body only carried `dependencies` and `devDependencies`, so a project's `optionalDependencies` were dropped on the way to the pnpr server — it resolved as if they didn't exist, producing a different lockfile than the local resolver. Thread `optionalDependencies` through the client request shape, the deps-installer single-project and workspace request builders, and the pnpr server (`InstallRequestProject` / `InstallRequest` + the throwaway manifest it writes for resolution). Adds an e2e case asserting an optional dependency is resolved through `pnprServer`.
This commit is contained in:
7
.changeset/pnpr-forward-optional-deps.md
Normal file
7
.changeset/pnpr-forward-optional-deps.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@pnpm/pnpr.client": patch
|
||||
"@pnpm/installing.deps-installer": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Fixed `optionalDependencies` being dropped when resolving through a `pnprServer`. The pnpr request now carries each project's optional dependencies (for both single-project and workspace installs), so the server resolves them like the local resolver does instead of producing a lockfile as if they did not exist.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
"@pnpm/agent.client": minor
|
||||
"@pnpm/pnpr.client": minor
|
||||
"@pnpm/installing.deps-installer": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
`pnpm install --lockfile-only` (and the `lockfileOnly` setting) is now honored when an agent / `pnprServer` is configured. The agent path resolves and writes `pnpm-lock.yaml` but fetches no files into the store and links no `node_modules`, matching the local lockfile-only behavior. The client ignores any file/index lines an older agent server still streams, so the store stays untouched even against a server that predates the resolve-only mode [#12146](https://github.com/pnpm/pnpm/issues/12146).
|
||||
`pnpm install --lockfile-only` (and the `lockfileOnly` setting) is now honored when a `pnprServer` is configured. The pnpr path resolves and writes `pnpm-lock.yaml` but fetches no files into the store and links no `node_modules`, matching the local lockfile-only behavior. The client ignores any file/index lines an older pnpr server still streams, so the store stays untouched even against a server that predates the resolve-only mode [#12146](https://github.com/pnpm/pnpm/issues/12146).
|
||||
|
||||
10
.changeset/rename-agent-to-pnpr-server.md
Normal file
10
.changeset/rename-agent-to-pnpr-server.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@pnpm/pnpr.client": minor
|
||||
"@pnpm/config.reader": minor
|
||||
"@pnpm/types": minor
|
||||
"@pnpm/installing.deps-installer": patch
|
||||
"@pnpm/installing.commands": patch
|
||||
"pnpm": minor
|
||||
---
|
||||
|
||||
Renamed the experimental `agent` setting to `pnprServer` so the pnpm CLI matches the same setting name pacquet uses for offloading resolution to a [pnpr](https://github.com/pnpm/pnpm/tree/main/pnpr) server. Point pnpm at a pnpr server with `pnprServer: <url>` in `pnpm-workspace.yaml` (or `--pnpr-server <url>`); the previous `agent` / `--agent` name no longer works. The client package was likewise renamed from `@pnpm/agent.client` to `@pnpm/pnpr.client`.
|
||||
@@ -18,7 +18,7 @@ const CLI_PKG_NAME = 'pnpm'
|
||||
// Experimental packages that are versioned independently on the 0.0.x track
|
||||
// and should not be normalized to the pnpm major version.
|
||||
const EXPERIMENTAL_PKGS = new Set([
|
||||
'@pnpm/agent.client',
|
||||
'@pnpm/pnpr.client',
|
||||
])
|
||||
|
||||
// Files that must be packed with mode 0755 in both `pnpm` and `@pnpm/exe`.
|
||||
|
||||
@@ -223,7 +223,7 @@ export interface Config extends OptionsFromRootManifest {
|
||||
packGzipLevel?: number
|
||||
blockExoticSubdeps?: boolean
|
||||
|
||||
agent?: string
|
||||
pnprServer?: string
|
||||
|
||||
registries: Registries
|
||||
namedRegistries?: Record<string, string>
|
||||
|
||||
@@ -8,7 +8,6 @@ type PnpmKey = keyof typeof pnpmTypes
|
||||
* Keys from {@link pnpmTypes} that are valid fields in a global config file.
|
||||
*/
|
||||
export const pnpmConfigFileKeys = [
|
||||
'agent',
|
||||
'bail',
|
||||
'ci',
|
||||
'color',
|
||||
@@ -47,6 +46,7 @@ export const pnpmConfigFileKeys = [
|
||||
'npm-path',
|
||||
'npmrc-auth-file',
|
||||
'package-import-method',
|
||||
'pnpr-server',
|
||||
'prefer-frozen-lockfile',
|
||||
'prefer-offline',
|
||||
'prefer-symlinked-executables',
|
||||
|
||||
@@ -23,7 +23,7 @@ export type OptionsFromRootManifest = {
|
||||
supportedArchitectures?: SupportedArchitectures
|
||||
allowBuilds?: Record<string, boolean | string>
|
||||
requiredScripts?: string[]
|
||||
} & Pick<PnpmSettings, 'configDependencies' | 'auditConfig' | 'agent' | 'updateConfig'>
|
||||
} & Pick<PnpmSettings, 'configDependencies' | 'auditConfig' | 'pnprServer' | 'updateConfig'>
|
||||
|
||||
export function getOptionsFromPnpmSettings (manifestDir: string | undefined, pnpmSettings: PnpmSettings, manifest?: ProjectManifest): OptionsFromRootManifest {
|
||||
const settings: OptionsFromRootManifest = replaceEnvInSettings(pnpmSettings)
|
||||
|
||||
@@ -142,7 +142,7 @@ export const pnpmTypes = {
|
||||
'embed-readme': Boolean,
|
||||
'skip-manifest-obfuscation': Boolean,
|
||||
'update-notifier': Boolean,
|
||||
'agent': [null, String],
|
||||
'pnpr-server': [null, String],
|
||||
'registry-supports-time-field': Boolean,
|
||||
'fail-if-no-match': Boolean,
|
||||
'sync-injected-deps-after-scripts': Array,
|
||||
|
||||
@@ -200,7 +200,7 @@ export interface PnpmSettings {
|
||||
httpProxy?: string
|
||||
httpsProxy?: string
|
||||
noProxy?: string | boolean
|
||||
agent?: string
|
||||
pnprServer?: string
|
||||
}
|
||||
|
||||
export interface ProjectManifest extends BaseManifest {
|
||||
|
||||
@@ -48,8 +48,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
|
||||
'node-linker',
|
||||
'noproxy',
|
||||
'package-import-method',
|
||||
'agent',
|
||||
'pnpmfile',
|
||||
'pnpr-server',
|
||||
'prefer-frozen-lockfile',
|
||||
'prefer-offline',
|
||||
'production',
|
||||
@@ -350,7 +350,7 @@ export type InstallCommandOptions = Pick<Config,
|
||||
| 'updateConfig'
|
||||
| 'overrides'
|
||||
| 'packageExtensions'
|
||||
| 'agent'
|
||||
| 'pnprServer'
|
||||
| 'supportedArchitectures'
|
||||
| 'packageConfigs'
|
||||
> & Pick<ConfigContext,
|
||||
|
||||
@@ -82,7 +82,7 @@ export type InstallDepsOptions = Pick<Config,
|
||||
| 'linkWorkspacePackages'
|
||||
| 'lockfileDir'
|
||||
| 'lockfileOnly'
|
||||
| 'agent'
|
||||
| 'pnprServer'
|
||||
| 'production'
|
||||
| 'preferWorkspacePackages'
|
||||
| 'registries'
|
||||
|
||||
@@ -73,7 +73,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
|
||||
| 'lockfileDir'
|
||||
| 'lockfileOnly'
|
||||
| 'modulesDir'
|
||||
| 'agent'
|
||||
| 'pnprServer'
|
||||
| 'allowBuilds'
|
||||
| 'registries'
|
||||
| 'runtime'
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/prompts": "catalog:",
|
||||
"@pnpm/agent.client": "workspace:*",
|
||||
"@pnpm/bins.linker": "workspace:*",
|
||||
"@pnpm/bins.remover": "workspace:*",
|
||||
"@pnpm/building.after-install": "workspace:*",
|
||||
@@ -102,6 +101,7 @@
|
||||
"@pnpm/npm-package-arg": "catalog:",
|
||||
"@pnpm/patching.config": "workspace:*",
|
||||
"@pnpm/pkg-manifest.utils": "workspace:*",
|
||||
"@pnpm/pnpr.client": "workspace:*",
|
||||
"@pnpm/resolving.parse-wanted-dependency": "workspace:*",
|
||||
"@pnpm/resolving.resolver-base": "workspace:*",
|
||||
"@pnpm/store.controller-types": "workspace:*",
|
||||
|
||||
@@ -257,11 +257,10 @@ export interface StrictInstallOptions {
|
||||
*/
|
||||
omitSummaryLog: boolean
|
||||
/**
|
||||
* URL of an agent server that resolves dependencies server-side and serves
|
||||
* only the files missing from the client's store. The `pnpr` server
|
||||
* implements this protocol.
|
||||
* URL of a pnpr server that resolves dependencies server-side and serves
|
||||
* only the files missing from the client's store.
|
||||
*/
|
||||
agent?: string
|
||||
pnprServer?: string
|
||||
}
|
||||
|
||||
export type InstallOptions =
|
||||
|
||||
@@ -174,9 +174,9 @@ export async function install (
|
||||
): Promise<InstallResult> {
|
||||
const rootDir = (opts.dir ?? process.cwd()) as ProjectRootDir
|
||||
|
||||
// When a pnpm agent is configured, use server-side resolution
|
||||
// When a pnpr server is configured, use server-side resolution
|
||||
// instead of the normal resolution flow.
|
||||
if (opts.agent) {
|
||||
if (opts.pnprServer) {
|
||||
return installFromPnpmRegistry(manifest, rootDir, opts)
|
||||
}
|
||||
|
||||
@@ -305,13 +305,13 @@ export async function mutateModules (
|
||||
|
||||
const opts = extendOptions(maybeOpts)
|
||||
|
||||
// When a pnpm agent is configured, use server-side resolution. The agent
|
||||
// When a pnpr server is configured, use server-side resolution. The pnpr server
|
||||
// path supports `install`, `installSome` (pnpm add), and `uninstallSome`
|
||||
// (pnpm remove). Mutations that need full client-side resolution (update
|
||||
// flags) still fall through to the normal flow.
|
||||
if (opts.agent && canUseAgentForMutations(projects)) {
|
||||
const agentResult = await mutateModulesViaAgent(projects, opts)
|
||||
if (agentResult) return agentResult
|
||||
if (opts.pnprServer && canUsePnprForMutations(projects)) {
|
||||
const pnprResult = await mutateModulesViaPnpr(projects, opts)
|
||||
if (pnprResult) return pnprResult
|
||||
}
|
||||
|
||||
const allowBuild = createAllowBuildFunction(opts)
|
||||
@@ -1869,7 +1869,7 @@ function allMutationsAreInstalls (projects: MutatedProject[]): boolean {
|
||||
* Run the pacquet binary if it's configured, otherwise run the JS
|
||||
* `headlessInstall`. Callers can hand off any code path that materializes
|
||||
* an already-resolved lockfile (workspace partial install, hoisted
|
||||
* linker, agent-server install, frozen install) without restating the
|
||||
* linker, pnpr server install, frozen install) without restating the
|
||||
* delegation choice.
|
||||
*
|
||||
* Pacquet reads the wanted lockfile from disk and produces its own
|
||||
@@ -1886,7 +1886,7 @@ async function materializeOrDelegate (
|
||||
): Promise<{ stats?: InstallationResultStats, ignoredBuilds?: IgnoredBuilds }> {
|
||||
if (opts.runPacquet != null) {
|
||||
// Reached only from the resolve-then-materialize call sites
|
||||
// (workspace-partial, hoisted-linker, agent install). Each ran a
|
||||
// (workspace-partial, hoisted-linker, pnpr server install). Each ran a
|
||||
// lockfileOnly resolve pass that emitted one
|
||||
// `pnpm:progress status:resolved` per package, so pacquet's
|
||||
// duplicate `resolved` events would double the reporter's count.
|
||||
@@ -2072,13 +2072,13 @@ function getProjectsWithTargetDirs<T extends { id: ProjectId }> (
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent path can handle this batch of mutations. The agent flow
|
||||
* Whether the pnpr server path can handle this batch of mutations. The pnpr server flow
|
||||
* supports installing the manifest as-is (`install`), adding new deps
|
||||
* (`installSome`), and removing deps (`uninstallSome`). It cannot model the
|
||||
* client-side update-flag behavior (`update`/`updateMatching`/`updateToLatest`)
|
||||
* yet, so those still go through the normal client-side resolver.
|
||||
*/
|
||||
function canUseAgentForMutations (projects: MutatedProject[]): boolean {
|
||||
function canUsePnprForMutations (projects: MutatedProject[]): boolean {
|
||||
if (projects.length === 0) return false
|
||||
return projects.every((p) => {
|
||||
if (p.mutation === 'uninstallSome') return true
|
||||
@@ -2088,52 +2088,52 @@ function canUseAgentForMutations (projects: MutatedProject[]): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
interface AgentNewDep {
|
||||
interface PnprNewDep {
|
||||
alias: string
|
||||
/**
|
||||
* Whether the user specified a spec (e.g. `pnpm add foo@^2`). If true, the
|
||||
* manifest already has the right value and we must preserve it. If false
|
||||
* we merged in `'latest'` and need to compute a save-prefix spec from the
|
||||
* resolved version in the lockfile after the agent runs.
|
||||
* resolved version in the lockfile after the pnpr server runs.
|
||||
*/
|
||||
userSpecified: boolean
|
||||
}
|
||||
|
||||
interface AgentInstallProject {
|
||||
interface PnprInstallProject {
|
||||
rootDir: ProjectRootDir
|
||||
/** The (possibly pre-processed) manifest we send to the agent. */
|
||||
/** The (possibly pre-processed) manifest we send to the pnpr server. */
|
||||
manifest: ProjectManifest
|
||||
mutation: MutatedProject['mutation']
|
||||
/** Newly added deps from an `installSome` mutation. Empty otherwise. */
|
||||
newDeps: AgentNewDep[]
|
||||
newDeps: PnprNewDep[]
|
||||
/** Save-prefix config for `installSome`; applied to deps whose spec defaulted to `'latest'`. */
|
||||
pinnedVersion?: PinnedVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-process projects for the agent flow:
|
||||
* Pre-process projects for the pnpr server flow:
|
||||
* - `install`: send the manifest as-is.
|
||||
* - `uninstallSome`: drop the named deps from the manifest before sending,
|
||||
* so the agent's resolution naturally produces a lockfile without them.
|
||||
* so the pnpr server's resolution naturally produces a lockfile without them.
|
||||
* - `installSome`: parse selectors and merge them into the manifest. The
|
||||
* agent server then resolves the merged manifest, and we read the resolved
|
||||
* pnpr server then resolves the merged manifest, and we read the resolved
|
||||
* specifiers (with the right save-prefix applied server-side) back from
|
||||
* the lockfile importer entries to update the client-side manifest.
|
||||
*
|
||||
* Returns null if the projects don't map cleanly to allProjects (caller
|
||||
* should fall through to the normal flow).
|
||||
*/
|
||||
async function prepareAgentProjects (
|
||||
async function preparePnprProjects (
|
||||
projects: MutatedProject[],
|
||||
opts: MutateModulesOptions
|
||||
): Promise<AgentInstallProject[] | null> {
|
||||
): Promise<PnprInstallProject[] | null> {
|
||||
const allProjects = opts.allProjects ?? []
|
||||
const mutationByRootDir = new Map<ProjectRootDir, MutatedProject>()
|
||||
for (const p of projects) {
|
||||
mutationByRootDir.set(p.rootDir, p)
|
||||
}
|
||||
// Include every workspace project, not just the mutated ones — otherwise
|
||||
// the agent's resulting lockfile would only contain the targeted importer
|
||||
// the pnpr server's resulting lockfile would only contain the targeted importer
|
||||
// and `headlessInstall` (or a later install) would crash on the missing
|
||||
// entries for the other workspace projects. Projects without a mutation
|
||||
// are sent with their current manifest (no-op for resolution).
|
||||
@@ -2159,7 +2159,7 @@ async function prepareAgentProjects (
|
||||
}
|
||||
return Promise.all(targetSet.map(async (t) => {
|
||||
let manifest: ProjectManifest = clone(t.manifest)
|
||||
const newDeps: AgentNewDep[] = []
|
||||
const newDeps: PnprNewDep[] = []
|
||||
const mutation = t.mutation
|
||||
let pinnedVersion: PinnedVersion | undefined
|
||||
if (mutation?.mutation === 'uninstallSome') {
|
||||
@@ -2192,7 +2192,7 @@ async function prepareAgentProjects (
|
||||
* dependency field per the mutation's `targetDependenciesField` (or the
|
||||
* existing field if the dep is already in the manifest, defaulting to
|
||||
* `dependencies`). Selectors without a version use `'latest'` so the
|
||||
* agent's resolver picks the newest matching release.
|
||||
* pnpr server's resolver picks the newest matching release.
|
||||
*/
|
||||
function mergeInstallSelectors (manifest: ProjectManifest, mutation: InstallSomeDepsMutation): ProjectManifest {
|
||||
const target = mutation.targetDependenciesField
|
||||
@@ -2234,16 +2234,16 @@ function findExistingSpec (alias: string, manifest: ProjectManifest): string | u
|
||||
}
|
||||
|
||||
/**
|
||||
* After the agent resolves, copy the lockfile importer's per-dep specifier
|
||||
* After the pnpr server resolves, copy the lockfile importer's per-dep specifier
|
||||
* (which the server's resolver computed with the right save-prefix) back
|
||||
* into the client manifest for any newly added aliases. We rely on the
|
||||
* lockfile because the agent server applies catalog substitution,
|
||||
* lockfile because the pnpr server applies catalog substitution,
|
||||
* normalizedBareSpecifier, and save-prefix logic during resolution.
|
||||
*/
|
||||
function applyResolvedSpecsFromLockfile (
|
||||
manifest: ProjectManifest,
|
||||
importerSnapshot: ProjectSnapshot | undefined,
|
||||
newDeps: AgentNewDep[],
|
||||
newDeps: PnprNewDep[],
|
||||
pinnedVersion?: PinnedVersion
|
||||
): ProjectManifest {
|
||||
if (!importerSnapshot || newDeps.length === 0) return manifest
|
||||
@@ -2258,7 +2258,7 @@ function applyResolvedSpecsFromLockfile (
|
||||
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies'] as const) {
|
||||
const resolvedVersion = importerSnapshot[field]?.[dep.alias]
|
||||
if (!resolvedVersion || manifest[field]?.[dep.alias] == null) continue
|
||||
// The agent server resolved the tree but, on the plain-install path, it
|
||||
// The pnpr server resolved the tree but, on the plain-install path, it
|
||||
// writes the user's raw spec (`'latest'`) into the lockfile specifier
|
||||
// rather than normalizing to a save-prefix range. Compute the
|
||||
// save-prefix spec client-side from the resolved version.
|
||||
@@ -2270,25 +2270,25 @@ function applyResolvedSpecsFromLockfile (
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the agent path for a `mutateModules` call across one or more
|
||||
* projects. Returns null if the call can't be served by the agent (e.g. one
|
||||
* Drives the pnpr server path for a `mutateModules` call across one or more
|
||||
* projects. Returns null if the call can't be served by the pnpr server (e.g. one
|
||||
* of the projects isn't in `allProjects`).
|
||||
*/
|
||||
async function mutateModulesViaAgent (
|
||||
async function mutateModulesViaPnpr (
|
||||
projects: MutatedProject[],
|
||||
opts: MutateModulesOptions
|
||||
): Promise<MutateModulesResult | null> {
|
||||
const agentProjects = await prepareAgentProjects(projects, opts)
|
||||
if (!agentProjects) return null
|
||||
const pnprProjects = await preparePnprProjects(projects, opts)
|
||||
if (!pnprProjects) return null
|
||||
|
||||
// installFromPnpmRegistry runs the headless install for the first
|
||||
// project's root and the workspace path for the rest. Pass the
|
||||
// pre-processed manifests so resolution sees the post-mutation state.
|
||||
const result = await installFromPnpmRegistry(
|
||||
agentProjects[0].manifest,
|
||||
agentProjects[0].rootDir,
|
||||
pnprProjects[0].manifest,
|
||||
pnprProjects[0].rootDir,
|
||||
opts,
|
||||
agentProjects.map((p) => ({ rootDir: p.rootDir, manifest: p.manifest }))
|
||||
pnprProjects.map((p) => ({ rootDir: p.rootDir, manifest: p.manifest }))
|
||||
)
|
||||
|
||||
// For installSome projects, copy resolved specs from the lockfile importer
|
||||
@@ -2296,7 +2296,7 @@ async function mutateModulesViaAgent (
|
||||
// effect (the server applies these during its resolution step).
|
||||
const lockfileDir = opts.lockfileDir ?? projects[0].rootDir
|
||||
const mutatedRootDirs = new Set(projects.map((p) => p.rootDir))
|
||||
const updatedProjects = agentProjects
|
||||
const updatedProjects = pnprProjects
|
||||
.filter((p) => mutatedRootDirs.has(p.rootDir))
|
||||
.map((p) => {
|
||||
if (p.mutation === 'installSome' && p.newDeps.length > 0) {
|
||||
@@ -2317,7 +2317,7 @@ async function mutateModulesViaAgent (
|
||||
}
|
||||
|
||||
/**
|
||||
* When a pnpm agent is configured, resolve dependencies server-side
|
||||
* When a pnpr server is configured, resolve dependencies server-side
|
||||
* and download only the missing files. Then run a headless install to link
|
||||
* packages into node_modules.
|
||||
*/
|
||||
@@ -2327,22 +2327,22 @@ async function installFromPnpmRegistry (
|
||||
opts: Opts,
|
||||
allInstallProjects?: Array<{ rootDir: ProjectRootDir, manifest: ProjectManifest }>
|
||||
): Promise<InstallResult & { stats: InstallationResultStats, lockfile: LockfileObject }> {
|
||||
// The agent path skips client-side resolution, so resolver-side policies
|
||||
// The pnpr server path skips client-side resolution, so resolver-side policies
|
||||
// can't be enforced locally. `minimumReleaseAge` is forwarded to the
|
||||
// agent and enforced server-side. `trustPolicy` has no server-side
|
||||
// pnpr server and enforced server-side. `trustPolicy` has no server-side
|
||||
// counterpart yet, so refuse to run under it instead of silently
|
||||
// letting through a lockfile the local verifier would reject.
|
||||
if (opts.trustPolicy === 'no-downgrade') {
|
||||
throw new PnpmError(
|
||||
'TRUST_POLICY_INCOMPATIBLE_WITH_AGENT',
|
||||
'The pnpm agent does not yet enforce `trustPolicy: no-downgrade`, so running an install through the agent under this policy would produce a lockfile that the local verifier rejects.',
|
||||
{ hint: 'Unset `trustPolicy` for this install, or disable the agent (unset `--agent` / `agent` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' }
|
||||
'TRUST_POLICY_INCOMPATIBLE_WITH_PNPR',
|
||||
'The pnpr server does not yet enforce `trustPolicy: no-downgrade`, so running an install through it under this policy would produce a lockfile that the local verifier rejects.',
|
||||
{ hint: 'Unset `trustPolicy` for this install, or disable the pnpr server (unset `--pnpr-server` / `pnprServer` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' }
|
||||
)
|
||||
}
|
||||
const { fetchFromPnpmRegistry } = await import('@pnpm/agent.client')
|
||||
const { fetchFromPnpmRegistry } = await import('@pnpm/pnpr.client')
|
||||
const { StoreIndex } = await import('@pnpm/store.index')
|
||||
const { setImportConcurrency } = await import('@pnpm/worker')
|
||||
// Raise import concurrency for this install only — the agent path has no
|
||||
// Raise import concurrency for this install only — the pnpr server path has no
|
||||
// concurrent fetching competing for workers. Restore afterwards so we
|
||||
// don't leak a process-wide mutation to other installs (e.g. tests).
|
||||
const restoreImportConcurrency = setImportConcurrency(6)
|
||||
@@ -2351,38 +2351,40 @@ async function installFromPnpmRegistry (
|
||||
const lockfileDir = opts.lockfileDir ?? rootDir
|
||||
|
||||
// Read the existing lockfile (if any) in its on-disk shape — that's
|
||||
// what the agent protocol carries, so no conversion is needed before
|
||||
// what the pnpr server protocol carries, so no conversion is needed before
|
||||
// sending it.
|
||||
const existingLockfile = await readWantedLockfileFile(lockfileDir, {
|
||||
ignoreIncompatible: true,
|
||||
}).catch(() => null)
|
||||
|
||||
logger.info({ message: 'Resolving dependencies via pnpm agent', prefix: rootDir })
|
||||
logger.info({ message: 'Resolving dependencies via the pnpr server', prefix: rootDir })
|
||||
|
||||
// Open the store index to read integrities and write new entries.
|
||||
// Close it in a finally so a failure in fetchFromPnpmRegistry doesn't
|
||||
// leak an open SQLite handle (on Windows that also blocks store cleanup).
|
||||
const storeIndex = new StoreIndex(opts.storeDir)
|
||||
let lockfile, agentStats, fileDownloads, indexEntries
|
||||
let lockfile, pnprStats, fileDownloads, indexEntries
|
||||
try {
|
||||
// Build projects list for workspace support.
|
||||
// Normalize separators to POSIX — on Windows `path.relative` returns
|
||||
// backslashes, which the agent server rejects (it treats `\` as an
|
||||
// backslashes, which the pnpr server rejects (it treats `\` as an
|
||||
// unsafe/YAML-injection character and normalizes paths as POSIX).
|
||||
const projectsList = allInstallProjects && allInstallProjects.length > 1
|
||||
? allInstallProjects.map(p => ({
|
||||
dir: (path.relative(lockfileDir, p.rootDir) || '.').split(path.sep).join('/'),
|
||||
dependencies: p.manifest.dependencies,
|
||||
devDependencies: p.manifest.devDependencies,
|
||||
optionalDependencies: p.manifest.optionalDependencies,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
;({ lockfile, stats: agentStats, fileDownloads, indexEntries } = await fetchFromPnpmRegistry({
|
||||
registryUrl: opts.agent!,
|
||||
;({ lockfile, stats: pnprStats, fileDownloads, indexEntries } = await fetchFromPnpmRegistry({
|
||||
registryUrl: opts.pnprServer!,
|
||||
storeDir: opts.storeDir,
|
||||
storeIndex,
|
||||
dependencies: projectsList ? undefined : manifest.dependencies,
|
||||
devDependencies: projectsList ? undefined : manifest.devDependencies,
|
||||
optionalDependencies: projectsList ? undefined : manifest.optionalDependencies,
|
||||
projects: projectsList,
|
||||
overrides: opts.overrides,
|
||||
minimumReleaseAge: opts.minimumReleaseAge,
|
||||
@@ -2391,7 +2393,7 @@ async function installFromPnpmRegistry (
|
||||
}))
|
||||
|
||||
// Write store index entries so headless install finds them.
|
||||
const { writeRawIndexEntries } = await import('@pnpm/agent.client')
|
||||
const { writeRawIndexEntries } = await import('@pnpm/pnpr.client')
|
||||
writeRawIndexEntries(indexEntries, storeIndex)
|
||||
|
||||
storeIndex.checkpoint()
|
||||
@@ -2409,11 +2411,11 @@ async function installFromPnpmRegistry (
|
||||
})
|
||||
|
||||
logger.info({
|
||||
message: `Resolved ${agentStats.totalPackages} packages: ${agentStats.alreadyInStore} cached, ${agentStats.filesToDownload} files to download`,
|
||||
message: `Resolved ${pnprStats.totalPackages} packages: ${pnprStats.alreadyInStore} cached, ${pnprStats.filesToDownload} files to download`,
|
||||
prefix: rootDir,
|
||||
})
|
||||
|
||||
// `--lockfile-only`: the agent resolved and we wrote the lockfile, but
|
||||
// `--lockfile-only`: the pnpr server resolved and we wrote the lockfile, but
|
||||
// pnpm fetches nothing and links nothing in this mode — stop before the
|
||||
// headless install. See https://github.com/pnpm/pnpm/issues/12146.
|
||||
if (opts.lockfileOnly) {
|
||||
@@ -2432,8 +2434,8 @@ async function installFromPnpmRegistry (
|
||||
}
|
||||
|
||||
// Wrap fetchPackage to:
|
||||
// 1. Wait for agent file downloads before checking the store
|
||||
// 2. Skip integrity verification — files just written from the agent
|
||||
// 1. Wait for pnpr server file downloads before checking the store
|
||||
// 2. Skip integrity verification — files just written from the pnpr server
|
||||
// are guaranteed correct (server verified, no rehashing needed)
|
||||
const { readPkgFromCafs } = await import('@pnpm/worker')
|
||||
const { storeIndexKey: _storeIndexKey } = await import('@pnpm/store.index')
|
||||
@@ -2445,7 +2447,7 @@ async function installFromPnpmRegistry (
|
||||
const integrity = resolution?.integrity
|
||||
// Fall through to the regular store controller for git-hosted tarballs.
|
||||
// Their cached entry lives under gitHostedStoreIndexKey (preserves the
|
||||
// built/not-built dimension), not the integrity-keyed path the agent
|
||||
// built/not-built dimension), not the integrity-keyed path the pnpr server
|
||||
// uses for npm tarballs. See @pnpm/store.pkg-finder for the rationale.
|
||||
if (integrity && !resolution?.gitHosted) {
|
||||
const filesIndexFile = _storeIndexKey(integrity, fetchOpts.pkg.id)
|
||||
@@ -2469,7 +2471,7 @@ async function installFromPnpmRegistry (
|
||||
|
||||
const headlessOpts = {
|
||||
...opts,
|
||||
// Skip re-verifying files just written from the agent — they're
|
||||
// Skip re-verifying files just written from the pnpr server — they're
|
||||
// guaranteed correct (server verified, no rehashing needed).
|
||||
verifyStoreIntegrity: false,
|
||||
storeController: wrappedStoreController,
|
||||
@@ -2516,13 +2518,13 @@ async function installFromPnpmRegistry (
|
||||
updatedManifest: manifest,
|
||||
ignoredBuilds,
|
||||
// Pacquet doesn't surface a structured stats return; default to
|
||||
// zeros so the agent-path's non-optional `stats` slot is filled.
|
||||
// zeros so the pnpr server's non-optional `stats` slot is filled.
|
||||
// The reporter still renders accurate counts from pacquet's
|
||||
// `pnpm:stats` log events.
|
||||
stats: stats ?? { added: 0, removed: 0, linkedToRoot: 0 },
|
||||
lockfile,
|
||||
// Server-side resolution (pnpm agent) enforces `minimumReleaseAge`
|
||||
// itself — the agent picks only mature versions and the lockfile
|
||||
// Server-side resolution (pnpr server) enforces `minimumReleaseAge`
|
||||
// itself — the pnpr server picks only mature versions and the lockfile
|
||||
// can't contain immature entries to auto-collect. `trustPolicy` is
|
||||
// guarded above (we refuse to enter this path when it's set), so
|
||||
// there's nothing for the install command to react to here.
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
{
|
||||
"path": "../../__utils__/test-ipc-server"
|
||||
},
|
||||
{
|
||||
"path": "../../agent/client"
|
||||
},
|
||||
{
|
||||
"path": "../../bins/linker"
|
||||
},
|
||||
@@ -147,6 +144,9 @@
|
||||
{
|
||||
"path": "../../pkg-manifest/utils"
|
||||
},
|
||||
{
|
||||
"path": "../../pnpr/client"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/parse-wanted-dependency"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Client for pnpr's server-accelerated installs.
|
||||
//!
|
||||
//! Port of the TypeScript `@pnpm/agent.client` (`fetchFromPnpmRegistry`)
|
||||
//! Port of the TypeScript `@pnpm/pnpr.client` (`fetchFromPnpmRegistry`)
|
||||
//! plus the `fetch-and-write-cafs` worker. Given a set of dependencies
|
||||
//! and the client's content-addressable store, it:
|
||||
//!
|
||||
|
||||
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@@ -1510,34 +1510,6 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
|
||||
agent/client:
|
||||
dependencies:
|
||||
'@pnpm/lockfile.fs':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/fs
|
||||
'@pnpm/lockfile.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/types
|
||||
'@pnpm/logger':
|
||||
specifier: 'catalog:'
|
||||
version: 1001.0.1
|
||||
'@pnpm/store.cafs':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/cafs
|
||||
'@pnpm/store.index':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/index
|
||||
'@pnpm/worker':
|
||||
specifier: workspace:^
|
||||
version: link:../../worker
|
||||
devDependencies:
|
||||
'@pnpm/agent.client':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/types
|
||||
|
||||
auth/commands:
|
||||
dependencies:
|
||||
'@inquirer/prompts':
|
||||
@@ -5578,9 +5550,6 @@ importers:
|
||||
'@inquirer/prompts':
|
||||
specifier: 'catalog:'
|
||||
version: 8.5.2(@types/node@25.9.1)
|
||||
'@pnpm/agent.client':
|
||||
specifier: workspace:*
|
||||
version: link:../../agent/client
|
||||
'@pnpm/bins.linker':
|
||||
specifier: workspace:*
|
||||
version: link:../../bins/linker
|
||||
@@ -5713,6 +5682,9 @@ importers:
|
||||
'@pnpm/pkg-manifest.utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manifest/utils
|
||||
'@pnpm/pnpr.client':
|
||||
specifier: workspace:*
|
||||
version: link:../../pnpr/client
|
||||
'@pnpm/resolving.parse-wanted-dependency':
|
||||
specifier: workspace:*
|
||||
version: link:../../resolving/parse-wanted-dependency
|
||||
@@ -8084,6 +8056,34 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
|
||||
pnpr/client:
|
||||
dependencies:
|
||||
'@pnpm/lockfile.fs':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/fs
|
||||
'@pnpm/lockfile.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/types
|
||||
'@pnpm/logger':
|
||||
specifier: 'catalog:'
|
||||
version: 1001.0.1
|
||||
'@pnpm/store.cafs':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/cafs
|
||||
'@pnpm/store.index':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/index
|
||||
'@pnpm/worker':
|
||||
specifier: workspace:^
|
||||
version: link:../../worker
|
||||
devDependencies:
|
||||
'@pnpm/pnpr.client':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/types
|
||||
|
||||
registry-access/client:
|
||||
dependencies:
|
||||
'@pnpm/error':
|
||||
|
||||
@@ -39,7 +39,7 @@ packages:
|
||||
- testing/*
|
||||
- worker
|
||||
- pnpm/artifacts/*
|
||||
- agent/*
|
||||
- pnpr/client
|
||||
- registry-access/*
|
||||
- releasing/*
|
||||
- resolving/*
|
||||
|
||||
@@ -12,7 +12,7 @@ import { execPnpm } from '../utils/index.js'
|
||||
|
||||
// The pnpr server started by the test harness (see the with-registry jest
|
||||
// preset) serves the install-accelerator endpoints (/v1/install, /v1/files)
|
||||
// on the registry-mock port, so it doubles as the agent under test.
|
||||
// on the registry-mock port, so it doubles as the pnpr server under test.
|
||||
const PNPR = `http://localhost:${REGISTRY_MOCK_PORT}`
|
||||
|
||||
let server: http.Server
|
||||
@@ -21,7 +21,7 @@ let requestCount: number
|
||||
|
||||
beforeAll(async () => {
|
||||
// Counting proxy — forwards to the pnpr server and counts /v1/install
|
||||
// requests so we can assert that the agent path was actually taken.
|
||||
// requests so we can assert that the pnpr server path was actually taken.
|
||||
requestCount = 0
|
||||
server = http.createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
@@ -55,7 +55,7 @@ afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
})
|
||||
|
||||
test('pnpm install uses pnpm agent when configured', async () => {
|
||||
test('pnpm install uses pnpr server when configured', async () => {
|
||||
prepare({
|
||||
dependencies: {
|
||||
'is-positive': '1.0.0',
|
||||
@@ -65,7 +65,7 @@ test('pnpm install uses pnpm agent when configured', async () => {
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['install', `--config.agent=http://localhost:${serverPort}`]
|
||||
['install', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
// Verify the registry server received at least one request
|
||||
@@ -78,25 +78,48 @@ test('pnpm install uses pnpm agent when configured', async () => {
|
||||
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
|
||||
})
|
||||
|
||||
test('a second resolution forwards the existing lockfile to the agent', async () => {
|
||||
test('pnpm install resolves optionalDependencies via the pnpr server', async () => {
|
||||
prepare({
|
||||
dependencies: {
|
||||
'is-positive': '1.0.0',
|
||||
},
|
||||
optionalDependencies: {
|
||||
'is-negative': '1.0.0',
|
||||
},
|
||||
})
|
||||
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['install', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
|
||||
// The optional dependency must be forwarded to the server and resolved,
|
||||
// not silently dropped from the request.
|
||||
expect(fs.existsSync('node_modules/is-negative')).toBe(true)
|
||||
})
|
||||
|
||||
test('a second resolution forwards the existing lockfile to the pnpr server', async () => {
|
||||
prepare({})
|
||||
|
||||
// First add creates the lockfile.
|
||||
await execPnpm(['add', 'is-positive@1.0.0', `--config.agent=http://localhost:${serverPort}`])
|
||||
await execPnpm(['add', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`])
|
||||
expect(fs.existsSync(WANTED_LOCKFILE)).toBe(true)
|
||||
|
||||
// Second add reads that lockfile and forwards it to the agent for
|
||||
// Second add reads that lockfile and forwards it to the pnpr server for
|
||||
// incremental resolution — exercises the on-disk lockfile being sent
|
||||
// over the wire without an in-memory round-trip.
|
||||
requestCount = 0
|
||||
await execPnpm(['add', 'is-negative@1.0.0', `--config.agent=http://localhost:${serverPort}`])
|
||||
await execPnpm(['add', 'is-negative@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`])
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
|
||||
expect(fs.existsSync('node_modules/is-negative')).toBe(true)
|
||||
})
|
||||
|
||||
test('pnpm add uses pnpm agent when configured', async () => {
|
||||
test('pnpm add uses pnpr server when configured', async () => {
|
||||
prepare({
|
||||
dependencies: {
|
||||
'is-negative': '1.0.0',
|
||||
@@ -106,7 +129,7 @@ test('pnpm add uses pnpm agent when configured', async () => {
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['add', 'is-positive@1.0.0', `--config.agent=http://localhost:${serverPort}`]
|
||||
['add', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
@@ -122,7 +145,7 @@ test('pnpm add uses pnpm agent when configured', async () => {
|
||||
expect(manifest.dependencies?.['is-negative']).toBe('1.0.0')
|
||||
})
|
||||
|
||||
test('pnpm remove uses pnpm agent when configured', async () => {
|
||||
test('pnpm remove uses pnpr server when configured', async () => {
|
||||
prepare({
|
||||
dependencies: {
|
||||
'is-positive': '1.0.0',
|
||||
@@ -133,7 +156,7 @@ test('pnpm remove uses pnpm agent when configured', async () => {
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['remove', 'is-negative', `--config.agent=http://localhost:${serverPort}`]
|
||||
['remove', 'is-negative', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
@@ -147,32 +170,32 @@ test('pnpm remove uses pnpm agent when configured', async () => {
|
||||
expect(manifest.dependencies?.['is-negative']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('pnpm add without a version uses the pnpm agent and writes the save-prefix spec from the lockfile', async () => {
|
||||
test('pnpm add without a version uses the pnpr server and writes the save-prefix spec from the lockfile', async () => {
|
||||
prepare({})
|
||||
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['add', 'is-positive', `--config.agent=http://localhost:${serverPort}`]
|
||||
['add', 'is-positive', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
expect(fs.existsSync('node_modules/is-positive')).toBe(true)
|
||||
|
||||
const manifest = loadJsonFileSync<{ dependencies?: Record<string, string> }>('package.json')
|
||||
// The agent resolves "latest" to a concrete version and writes the
|
||||
// The pnpr server resolves "latest" to a concrete version and writes the
|
||||
// resolved version into the lockfile importer's `dependencies` map; the
|
||||
// client computes the save-prefix spec from that version.
|
||||
expect(manifest.dependencies?.['is-positive']).toMatch(/^\^\d+\.\d+\.\d+$/)
|
||||
})
|
||||
|
||||
test('pnpm add -D uses pnpm agent and targets devDependencies', async () => {
|
||||
test('pnpm add -D uses pnpr server and targets devDependencies', async () => {
|
||||
prepare({})
|
||||
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['add', '-D', 'is-positive@1.0.0', `--config.agent=http://localhost:${serverPort}`]
|
||||
['add', '-D', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
@@ -186,13 +209,13 @@ test('pnpm add -D uses pnpm agent and targets devDependencies', async () => {
|
||||
expect(manifest.dependencies?.['is-positive']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('pnpm add with multiple selectors uses pnpm agent', async () => {
|
||||
test('pnpm add with multiple selectors uses pnpr server', async () => {
|
||||
prepare({})
|
||||
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['add', 'is-positive@1.0.0', 'is-negative@1.0.0', `--config.agent=http://localhost:${serverPort}`]
|
||||
['add', 'is-positive@1.0.0', 'is-negative@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
@@ -204,7 +227,7 @@ test('pnpm add with multiple selectors uses pnpm agent', async () => {
|
||||
expect(manifest.dependencies?.['is-negative']).toBe('1.0.0')
|
||||
})
|
||||
|
||||
test('pnpm --filter remove inside a workspace uses pnpm agent', async () => {
|
||||
test('pnpm --filter remove inside a workspace uses pnpr server', async () => {
|
||||
preparePackages([
|
||||
{
|
||||
name: 'project-a',
|
||||
@@ -228,7 +251,7 @@ test('pnpm --filter remove inside a workspace uses pnpm agent', async () => {
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['--filter=project-b', 'remove', 'is-negative', `--config.agent=http://localhost:${serverPort}`]
|
||||
['--filter=project-b', 'remove', 'is-negative', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
@@ -244,7 +267,7 @@ test('pnpm --filter remove inside a workspace uses pnpm agent', async () => {
|
||||
expect(projectBManifest.dependencies?.['is-positive']).toBe('1.0.0')
|
||||
})
|
||||
|
||||
test('pnpm add inside a workspace project uses pnpm agent', async () => {
|
||||
test('pnpm add inside a workspace project uses pnpr server', async () => {
|
||||
preparePackages([
|
||||
{
|
||||
name: 'project-a',
|
||||
@@ -264,7 +287,7 @@ test('pnpm add inside a workspace project uses pnpm agent', async () => {
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['--filter=project-b', 'add', 'is-negative@1.0.0', `--config.agent=http://localhost:${serverPort}`]
|
||||
['--filter=project-b', 'add', 'is-negative@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
@@ -279,7 +302,7 @@ test('pnpm add inside a workspace project uses pnpm agent', async () => {
|
||||
expect(projectBManifest.dependencies?.['is-negative']).toBe('1.0.0')
|
||||
})
|
||||
|
||||
test('pnpm install with agent works in a workspace with multiple projects', async () => {
|
||||
test('pnpm install with pnpr server works in a workspace with multiple projects', async () => {
|
||||
preparePackages([
|
||||
{
|
||||
name: 'project-a',
|
||||
@@ -302,10 +325,10 @@ test('pnpm install with agent works in a workspace with multiple projects', asyn
|
||||
requestCount = 0
|
||||
|
||||
await execPnpm(
|
||||
['install', `--config.agent=http://localhost:${serverPort}`]
|
||||
['install', `--config.pnprServer=http://localhost:${serverPort}`]
|
||||
)
|
||||
|
||||
// Verify the agent server was used
|
||||
// Verify the pnpr server was used
|
||||
expect(requestCount).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Verify the lockfile was created
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# @pnpm/agent.client
|
||||
# @pnpm/pnpr.client
|
||||
|
||||
Client library for the pnpm agent server. Reads the local store state, sends it to the server, and writes the received files into the content-addressable store.
|
||||
Client library for the pnpr server. Reads the local store state, sends it to the server, and writes the received files into the content-addressable store.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Reads integrity hashes from the local store index (`index.db`).
|
||||
2. Sends `POST /v1/install` to the pnpm agent server with the project's dependencies and the store integrities.
|
||||
2. Sends `POST /v1/install` to the pnpr server with the project's dependencies and the store integrities.
|
||||
3. Parses the NDJSON streaming response — `D`-lines (missing file digests) are dispatched to worker downloads against `/v1/files`, `I`-lines are buffered as raw store-index entries, and the final `L`-line yields the resolved lockfile and stats.
|
||||
4. File download workers write each received file directly to the local CAFS (`files/{hash[:2]}/{hash[2:]}`).
|
||||
5. Writes store index entries for all new packages in a single SQLite transaction.
|
||||
@@ -13,10 +13,10 @@ Client library for the pnpm agent server. Reads the local store state, sends it
|
||||
|
||||
## Usage
|
||||
|
||||
This package is used internally by pnpm when the `agent` config option is set. It is not intended to be called directly, but can be used programmatically:
|
||||
This package is used internally by pnpm when the `pnprServer` config option is set. It is not intended to be called directly, but can be used programmatically:
|
||||
|
||||
```typescript
|
||||
import { fetchFromPnpmRegistry } from '@pnpm/agent.client'
|
||||
import { fetchFromPnpmRegistry } from '@pnpm/pnpr.client'
|
||||
import { StoreIndex } from '@pnpm/store.index'
|
||||
|
||||
const storeIndex = new StoreIndex('/path/to/store')
|
||||
@@ -39,5 +39,5 @@ console.log(`${stats.alreadyInStore} cached, ${stats.filesToDownload} files down
|
||||
Add to `pnpm-workspace.yaml` to enable automatically during `pnpm install`:
|
||||
|
||||
```yaml
|
||||
agent: http://localhost:4000
|
||||
pnprServer: http://localhost:4000
|
||||
```
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@pnpm/agent.client",
|
||||
"name": "@pnpm/pnpr.client",
|
||||
"version": "1.0.8",
|
||||
"description": "Client for pnpm agent server — sends store state, receives resolved lockfile and missing files",
|
||||
"description": "Client for the pnpr server — sends store state, receives resolved lockfile and missing files",
|
||||
"keywords": [
|
||||
"pnpm",
|
||||
"pnpm11"
|
||||
],
|
||||
"license": "MIT",
|
||||
"funding": "https://opencollective.com/pnpm",
|
||||
"repository": "https://github.com/pnpm/pnpm/tree/main/agent/client",
|
||||
"homepage": "https://github.com/pnpm/pnpm/tree/main/agent/client#readme",
|
||||
"repository": "https://github.com/pnpm/pnpm/tree/main/pnpr/client",
|
||||
"homepage": "https://github.com/pnpm/pnpm/tree/main/pnpr/client#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pnpm/pnpm/issues"
|
||||
},
|
||||
@@ -41,7 +41,7 @@
|
||||
"@pnpm/worker": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/agent.client": "workspace:*",
|
||||
"@pnpm/pnpr.client": "workspace:*",
|
||||
"@pnpm/types": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
@@ -9,15 +9,16 @@ import { fetchAndWriteCafsFiles } from '@pnpm/worker'
|
||||
|
||||
import type { ResponseMetadata } from './protocol.js'
|
||||
|
||||
export interface AgentProject {
|
||||
export interface PnprProject {
|
||||
/** Relative dir within the workspace (e.g. "." or "packages/foo") */
|
||||
dir: string
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface FetchFromPnpmRegistryOptions {
|
||||
/** URL of the pnpm agent server */
|
||||
/** URL of the pnpr server */
|
||||
registryUrl: string
|
||||
/** Client's store directory */
|
||||
storeDir: string
|
||||
@@ -27,8 +28,10 @@ export interface FetchFromPnpmRegistryOptions {
|
||||
dependencies?: Record<string, string>
|
||||
/** Dev dependencies to resolve (single project) */
|
||||
devDependencies?: Record<string, string>
|
||||
/** Optional dependencies to resolve (single project) */
|
||||
optionalDependencies?: Record<string, string>
|
||||
/** Multiple projects in a workspace */
|
||||
projects?: AgentProject[]
|
||||
projects?: PnprProject[]
|
||||
/** Overrides */
|
||||
overrides?: Record<string, string>
|
||||
/** Node.js version for resolution */
|
||||
@@ -62,7 +65,7 @@ export interface FetchFromPnpmRegistryResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch resolved dependencies from a pnpm agent server.
|
||||
* Fetch resolved dependencies from a pnpr server.
|
||||
*
|
||||
* The response is a streaming NDJSON where each line is one message:
|
||||
* - `D\t{digest}\t{size}\t{executable}\n` — file digest (streamed as packages resolve)
|
||||
@@ -81,6 +84,7 @@ export async function fetchFromPnpmRegistry (
|
||||
dir: '.',
|
||||
dependencies: opts.dependencies,
|
||||
devDependencies: opts.devDependencies,
|
||||
optionalDependencies: opts.optionalDependencies,
|
||||
}]
|
||||
|
||||
const requestBody = JSON.stringify({
|
||||
@@ -169,7 +173,7 @@ export async function fetchFromPnpmRegistry (
|
||||
} else if (type === 'E') {
|
||||
// Server emitted a structured error after headers were sent.
|
||||
// Record it so stream `end` / `catch` can reject with the payload.
|
||||
let message = 'pnpm agent server error'
|
||||
let message = 'pnpr server error'
|
||||
try {
|
||||
const payload = JSON.parse(line.substring(tabIdx + 1)) as { error?: string }
|
||||
if (payload?.error) message = payload.error
|
||||
@@ -189,7 +193,7 @@ export async function fetchFromPnpmRegistry (
|
||||
if (serverError) {
|
||||
reject(serverError)
|
||||
} else if (!resolved) {
|
||||
reject(new Error('pnpm agent server closed the stream without emitting a lockfile'))
|
||||
reject(new Error('pnpr server closed the stream without emitting a lockfile'))
|
||||
}
|
||||
}, reject)
|
||||
})
|
||||
@@ -203,7 +207,7 @@ function readStoreIntegrities (storeIndex: StoreIndex): string[] {
|
||||
const integrity = key.slice(0, tabIdx)
|
||||
// StoreIndex also stores non-integrity keys (e.g. git-hosted entries
|
||||
// keyed by URL). Filter to actual SRI hashes — sending those over to
|
||||
// the agent server would just bloat the request without ever matching.
|
||||
// the pnpr server would just bloat the request without ever matching.
|
||||
if (!isIntegrityLike(integrity)) continue
|
||||
seen.add(integrity)
|
||||
}
|
||||
@@ -231,7 +235,7 @@ async function streamNdjsonRequest (
|
||||
): Promise<void> {
|
||||
// `urlPath` is expected to be relative (e.g. "v1/install"). We normalize
|
||||
// the base to end with "/" so `new URL(rel, base)` preserves any path
|
||||
// prefix configured on the agent URL (e.g. https://host/pnpr/).
|
||||
// prefix configured on the pnpr server URL (e.g. https://host/pnpr/).
|
||||
const base = registryUrl.endsWith('/') ? registryUrl : `${registryUrl}/`
|
||||
const url = new URL(urlPath, base)
|
||||
const isHttps = url.protocol === 'https:'
|
||||
@@ -251,7 +255,7 @@ async function streamNdjsonRequest (
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
reject(new Error(`pnpm agent responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
|
||||
reject(new Error(`pnpr server responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -274,11 +278,11 @@ async function streamNdjsonRequest (
|
||||
})
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error(`pnpm agent request timed out after ${REQUEST_TIMEOUT / 1000}s (${registryUrl})`))
|
||||
req.destroy(new Error(`pnpr server request timed out after ${REQUEST_TIMEOUT / 1000}s (${registryUrl})`))
|
||||
})
|
||||
req.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
reject(new Error(`Could not connect to pnpm agent at ${registryUrl}. Is the server running?`))
|
||||
reject(new Error(`Could not connect to pnpr server at ${registryUrl}. Is the server running?`))
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Wire types for the pnpr install-accelerator endpoints, matching the
|
||||
//! `@pnpm/agent.client` TypeScript client's request shapes.
|
||||
//! `@pnpm/pnpr.client` TypeScript client's request shapes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -18,6 +18,8 @@ pub struct InstallRequestProject {
|
||||
pub dependencies: DepMap,
|
||||
#[serde(default)]
|
||||
pub dev_dependencies: DepMap,
|
||||
#[serde(default)]
|
||||
pub optional_dependencies: DepMap,
|
||||
}
|
||||
|
||||
fn root_dir() -> String {
|
||||
@@ -39,6 +41,8 @@ pub struct InstallRequest {
|
||||
#[serde(default)]
|
||||
pub dev_dependencies: Option<DepMap>,
|
||||
#[serde(default)]
|
||||
pub optional_dependencies: Option<DepMap>,
|
||||
#[serde(default)]
|
||||
pub projects: Option<Vec<InstallRequestProject>>,
|
||||
#[serde(default)]
|
||||
pub store_integrities: Vec<String>,
|
||||
@@ -119,6 +123,7 @@ pub struct ProjectDeps {
|
||||
pub dir: String,
|
||||
pub dependencies: DepMap,
|
||||
pub dev_dependencies: DepMap,
|
||||
pub optional_dependencies: DepMap,
|
||||
}
|
||||
|
||||
impl InstallRequest {
|
||||
@@ -134,6 +139,7 @@ impl InstallRequest {
|
||||
dir: project.dir.clone(),
|
||||
dependencies: project.dependencies.clone(),
|
||||
dev_dependencies: project.dev_dependencies.clone(),
|
||||
optional_dependencies: project.optional_dependencies.clone(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -141,6 +147,7 @@ impl InstallRequest {
|
||||
dir: root_dir(),
|
||||
dependencies: self.dependencies.clone().unwrap_or_default(),
|
||||
dev_dependencies: self.dev_dependencies.clone().unwrap_or_default(),
|
||||
optional_dependencies: self.optional_dependencies.clone().unwrap_or_default(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub async fn resolve(
|
||||
"version": "0.0.0",
|
||||
"dependencies": project.dependencies,
|
||||
"devDependencies": project.dev_dependencies,
|
||||
"optionalDependencies": project.optional_dependencies,
|
||||
});
|
||||
let manifest_bytes = serde_json::to_vec(&manifest_json)
|
||||
.map_err(|err| ResolveError::Install(err.to_string()))?;
|
||||
|
||||
@@ -284,7 +284,7 @@ export async function readPkgFromCafs (
|
||||
let limitImportingPackage = pLimit(4)
|
||||
|
||||
/**
|
||||
* Temporarily change import concurrency. Called by the pnpm agent code path
|
||||
* Temporarily change import concurrency. Called by the pnpr server code path
|
||||
* where there's no concurrent fetching competing for workers. Returns a
|
||||
* disposer that restores the previous limiter — callers must invoke it (in a
|
||||
* finally block) to avoid leaking the mutation to other installs in the same
|
||||
|
||||
@@ -508,7 +508,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
const { createGunzip } = await import('node:zlib')
|
||||
const { contentPathFromHex } = await import('@pnpm/store.cafs')
|
||||
|
||||
// Preserve any path prefix on the agent URL (e.g. https://host/pnpr/)
|
||||
// Preserve any path prefix on the pnpr server URL (e.g. https://host/pnpr/)
|
||||
// by normalizing the base and using a relative URL.
|
||||
const base = message.registryUrl.endsWith('/') ? message.registryUrl : `${message.registryUrl}/`
|
||||
const url = new URL('v1/files', base)
|
||||
@@ -517,7 +517,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
const createdDirs = new Set<string>()
|
||||
|
||||
// Build a set of digests we actually requested, so we can reject a
|
||||
// misbehaving agent that streams unrelated entries and tries to write
|
||||
// misbehaving pnpr server that streams unrelated entries and tries to write
|
||||
// unbounded files into our CAFS. The set is keyed by `${digest}:${exec}`
|
||||
// because the same digest may appear with different modes.
|
||||
const requestedDigests = new Set<string>()
|
||||
@@ -565,7 +565,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
|
||||
const requestKey = `${digest}:${executable ? 'x' : ''}`
|
||||
if (!requestedDigests.has(requestKey)) {
|
||||
throw new Error(`pnpm agent /v1/files returned an entry that was not requested: digest=${digest} executable=${String(executable)}`)
|
||||
throw new Error(`pnpr server /v1/files returned an entry that was not requested: digest=${digest} executable=${String(executable)}`)
|
||||
}
|
||||
// Consume the request so duplicates past the requested count also fail.
|
||||
requestedDigests.delete(requestKey)
|
||||
@@ -588,7 +588,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
// EEXIST means the same digest is already at this CAFS path. CAFS
|
||||
// is content-addressed, so a complete file is by definition correct.
|
||||
// But a previous process could have crashed mid-write and left a
|
||||
// truncated file — the agent path skips integrity verification, so
|
||||
// truncated file — the pnpr server path skips integrity verification, so
|
||||
// we'd silently install garbage. Detect truncation by size and
|
||||
// overwrite atomically if the on-disk file is the wrong length.
|
||||
const onDiskSize = fs.statSync(fullPath).size
|
||||
@@ -630,13 +630,13 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
'Accept-Encoding': 'gzip',
|
||||
},
|
||||
}, (res: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// Non-2xx responses are JSON error bodies from the agent server; read
|
||||
// Non-2xx responses are JSON error bodies from the pnpr server; read
|
||||
// and reject so we never try to gunzip an error payload as a file stream.
|
||||
if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
reject(new Error(`pnpm agent /v1/files responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
|
||||
reject(new Error(`pnpr server /v1/files responded with ${res.statusCode}: ${Buffer.concat(chunks).toString('utf-8')}`))
|
||||
})
|
||||
res.on('error', reject)
|
||||
return
|
||||
@@ -660,11 +660,11 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
// partial entry still in `buf`, fail — otherwise we'd silently leave
|
||||
// the CAFS missing files.
|
||||
if (!endMarkerSeen) {
|
||||
reject(new Error('pnpm agent /v1/files stream ended without the end marker'))
|
||||
reject(new Error('pnpr server /v1/files stream ended without the end marker'))
|
||||
return
|
||||
}
|
||||
if (buf.length > 0) {
|
||||
reject(new Error(`pnpm agent /v1/files stream left ${buf.length} unparsed bytes after end marker`))
|
||||
reject(new Error(`pnpr server /v1/files stream left ${buf.length} unparsed bytes after end marker`))
|
||||
return
|
||||
}
|
||||
// Every received entry was drained from `requestedDigests` as it was
|
||||
@@ -672,7 +672,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
// but omitted files, which would silently leave the CAFS incomplete.
|
||||
if (requestedDigests.size > 0) {
|
||||
const sample = [...requestedDigests].slice(0, 3).join(', ')
|
||||
reject(new Error(`pnpm agent /v1/files omitted ${requestedDigests.size} requested entries (e.g. ${sample})`))
|
||||
reject(new Error(`pnpr server /v1/files omitted ${requestedDigests.size} requested entries (e.g. ${sample})`))
|
||||
return
|
||||
}
|
||||
resolve({ status: 'success', filesWritten })
|
||||
@@ -680,7 +680,7 @@ async function fetchAndWriteCafs (message: FetchAndWriteCafsMessage): Promise<{
|
||||
stream.on('error', reject)
|
||||
})
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error(`pnpm agent /v1/files request timed out after ${FILES_REQUEST_TIMEOUT_MS / 1000}s`))
|
||||
req.destroy(new Error(`pnpr server /v1/files request timed out after ${FILES_REQUEST_TIMEOUT_MS / 1000}s`))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.write(body)
|
||||
|
||||
Reference in New Issue
Block a user