Files
openchamber/packages/web/server/lib/opencode/plugin-spec.js
T

108 lines
3.3 KiB
JavaScript
Raw Normal View History

2026-05-25 12:20:04 -04:00
import path from 'path';
/**
* @typedef {Object} ParsedNpmSpec
* @property {string} name
* @property {string|null} version
*/
/**
* @typedef {Object} MalformedSpec
* @property {true} malformed
* @property {string} raw
*/
/**
* @typedef {Object} ParsedPathSpec
* @property {string} absolutePath
*/
/**
* Parse an npm package spec string into name + version.
* Handles scoped packages (`@scope/name[@version]`) and unscoped (`name[@version]`).
* Non-string inputs are coerced via `String()` and returned as malformed.
*
* @param {unknown} spec
* @returns {ParsedNpmSpec | MalformedSpec}
*/
export function parseNpmSpec(spec) {
if (typeof spec !== 'string') {
return { malformed: true, raw: String(spec) };
}
if (spec.startsWith('@')) {
// scoped: '@scope/name' or '@scope/name@version'
const slashIdx = spec.indexOf('/');
if (slashIdx < 2) return { malformed: true, raw: spec }; // '@' or '@/foo'
const afterSlash = spec.slice(slashIdx + 1);
if (afterSlash === '') return { malformed: true, raw: spec }; // '@scope/'
const atIdx = afterSlash.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
const namePart = spec.slice(0, slashIdx + 1 + atIdx); // '@scope/name'
const versionPart = afterSlash.slice(atIdx + 1);
if (versionPart === '') return { malformed: true, raw: spec }; // '@scope/foo@'
return { name: namePart, version: versionPart };
}
// unscoped
if (spec === '') return { malformed: true, raw: spec };
const atIdx = spec.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
if (atIdx === 0) return { malformed: true, raw: spec }; // bare '@'
const namePart = spec.slice(0, atIdx);
const versionPart = spec.slice(atIdx + 1);
if (versionPart === '') return { malformed: true, raw: spec }; // 'foo@'
return { name: namePart, version: versionPart };
}
/**
* Check whether a version string is an exact semver (no range operators).
* Accepts optional pre-release (`-label`) or build metadata (`+label`) suffixes.
*
* @param {string} version
* @returns {boolean}
*/
export function isExactSemver(version) {
return /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version);
}
/**
* Check whether a plugin spec is path-like instead of an npm package spec.
* Includes Windows absolute paths so local paths are never queried against npm.
*
* @param {string} spec
* @returns {boolean}
*/
export function isPathSpec(spec) {
return spec.startsWith('/')
|| spec.startsWith('./')
|| spec.startsWith('../')
|| spec.startsWith('~')
|| path.win32.isAbsolute(spec);
}
/**
* Resolve a path-style plugin spec to an absolute path.
* Supports `~` (home), `./`, `../` (relative to cwd), and absolute paths.
* Pure — no filesystem access; uses only `path.resolve`.
*
* @param {string} spec
* @param {{ homedir: string, cwd: string }} options
* @returns {ParsedPathSpec}
*/
export function parsePathSpec(spec, { homedir, cwd }) {
if (spec === '~') {
return { absolutePath: path.resolve(homedir) };
}
if (spec.startsWith('~/')) {
return { absolutePath: path.resolve(homedir, spec.slice(2)) };
}
if (spec.startsWith('./') || spec.startsWith('../')) {
return { absolutePath: path.resolve(cwd, spec) };
}
if (path.win32.isAbsolute(spec)) {
return { absolutePath: spec };
}
return { absolutePath: path.resolve(spec) };
}