9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
166 lines
6.0 KiB
JavaScript
166 lines
6.0 KiB
JavaScript
// Shared helpers for the Go module proxy adapters.
|
|
//
|
|
// proxy.golang.org is the canonical Go module proxy. It is unauthenticated
|
|
// and serves the GOPROXY protocol (`@latest`, `@v/list`, `@v/<ver>.info|mod|zip`).
|
|
// Spec: https://go.dev/ref/mod#goproxy-protocol
|
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
|
|
export const GOPROXY_BASE = 'https://proxy.golang.org';
|
|
const UA = 'opencli-goproxy-adapter (+https://github.com/jackwener/opencli)';
|
|
|
|
// Module paths look like host/path/...; conservative shape: at least one slash,
|
|
// host segment is alnum + dots, path segments are alnum + dashes/dots/underscores/slashes.
|
|
// We enforce at most 200 chars and reject characters that would need URL-escaping.
|
|
const MODULE_PATH = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/;
|
|
|
|
// Go semver tags include "v" prefix; we accept the GOPROXY canonical form.
|
|
const VERSION_TAG = /^v[0-9]+(\.[0-9]+)*([-+][A-Za-z0-9._-]+)?$/;
|
|
|
|
export function requireModulePath(value) {
|
|
const s = String(value ?? '').trim();
|
|
if (!s) {
|
|
throw new ArgumentError(
|
|
'goproxy module path is required (e.g. "github.com/gin-gonic/gin", "golang.org/x/net")',
|
|
'Use the canonical module path that appears in `go.mod`.',
|
|
);
|
|
}
|
|
if (!MODULE_PATH.test(s) || !s.includes('/')) {
|
|
throw new ArgumentError(
|
|
`goproxy module path "${value}" is not a recognised Go module path`,
|
|
'Module paths look like "github.com/<org>/<repo>" or "golang.org/x/<name>".',
|
|
);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
export function requireVersionTag(value) {
|
|
const s = String(value ?? '').trim();
|
|
if (!s) throw new ArgumentError('goproxy --version cannot be empty');
|
|
if (!VERSION_TAG.test(s)) {
|
|
throw new ArgumentError(
|
|
`goproxy --version "${value}" is not a valid Go semver tag`,
|
|
'Use the GOPROXY canonical form like "v1.2.3" or "v0.0.0-20240101010101-abcdef012345".',
|
|
);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
const raw = value ?? defaultValue;
|
|
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
if (!Number.isInteger(n) || n <= 0) {
|
|
throw new ArgumentError(`goproxy ${label} must be a positive integer`);
|
|
}
|
|
if (n > maxValue) {
|
|
throw new ArgumentError(`goproxy ${label} must be <= ${maxValue}`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
async function rawFetch(url, label) {
|
|
let resp;
|
|
try {
|
|
resp = await fetch(url, { headers: { 'user-agent': UA } });
|
|
}
|
|
catch (err) {
|
|
throw new CommandExecutionError(
|
|
`${label} request failed: ${err?.message ?? err}`,
|
|
'Check that proxy.golang.org is reachable from this network.',
|
|
);
|
|
}
|
|
if (resp.status === 404 || resp.status === 410) {
|
|
throw new EmptyResultError(label, `proxy.golang.org returned ${resp.status} for ${url}.`);
|
|
}
|
|
if (resp.status === 429) {
|
|
throw new CommandExecutionError(`${label} returned HTTP 429 (rate limited)`);
|
|
}
|
|
if (!resp.ok) {
|
|
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
|
|
}
|
|
return resp;
|
|
}
|
|
|
|
export async function goproxyJson(url, label) {
|
|
const resp = await rawFetch(url, label);
|
|
let body;
|
|
try {
|
|
body = await resp.json();
|
|
}
|
|
catch (err) {
|
|
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
export async function goproxyText(url, label) {
|
|
const resp = await rawFetch(url, label);
|
|
return resp.text();
|
|
}
|
|
|
|
// Sort version tags by their numeric components, newest first. Pre-release tags
|
|
// (anything after "-" that isn't a pure number) sort lower than the matching
|
|
// release. Returns a new sorted array; non-tag inputs are dropped.
|
|
export function sortVersionsDescending(versions) {
|
|
return versions
|
|
.filter((v) => typeof v === 'string' && VERSION_TAG.test(v))
|
|
.map((v) => ({ v, parts: parseSemver(v) }))
|
|
.sort((a, b) => compareParts(b.parts, a.parts))
|
|
.map((entry) => entry.v);
|
|
}
|
|
|
|
function parseSemver(tag) {
|
|
// Strip leading "v"; split into <numbers>(-<pre>)?(+<build>)?
|
|
const stripped = tag.replace(/^v/, '');
|
|
const plusIdx = stripped.indexOf('+');
|
|
const noBuild = plusIdx >= 0 ? stripped.slice(0, plusIdx) : stripped;
|
|
const dashIdx = noBuild.indexOf('-');
|
|
const head = dashIdx >= 0 ? noBuild.slice(0, dashIdx) : noBuild;
|
|
const pre = dashIdx >= 0 ? noBuild.slice(dashIdx + 1) : '';
|
|
const numbers = head.split('.').map((s) => Number.parseInt(s, 10)).map((n) => Number.isFinite(n) ? n : 0);
|
|
return { numbers, pre };
|
|
}
|
|
|
|
function compareParts(a, b) {
|
|
const len = Math.max(a.numbers.length, b.numbers.length);
|
|
for (let i = 0; i < len; i += 1) {
|
|
const ai = a.numbers[i] ?? 0;
|
|
const bi = b.numbers[i] ?? 0;
|
|
if (ai !== bi) return ai - bi;
|
|
}
|
|
// No pre-release sorts higher than has pre-release.
|
|
if (a.pre === '' && b.pre !== '') return 1;
|
|
if (a.pre !== '' && b.pre === '') return -1;
|
|
if (a.pre === b.pre) return 0;
|
|
return comparePrerelease(a.pre, b.pre);
|
|
}
|
|
|
|
function comparePrerelease(a, b) {
|
|
const aParts = a.split('.');
|
|
const bParts = b.split('.');
|
|
const len = Math.max(aParts.length, bParts.length);
|
|
for (let i = 0; i < len; i += 1) {
|
|
const ai = aParts[i];
|
|
const bi = bParts[i];
|
|
if (ai == null) return -1;
|
|
if (bi == null) return 1;
|
|
const aNum = /^\d+$/.test(ai);
|
|
const bNum = /^\d+$/.test(bi);
|
|
if (aNum && bNum) {
|
|
const diff = Number(ai) - Number(bi);
|
|
if (diff !== 0) return diff;
|
|
continue;
|
|
}
|
|
if (aNum !== bNum) return aNum ? -1 : 1;
|
|
if (ai !== bi) return ai < bi ? -1 : 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Normalise GOPROXY ISO-Z timestamps to second precision (no fractional ms).
|
|
export function trimDate(value) {
|
|
const s = String(value ?? '').trim();
|
|
if (!s) return null;
|
|
const noFrac = s.replace(/\.\d+/, '');
|
|
return noFrac.endsWith('Z') ? noFrac : (s.length >= 10 ? s.slice(0, 10) : null);
|
|
}
|