Sfoglia il codice sorgente

fix(auto-update): avoid major-version upgrades

Alvin Unreal 2 mesi fa
parent
commit
c0753e70a9

+ 4 - 0
docs/configuration.md

@@ -173,6 +173,10 @@ With `autoUpdate` set to `false`, this becomes notification-only mode: you'll
 see that a new version is available, but the plugin won't install it
 automatically.
 
+Auto-update never crosses major versions. For example, a 1.x install can
+auto-update to a newer 1.x release, but it won't auto-install 2.x. When a newer
+major is available, the plugin shows a migration command instead.
+
 > Pinned plugin entries in `opencode.json` (for example
 > `"oh-my-opencode-slim@1.0.1"`) are the true version lock. Those stay pinned
 > regardless of `autoUpdate`.

+ 61 - 0
src/hooks/auto-update-checker/checker.test.ts

@@ -156,4 +156,65 @@ describe('auto-update-checker/checker', () => {
       readSpy.mockRestore();
     });
   });
+
+  describe('getLatestCompatibleVersion', () => {
+    test('selects latest version within current major', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '2.0.0',
+          },
+          versions: {
+            '1.1.0': {},
+            '1.1.2': {},
+            '2.0.0': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.1.1');
+
+      expect(result).toEqual({
+        latestVersion: '1.1.2',
+        latestMajorVersion: '2.0.0',
+        blockedByMajor: true,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+
+    test('does not report major block when latest is same major', async () => {
+      const originalFetch = globalThis.fetch;
+      globalThis.fetch = mock(async () =>
+        Response.json({
+          'dist-tags': {
+            latest: '1.1.2',
+          },
+          versions: {
+            '1.1.1': {},
+            '1.1.2': {},
+          },
+        }),
+      ) as never;
+
+      const { getLatestCompatibleVersion } = await import(
+        `./checker?test=${importCounter++}`
+      );
+
+      const result = await getLatestCompatibleVersion('1.1.1');
+
+      expect(result).toEqual({
+        latestVersion: '1.1.2',
+        latestMajorVersion: '1.1.2',
+        blockedByMajor: false,
+      });
+
+      globalThis.fetch = originalFetch;
+    });
+  });
 });

+ 128 - 0
src/hooks/auto-update-checker/checker.ts

@@ -6,18 +6,28 @@ import { log } from '../../utils/logger';
 import {
   INSTALLED_PACKAGE_JSON,
   NPM_FETCH_TIMEOUT,
+  NPM_PACKAGE_URL,
   NPM_REGISTRY_URL,
   PACKAGE_NAME,
   USER_OPENCODE_CONFIG,
   USER_OPENCODE_CONFIG_JSONC,
 } from './constants';
 import type {
+  CompatibleVersionResult,
   NpmDistTags,
+  NpmPackageMetadata,
   OpencodeConfig,
   PackageJson,
   PluginEntryInfo,
 } from './types';
 
+interface ParsedVersion {
+  major: number;
+  minor: number;
+  patch: number;
+  prerelease: string | null;
+}
+
 function isString(value: unknown): value is string {
   return typeof value === 'string';
 }
@@ -40,6 +50,53 @@ function isDistTag(version: string): boolean {
   return !/^\d/.test(version);
 }
 
+function parseVersion(version: string): ParsedVersion | null {
+  const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?/);
+  if (!match) return null;
+
+  return {
+    major: Number(match[1]),
+    minor: Number(match[2]),
+    patch: Number(match[3]),
+    prerelease: match[4] ?? null,
+  };
+}
+
+function compareVersions(a: string, b: string): number {
+  const parsedA = parseVersion(a);
+  const parsedB = parseVersion(b);
+  if (!parsedA || !parsedB) return a.localeCompare(b);
+
+  const parts: Array<keyof Pick<ParsedVersion, 'major' | 'minor' | 'patch'>> = [
+    'major',
+    'minor',
+    'patch',
+  ];
+  for (const part of parts) {
+    if (parsedA[part] !== parsedB[part]) {
+      return parsedA[part] - parsedB[part];
+    }
+  }
+
+  if (parsedA.prerelease === parsedB.prerelease) return 0;
+  if (!parsedA.prerelease) return 1;
+  if (!parsedB.prerelease) return -1;
+  return parsedA.prerelease.localeCompare(parsedB.prerelease);
+}
+
+function getPrereleaseChannel(version: ParsedVersion): string | null {
+  if (!version.prerelease) return null;
+
+  return version.prerelease.match(/^(alpha|beta|rc|canary|next)/)?.[1] ?? null;
+}
+
+function isVersionInChannel(version: string, channel: string): boolean {
+  const parsed = parseVersion(version);
+  if (!parsed) return false;
+  if (channel === 'latest') return parsed.prerelease === null;
+  return getPrereleaseChannel(parsed) === channel;
+}
+
 /**
  * Extracts the update channel (latest, alpha, beta, etc.) from a version string.
  * @param version The version or tag to analyze.
@@ -306,3 +363,74 @@ export async function getLatestVersion(
     clearTimeout(timeoutId);
   }
 }
+
+/**
+ * Resolves the newest version that is safe for the current install to use.
+ * Auto-update never crosses major versions; newer majors are surfaced as a
+ * manual migration notification instead.
+ */
+export async function getLatestCompatibleVersion(
+  currentVersion: string,
+  channel: string = 'latest',
+): Promise<CompatibleVersionResult> {
+  const current = parseVersion(currentVersion);
+  if (!current) {
+    const latestVersion = await getLatestVersion(channel);
+    return {
+      latestVersion,
+      latestMajorVersion: latestVersion,
+      blockedByMajor: false,
+    };
+  }
+
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT);
+
+  try {
+    const response = await fetch(NPM_PACKAGE_URL, {
+      signal: controller.signal,
+      headers: { Accept: 'application/json' },
+    });
+
+    if (!response.ok) return await getCompatibleFromDistTags(current, channel);
+
+    const data = (await response.json()) as NpmPackageMetadata;
+    const distTags = data['dist-tags'] ?? { latest: '' };
+    const latestMajorVersion = distTags.latest || null;
+    const taggedVersion = distTags[channel] ?? distTags.latest ?? null;
+    const tagged = taggedVersion ? parseVersion(taggedVersion) : null;
+    const blockedByMajor = Boolean(tagged && tagged.major > current.major);
+
+    const versions = Object.keys(data.versions ?? {})
+      .filter((version) => {
+        const parsed = parseVersion(version);
+        return (
+          parsed?.major === current.major &&
+          isVersionInChannel(version, channel)
+        );
+      })
+      .sort(compareVersions);
+    const latestVersion = versions.at(-1) ?? null;
+
+    return { latestVersion, latestMajorVersion, blockedByMajor };
+  } catch {
+    return await getCompatibleFromDistTags(current, channel);
+  } finally {
+    clearTimeout(timeoutId);
+  }
+}
+
+async function getCompatibleFromDistTags(
+  current: ParsedVersion,
+  channel: string,
+): Promise<CompatibleVersionResult> {
+  const latestVersion = await getLatestVersion(channel);
+  const latest = latestVersion ? parseVersion(latestVersion) : null;
+  const blockedByMajor = Boolean(latest && latest.major > current.major);
+
+  return {
+    latestVersion: blockedByMajor ? null : latestVersion,
+    latestMajorVersion: latestVersion,
+    blockedByMajor,
+  };
+}

