Thank you @jacobparis
Actually I had some trouble getting this to work with pnpm because it prefers to use SSH instead of HTTP.
The problem is that with current pnpm version (v10.17.0 at time of writing) even if I add something like in my package.json
"my-repo": "git+https://x-token-auth:<access-token>@bitbucket.org/<my-org>/<my-repo>.git""
I end up with following entries in pnpm-lock.yaml
my-repo@git+https://git@bitbucket.org:my-org/my-repo.git#1e37d6cc8bb4a22a897b01774e6ffd04a8a2617b:
resolution: {commit: 1e37d6cc8bb4a22a897b01774e6ffd04a8a2617b, repo: git@bitbucket.org:my-org/my-repo.git, type: git}
version: 0.1.0
engines: {node: '>= 20.x.x'}
my-repo@git+https://git@bitbucket.org:my-org/my-repo.git#1e37d6cc8bb4a22a897b01774e6ffd04a8a2617b:
dependencies:
date-fns: 4.1.0
date-fns-tz: 3.2.0(date-fns@4.1.0)
lodash: 4.17.21
I’m assuming that by default pnpm “prefers” to use SSH. Since locally I have my SSH keys configured, it works without any issues and pnpm is able to clone the repo which is then registered in the lockfile with SSH URL. This happens even if I explicitly add the repo with HTTPS URL.
This is problematic in 2 ways:
- it exposes the token in
package.json
- it won’t work at Vercel’s CI/CD because pnpm will look at the lockfile and try to clone via SSH which is not configured in Vercel’s environment.
The solution I’ve come up with is to use pnpmfile.cjs and a preResolution hook (docs) to modify the lockfile before installation. The idea is to check if the repo can be accessed via SSH, and if not, modify the resolution to use HTTPS with access token.
This works locally and in Vercel’s CI/CD.
In the scenario that I’m working on, our NextJs-based project depends on a private Bitbucket repo called main-dependency-package which in turn depends on another private Bitbucket repo called sub-dependency-package. So the pnpmfile.cjs modifies the resolution of both packages.
The only thing that need to be set in the environment are the access tokens for both repos:
MAIN_DEPENDENCY_TOKEN
SUB_DEPENDENCY_TOKEN
Code:
const { execSync } = require("child_process");
const ORG_NAME = "my-org";
const MAIN_DEPENDENCY_PACKAGE_NAME = "main-dependency-package";
const SUB_DEPENDENCY_PACKAGE_NAME = "sub-dependency-package";
const MAIN_DEPENDENCY_TOKEN = process.env.MAIN_DEPENDENCY_TOKEN;
const SUB_DEPENDENCY_TOKEN = process.env.SUB_DEPENDENCY_TOKEN;
const MAIN_DEPENDENCY_REPO_URL = `https://x-token-auth:${encodeURIComponent(
MAIN_DEPENDENCY_TOKEN
)}@bitbucket.org/${ORG_NAME}/${MAIN_DEPENDENCY_PACKAGE_NAME}.git`;
const SUB_DEPENDENCY_REPO_URL = `https://x-token-auth:${encodeURIComponent(
SUB_DEPENDENCY_TOKEN
)}@bitbucket.org/${ORG_NAME}/${SUB_DEPENDENCY_PACKAGE_NAME}.git`;
/**
* Check if we can access a Bitbucket repository via SSH
*
* @param {Object} params
* @param {string} params.orgName
* @param {string} params.repoName
* @returns
*/
function statBitbucketRepo({ orgName, repoName }) {
try {
const command = `git ls-remote git@bitbucket.org:${orgName}/${repoName}.git`;
execSync(command, { stdio: "ignore" });
return true;
} catch {
return false;
}
}
/**
* Check if we can clone the Git repositories via SSH
* @returns {boolean}
*/
function canGitClone() {
const canAccessMainDep = statBitbucketRepo({
orgName: ORG_NAME,
repoName: MAIN_DEPENDENCY_PACKAGE_NAME,
});
const canAccessSubDep = statBitbucketRepo({
orgName: ORG_NAME,
repoName: SUB_DEPENDENCY_PACKAGE_NAME,
});
return canAccessMainDep && canAccessSubDep;
}
/**
* Pnpm preResolution hook to modify dependencies in the lockfile
* to use HTTPS with access tokens instead of SSH.
*
* Docs: https://pnpm.io/pnpmfile#hookspreresolutionoptions-promisevoid
*
* @param {Object} options
* @param {Object} options.wantedLockfile - The current lockfile
* @returns {Object} The modified options
*/
function preResolution(options) {
const lockfile = options.wantedLockfile;
if (!lockfile) return options;
if (!lockfile.packages) return options;
if (canGitClone()) {
console.log(
`Can access "${MAIN_DEPENDENCY_PACKAGE_NAME}" and "${SUB_DEPENDENCY_PACKAGE_NAME}" via SSH. No patching needed.`
);
return options;
}
console.log(
`Patching "${MAIN_DEPENDENCY_PACKAGE_NAME}" and "${SUB_DEPENDENCY_PACKAGE_NAME}" to use HTTPS with access tokens.`
);
if (!MAIN_DEPENDENCY_TOKEN)
throw new Error("MAIN_DEPENDENCY_TOKEN is not set");
if (!SUB_DEPENDENCY_TOKEN) throw new Error("SUB_DEPENDENCY_TOKEN is not set");
// First, we need to modify the sub-dependency, because the main dependency will reference it
const subDep = modifyDependency({
packages: lockfile.packages,
mainPackageName: SUB_DEPENDENCY_PACKAGE_NAME,
mainPackageRepoUrl: SUB_DEPENDENCY_REPO_URL,
});
if (subDep) {
const { newKey, oldKey, pkg } = subDep;
delete lockfile.packages[oldKey];
lockfile.packages[newKey] = pkg;
// console.log({ oldKey, newKey, pkg });
}
const mainDep = modifyDependency({
packages: lockfile.packages,
mainPackageName: MAIN_DEPENDENCY_PACKAGE_NAME,
mainPackageRepoUrl: MAIN_DEPENDENCY_REPO_URL,
subDepPackageName: SUB_DEPENDENCY_PACKAGE_NAME,
subDepPackageRepoUrl: SUB_DEPENDENCY_REPO_URL,
});
if (mainDep) {
const { oldKey, pkg } = mainDep;
lockfile.packages[oldKey] = pkg;
// console.log({ oldKey, pkg });
}
console.log("Patching done.");
return options;
}
/**
* Modify a dependency in the lockfile to use HTTPS with access token
* instead of SSH.
*
* @param {Object} params
* @param {Record<string, PnpmLockfileEntry>} params.packages
* @param {string} params.mainPackageName - e.g. `main-dependency-package`
* @param {string} params.mainPackageRepoUrl - e.g. `https://x-token-auth:...@bitbucket.org/my-org/main-dependency-package.git`
* @param {string} [params.subDepPackageName] - e.g. `sub-dependency-package`
* @param {string} [params.subDepPackageRepoUrl] - e.g. `https://x-token-auth:...@bitbucket.org/my-org/sub-dependency-package.git`
* @returns {Object|null} - Returns an object with oldKey, newKey and modified pkg, or null if not found
*/
function modifyDependency({
packages,
mainPackageName,
mainPackageRepoUrl,
subDepPackageName,
subDepPackageRepoUrl,
}) {
const key = getPackageNameFromKey({
packages,
packageName: mainPackageName,
});
if (!key) return null;
/** @type {PnpmLockfileEntry} */
const pkg = packages[key];
if (!pkg) return null;
const newKey = rewriteDependencyKey({
key: key,
packageName: mainPackageName,
repoUrl: mainPackageRepoUrl,
});
pkg.resolution.repo = mainPackageRepoUrl;
if (
subDepPackageName &&
pkg.dependencies &&
pkg.dependencies[subDepPackageName]
) {
const from = pkg.dependencies[subDepPackageName];
const [_, commit] = from.split("#");
pkg.dependencies[
subDepPackageName
] = `git+${subDepPackageRepoUrl}#${commit}`;
}
return { oldKey: key, newKey, pkg };
}
/**
* @param {Object} params
* @param {Record<string, PnpmLockfileEntry>} params.packages
* @param {string} params.packageName
* @returns
*/
function getPackageNameFromKey({ packages, packageName }) {
const key = Object.keys(packages).find((k) => k.includes(packageName));
return key ? key : null;
}
/**
* Rewrite a dependency key to use HTTPS with access token instead of SSH.
* Example:
* ```js
* const input ='repo-name@git+https://git@bitbucket.org:org-name/repo-name.git#<COMMIT>'
* const output = 'repo-name@git+https://x-token-auth:<REPO-TOKEN>@bitbucket.org/my-org/repo-name.git#<COMMIT>'
* ```
* @param {Object} params
* @param {string} params.key - lockfile key that identifies a package
* @param {string} params.packageName - e.g. `main-dependency-package`
* @param {string} params.repoUrl - HTTP that includes `x-token-auth:...`
*/
function rewriteDependencyKey({ key, packageName, repoUrl }) {
const [name] = key.split("@");
const [_, commit] = key.split("#");
if (name !== packageName) return key;
const newKey = commit
? `${name}@git+${repoUrl}#${commit}`
: `${name}@git+${repoUrl}`;
return newKey;
}
module.exports = {
hooks: {
preResolution,
},
};
/**
* @typedef PnpmLockfileEntry
* @property {string} version
* @property {Record<string, string>} [dependencies]
* @property {Object} resolution
* @property {string} resolution.commit
* @property {string} resolution.repo
* @property {string} resolution.type
*/