Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openstellar/tool-search",
"version": "0.2.0",
"version": "0.2.1",
"description": "Tool search plugin for OpenCode — BM25 and regex search to discover tools on demand, reducing context usage",
"type": "module",
"main": "./dist/index.js",
Expand Down
146 changes: 106 additions & 40 deletions src/hooks/auto-update-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { readFileSync, existsSync, rmSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { env } from 'node:process';
import { gt, valid } from 'semver';
import { resolveRegistryUrl, buildDistTagsUrl } from './npm-registry.js';

const PACKAGE_SCOPE = '@openstellar';
const PACKAGE_NAME = '@openstellar/tool-search';
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/dist-tags`;
const NPM_FETCH_TIMEOUT = 5000;

export type UpdateCheckOutcome = 'up-to-date' | 'update-staged' | 'invalidation-failed' | 'check-failed';

export interface UpdateCheckResult {
needsUpdate: boolean;
outcome: UpdateCheckOutcome;
currentVersion: string | null;
latestVersion: string | null;
error?: string;
Expand All @@ -36,12 +38,20 @@ export function getCurrentVersion(): string | null {
return null;
}

async function defaultGetLatestVersionUrl(): Promise<string> {
const { url } = await resolveRegistryUrl();
return buildDistTagsUrl(url, PACKAGE_NAME)!;
}

export async function getLatestVersion(): Promise<string | null> {
const distTagsUrl = await defaultGetLatestVersionUrl();
if (!distTagsUrl) return null;

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);

try {
const response = await fetch(NPM_REGISTRY_URL, {
const response = await fetch(distTagsUrl, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
Expand All @@ -66,100 +76,156 @@ function getPossibleCacheRoots(): string[] {
return cacheDirs;
}

export function invalidatePackageCache(): boolean {
const cacheRoots = getPossibleCacheRoots();
export function getPackageCacheTargets(cacheRoot: string): string[] {
return [join(cacheRoot, PACKAGE_NAME), join(cacheRoot, `${PACKAGE_NAME}@latest`)];
}

export interface CacheInvalidationEffects {
existsSync: typeof existsSync;
rmSync: typeof rmSync;
}

const defaultCacheInvalidationEffects: CacheInvalidationEffects = { existsSync, rmSync };

export function invalidatePackageCache(
cacheRoots = getPossibleCacheRoots(),
effects: CacheInvalidationEffects = defaultCacheInvalidationEffects,
): boolean {
const seen = new Set<string>();
let removed = false;
let removalFailed = false;

for (const root of cacheRoots) {
if (seen.has(root)) continue;
seen.add(root);
if (!existsSync(root)) continue;

const packageDir = join(root, PACKAGE_NAME);
if (existsSync(packageDir)) {
for (const target of getPackageCacheTargets(root)) {
let exists: boolean;
try {
rmSync(packageDir, { recursive: true, force: true });
removed = true;
exists = effects.existsSync(target);
} catch {
removalFailed = true;
continue;
}
}

const specDir = join(root, `${PACKAGE_NAME}@latest`);
if (existsSync(specDir)) {
if (!exists) continue;
try {
rmSync(specDir, { recursive: true, force: true });
effects.rmSync(target, { recursive: true, force: true });
removed = true;
} catch {
removalFailed = true;
}
}
}
return removed;
return removed && !removalFailed;
}

export function isNewerVersion(latest: string, current: string): boolean {
return valid(latest) !== null && valid(current) !== null && gt(latest, current);
}

interface UpdateCheckEffects {
export interface UpdateCheckEffects {
getCurrentVersion: () => string | null;
getLatestVersionUrl?: () => Promise<string>;
getLatestVersion: () => Promise<string | null>;
invalidatePackageCache: () => boolean;
}

const defaultUpdateCheckEffects: UpdateCheckEffects = {
getCurrentVersion,
getLatestVersionUrl: defaultGetLatestVersionUrl,
getLatestVersion,
invalidatePackageCache,
};

export async function checkForUpdate(
effects: UpdateCheckEffects = defaultUpdateCheckEffects,
): Promise<UpdateCheckResult> {
const currentVersion = effects.getCurrentVersion();
let currentVersion: string | null;
try {
currentVersion = effects.getCurrentVersion();
} catch (error) {
return { outcome: 'check-failed', currentVersion: null, latestVersion: null, error: error instanceof Error ? error.message : 'Could not determine current version' };
}
if (!currentVersion) {
return {
needsUpdate: false,
currentVersion: null,
latestVersion: null,
error: 'Could not determine current version',
};
return { outcome: 'check-failed', currentVersion: null, latestVersion: null, error: 'Could not determine current version' };
}

let latestVersion: string | null;
try {
latestVersion = await effects.getLatestVersion();
if (effects.getLatestVersionUrl) {
const distTagsUrl = await effects.getLatestVersionUrl();
if (distTagsUrl) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
try {
const response = await fetch(distTagsUrl, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = (await response.json()) as Record<string, string>;
latestVersion = data.latest ?? null;
} else {
latestVersion = null;
}
} finally {
clearTimeout(timeoutId);
}
} else {
latestVersion = null;
}
} else {
latestVersion = await effects.getLatestVersion();
}
} catch {
latestVersion = null;
}
if (!latestVersion) {
return { outcome: 'check-failed', currentVersion, latestVersion: null, error: 'Could not fetch latest version from npm' };
}
if (valid(currentVersion) === null || valid(latestVersion) === null) {
return {
needsUpdate: false,
outcome: 'check-failed',
currentVersion,
latestVersion: null,
error: 'Could not fetch latest version from npm',
latestVersion,
error: 'Could not compare package versions',
};
}

if (!isNewerVersion(latestVersion, currentVersion)) {
return { needsUpdate: false, currentVersion, latestVersion };
return { outcome: 'up-to-date', currentVersion, latestVersion };
}

effects.invalidatePackageCache();
return { needsUpdate: true, currentVersion, latestVersion };
let invalidated: boolean;
try {
invalidated = effects.invalidatePackageCache();
} catch (error) {
return {
outcome: 'invalidation-failed',
currentVersion,
latestVersion,
error: error instanceof Error ? error.message : 'Could not invalidate the package cache',
};
}
if (!invalidated) {
return { outcome: 'invalidation-failed', currentVersion, latestVersion, error: 'Could not invalidate the package cache' };
}
return { outcome: 'update-staged', currentVersion, latestVersion };
}

export function formatUpdateMessage(result: UpdateCheckResult): {
title: string;
message: string;
variant: 'info' | 'success' | 'warning';
variant: 'info' | 'success' | 'warning' | 'error';
} {
if (!result.needsUpdate || !result.latestVersion) {
return { title: 'Tool Search', message: 'Up-to-date', variant: 'info' };
if (result.outcome === 'update-staged' && result.latestVersion) {
return {
title: 'Tool Search Update',
message: `v${result.currentVersion} -> v${result.latestVersion}. Restart OpenCode to apply.`,
variant: 'warning',
};
}
if (result.outcome === 'check-failed' || result.outcome === 'invalidation-failed') {
return { title: 'Tool Search Update Check', message: result.error ?? 'Update check failed.', variant: 'error' };
}
return {
title: 'Tool Search Update',
message: `v${result.currentVersion} -> v${result.latestVersion}. Restart OpenCode to apply.`,
variant: 'warning',
};
return { title: 'Tool Search', message: 'Up-to-date', variant: 'info' };
}
145 changes: 145 additions & 0 deletions src/hooks/npm-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { execFile as nodeExecFile } from 'node:child_process';

function execFileAsync(
cmd: string,
args: string[],
opts: { cwd?: string; timeout?: number },
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
nodeExecFile(cmd, args, opts, (err, stdout, stderr) => {
if (err) reject(err);
else resolve({ stdout, stderr });
});
});
}

const FALLBACK_REGISTRY = 'https://registry.npmjs.org';
const NPM_CONFIG_TIMEOUT = 5000;

// ---------------------------------------------------------------------------
// ResolveEffects – injected execFile for testability (no real npm in tests)
// ---------------------------------------------------------------------------

export interface ResolveEffects {
execFile: (
cmd: string,
args: string[],
opts: { cwd?: string; timeout?: number },
) => Promise<{ stdout: string; stderr: string }>;
}

async function defaultExecFile(
cmd: string,
args: string[],
opts: { cwd?: string; timeout?: number },
): Promise<{ stdout: string; stderr: string }> {
const { stdout } = await execFileAsync(cmd, args, opts);
return { stdout, stderr: '' };
}

// ---------------------------------------------------------------------------
// parseRegistryUrl
// ---------------------------------------------------------------------------

/**
* Validate a raw registry URL string.
*
* Accepts already-trimmed output (trims internally). Returns the normalised
* URL on success, `null` when the input is invalid: non-http(s) protocol,
* contains credentials, query-string, fragment, or is otherwise malformed.
*/
export function parseRegistryUrl(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;

let url: URL;
try {
url = new URL(trimmed);
} catch {
return null;
}

if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (url.username !== '' || url.password !== '') return null;
if (url.search !== '' || url.hash !== '') return null;

return url.href;
}

// ---------------------------------------------------------------------------
// buildDistTagsUrl
// ---------------------------------------------------------------------------

/**
* Build a dist-tags endpoint URL from a validated registry base and a package
* name. Preserves the registry's base path, percent-encodes the scoped
* package name exactly once, and rejects invalid / unsupported inputs.
*
* Returns `null` when either argument is invalid.
*/
export function buildDistTagsUrl(
registryBase: string,
packageName: string,
): string | null {
const validBase = parseRegistryUrl(registryBase);
if (!validBase) return null;

const trimmedPkg = packageName.trim();
if (!trimmedPkg) return null;

const base = validBase.endsWith('/') ? validBase.slice(0, -1) : validBase;
// Percent-encode the scoped prefix once: @scope/pkg → %40scope%2Fpkg
const encoded = trimmedPkg.replace(/@/g, '%40').replace(/\//g, '%2F');

return `${base}/-/package/${encoded}/dist-tags`;
}

// ---------------------------------------------------------------------------
// tryParseRegistryConfig – parse stdout of `npm config get`
// ---------------------------------------------------------------------------

function tryParseRegistryConfig(output: string): string | null {
return parseRegistryUrl(output);
}

// ---------------------------------------------------------------------------
// resolveRegistryUrl
// ---------------------------------------------------------------------------

/**
* Determine the npm registry base URL by probing `npm config` for the scoped
* `@openstellar:registry`, then the global `registry`, falling back to the
* public npmjs registry when either is missing or npm is unavailable.
*
* Uses injected `effects.execFile` so tests never invoke the real npm binary.
*/
export async function resolveRegistryUrl(
effects?: ResolveEffects,
): Promise<{ url: string; source: 'scoped' | 'default' | 'fallback' }> {
const exec = effects?.execFile ?? defaultExecFile;

// 1. Scoped registry: @openstellar:registry
try {
const { stdout } = await exec('npm', ['config', 'get', '@openstellar:registry'], {
timeout: NPM_CONFIG_TIMEOUT,
});
const url = tryParseRegistryConfig(stdout);
if (url) return { url, source: 'scoped' };
} catch {
// npm unavailable, timeout, or non-zero exit – fall through
}

// 2. Default registry: registry
try {
const { stdout } = await exec('npm', ['config', 'get', 'registry'], {
timeout: NPM_CONFIG_TIMEOUT,
});
const url = tryParseRegistryConfig(stdout);
if (url) return { url, source: 'default' };
} catch {
// fall through
}

// 3. Hard-coded fallback
return { url: FALLBACK_REGISTRY, source: 'fallback' };
}
Loading
Loading