* Infra: tighten npm registry spec parsing * Infra: block implicit prerelease npm installs * Plugins: cover prerelease install policy * Infra: add npm registry spec tests * Hooks: cover prerelease install policy * Docs: clarify plugin guide version policy * Docs: clarify plugin install version policy * Docs: clarify hooks install version policy * Docs: clarify hook pack version policy
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
const EXACT_SEMVER_VERSION_RE =
|
|
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
|
export type ParsedRegistryNpmSpec = {
|
|
name: string;
|
|
raw: string;
|
|
selector?: string;
|
|
selectorKind: "none" | "exact-version" | "tag";
|
|
selectorIsPrerelease: boolean;
|
|
};
|
|
|
|
function parseRegistryNpmSpecInternal(
|
|
rawSpec: string,
|
|
): { ok: true; parsed: ParsedRegistryNpmSpec } | { ok: false; error: string } {
|
|
const spec = rawSpec.trim();
|
|
if (!spec) {
|
|
return { ok: false, error: "missing npm spec" };
|
|
}
|
|
if (/\s/.test(spec)) {
|
|
return { ok: false, error: "unsupported npm spec: whitespace is not allowed" };
|
|
}
|
|
// Registry-only: no URLs, git, file, or alias protocols.
|
|
// Keep strict: this runs on the gateway host.
|
|
if (spec.includes("://")) {
|
|
return { ok: false, error: "unsupported npm spec: URLs are not allowed" };
|
|
}
|
|
if (spec.includes("#")) {
|
|
return { ok: false, error: "unsupported npm spec: git refs are not allowed" };
|
|
}
|
|
if (spec.includes(":")) {
|
|
return { ok: false, error: "unsupported npm spec: protocol specs are not allowed" };
|
|
}
|
|
|
|
const at = spec.lastIndexOf("@");
|
|
const hasSelector = at > 0;
|
|
const name = hasSelector ? spec.slice(0, at) : spec;
|
|
const selector = hasSelector ? spec.slice(at + 1) : "";
|
|
|
|
const unscopedName = /^[a-z0-9][a-z0-9-._~]*$/;
|
|
const scopedName = /^@[a-z0-9][a-z0-9-._~]*\/[a-z0-9][a-z0-9-._~]*$/;
|
|
const isValidName = name.startsWith("@") ? scopedName.test(name) : unscopedName.test(name);
|
|
if (!isValidName) {
|
|
return {
|
|
ok: false,
|
|
error: "unsupported npm spec: expected <name> or <name>@<version> from the npm registry",
|
|
};
|
|
}
|
|
if (!hasSelector) {
|
|
return {
|
|
ok: true,
|
|
parsed: {
|
|
name,
|
|
raw: spec,
|
|
selectorKind: "none",
|
|
selectorIsPrerelease: false,
|
|
},
|
|
};
|
|
}
|
|
if (!selector) {
|
|
return { ok: false, error: "unsupported npm spec: missing version/tag after @" };
|
|
}
|
|
if (/[\\/]/.test(selector)) {
|
|
return { ok: false, error: "unsupported npm spec: invalid version/tag" };
|
|
}
|
|
const exactVersionMatch = EXACT_SEMVER_VERSION_RE.exec(selector);
|
|
if (exactVersionMatch) {
|
|
return {
|
|
ok: true,
|
|
parsed: {
|
|
name,
|
|
raw: spec,
|
|
selector,
|
|
selectorKind: "exact-version",
|
|
selectorIsPrerelease: Boolean(exactVersionMatch[4]),
|
|
},
|
|
};
|
|
}
|
|
if (!DIST_TAG_RE.test(selector)) {
|
|
return {
|
|
ok: false,
|
|
error: "unsupported npm spec: use an exact version or dist-tag (ranges are not allowed)",
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
parsed: {
|
|
name,
|
|
raw: spec,
|
|
selector,
|
|
selectorKind: "tag",
|
|
selectorIsPrerelease: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function parseRegistryNpmSpec(rawSpec: string): ParsedRegistryNpmSpec | null {
|
|
const parsed = parseRegistryNpmSpecInternal(rawSpec);
|
|
return parsed.ok ? parsed.parsed : null;
|
|
}
|
|
|
|
export function validateRegistryNpmSpec(rawSpec: string): string | null {
|
|
const parsed = parseRegistryNpmSpecInternal(rawSpec);
|
|
return parsed.ok ? null : parsed.error;
|
|
}
|
|
|
|
export function isExactSemverVersion(value: string): boolean {
|
|
return EXACT_SEMVER_VERSION_RE.test(value.trim());
|
|
}
|
|
|
|
export function isPrereleaseSemverVersion(value: string): boolean {
|
|
const match = EXACT_SEMVER_VERSION_RE.exec(value.trim());
|
|
return Boolean(match?.[4]);
|
|
}
|
|
|
|
export function isPrereleaseResolutionAllowed(params: {
|
|
spec: ParsedRegistryNpmSpec;
|
|
resolvedVersion?: string;
|
|
}): boolean {
|
|
if (!params.resolvedVersion || !isPrereleaseSemverVersion(params.resolvedVersion)) {
|
|
return true;
|
|
}
|
|
if (params.spec.selectorKind === "none") {
|
|
return false;
|
|
}
|
|
if (params.spec.selectorKind === "exact-version") {
|
|
return params.spec.selectorIsPrerelease;
|
|
}
|
|
return params.spec.selector?.toLowerCase() !== "latest";
|
|
}
|
|
|
|
export function formatPrereleaseResolutionError(params: {
|
|
spec: ParsedRegistryNpmSpec;
|
|
resolvedVersion: string;
|
|
}): string {
|
|
const selectorHint =
|
|
params.spec.selectorKind === "none" || params.spec.selector?.toLowerCase() === "latest"
|
|
? `Use "${params.spec.name}@beta" (or another prerelease tag) or an exact prerelease version to opt in explicitly.`
|
|
: `Use an explicit prerelease tag or exact prerelease version if you want prerelease installs.`;
|
|
return `Resolved ${params.spec.raw} to prerelease version ${params.resolvedVersion}, but prereleases are only installed when explicitly requested. ${selectorHint}`;
|
|
}
|