+ 1 - 0
src/hooks/auto-update-checker/constants.ts

@@ -4,6 +4,7 @@ import { getOpenCodeConfigPaths } from '../../cli/config-manager';
 
 export const PACKAGE_NAME = 'oh-my-opencode-slim';
 export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
+export const NPM_PACKAGE_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`;
 export const NPM_FETCH_TIMEOUT = 5000;
 
 function getCacheDir(): string {

+ 65 - 4
src/hooks/auto-update-checker/index.test.ts

@@ -6,6 +6,11 @@ const checkerMocks = {
   extractChannel: mock(() => 'latest'),
   findPluginEntry: mock(() => null),
   getCachedVersion: mock(() => null),
+  getLatestCompatibleVersion: mock(async () => ({
+    latestVersion: null,
+    latestMajorVersion: null,
+    blockedByMajor: false,
+  })),
   getLatestVersion: mock(async () => null),
   getLocalDevVersion: mock(() => null),
   getCurrentRuntimePackageJsonPath: mock(() => null),
@@ -82,6 +87,12 @@ describe('auto-update-checker/index', () => {
     checkerMocks.findPluginEntry.mockImplementation(() => null);
     checkerMocks.getCachedVersion.mockReset();
     checkerMocks.getCachedVersion.mockImplementation(() => null);
+    checkerMocks.getLatestCompatibleVersion.mockReset();
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: null,
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
     checkerMocks.getLatestVersion.mockReset();
     checkerMocks.getLatestVersion.mockImplementation(async () => null);
     checkerMocks.getLocalDevVersion.mockReset();
@@ -140,7 +151,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: '0.9.11',
+      blockedByMajor: false,
+    }));
 
     crossSpawnMock.mockImplementation(() => ({
       exited: Promise.resolve(0),
@@ -184,7 +199,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: '0.9.11',
+      blockedByMajor: false,
+    }));
 
     const { createAutoUpdateCheckerHook } = await import(
       `./index?test=${importCounter++}`
@@ -215,7 +234,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: '0.9.11',
+      blockedByMajor: false,
+    }));
     cacheMocks.preparePackageUpdate.mockImplementation(() => null);
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -245,7 +268,11 @@ describe('auto-update-checker/index', () => {
       isPinned: false,
     }));
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
-    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: '0.9.11',
+      blockedByMajor: false,
+    }));
 
     crossSpawnMock.mockImplementation(() => ({
       exited: Promise.resolve(1),
@@ -279,4 +306,38 @@ describe('auto-update-checker/index', () => {
       },
     });
   });
+
+  test('does not auto-update across major versions', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '1.1.2');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '1.1.2',
+      latestMajorVersion: '2.0.0',
+      blockedByMajor: true,
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'oh-my-opencode-slim v2.0.0 is available.',
+        message:
+          'It requires OpenCode background subagents.\nRun: bunx oh-my-opencode-slim@latest install --background-subagents=yes',
+        variant: 'info',
+        duration: 12000,
+      },
+    });
+    expect(cacheMocks.preparePackageUpdate).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
+  });
 });

+ 17 - 2
src/hooks/auto-update-checker/index.ts

@@ -6,7 +6,7 @@ import {
   extractChannel,
   findPluginEntry,
   getCachedVersion,
-  getLatestVersion,
+  getLatestCompatibleVersion,
   getLocalDevVersion,
 } from './checker';
 import { CACHE_DIR, PACKAGE_NAME } from './constants';
@@ -77,7 +77,12 @@ async function runBackgroundUpdateCheck(
   }
 
   const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion);
-  const latestVersion = await getLatestVersion(channel);
+  const latestInfo = await getLatestCompatibleVersion(currentVersion, channel);
+  if (latestInfo.blockedByMajor && latestInfo.latestMajorVersion) {
+    showMajorUpgradeToast(ctx, latestInfo.latestMajorVersion);
+  }
+
+  const latestVersion = latestInfo.latestVersion;
   if (!latestVersion) {
     log(
       '[auto-update-checker] Failed to fetch latest version for channel:',
@@ -160,6 +165,16 @@ async function runBackgroundUpdateCheck(
   }
 }
 
+function showMajorUpgradeToast(ctx: PluginInput, version: string): void {
+  showToast(
+    ctx,
+    `oh-my-opencode-slim v${version} is available.`,
+    'It requires OpenCode background subagents.\nRun: bunx oh-my-opencode-slim@latest install --background-subagents=yes',
+    'info',
+    12_000,
+  );
+}
+
 export function getAutoUpdateInstallDir(): string {
   return resolveInstallContext()?.installDir ?? CACHE_DIR;
 }

+ 11 - 0
src/hooks/auto-update-checker/types.ts

@@ -3,6 +3,17 @@ export interface NpmDistTags {
   [key: string]: string;
 }
 
+export interface NpmPackageMetadata {
+  'dist-tags'?: NpmDistTags;
+  versions?: Record<string, unknown>;
+}
+
+export interface CompatibleVersionResult {
+  latestVersion: string | null;
+  latestMajorVersion: string | null;
+  blockedByMajor: boolean;
+}
+
 export interface OpencodeConfig {
   plugin?: unknown[];
   [key: string]: unknown;