diff --git a/.github/workflows/sync-plugins.yml b/.github/workflows/sync-plugins.yml index cbb1779..79be4cf 100644 --- a/.github/workflows/sync-plugins.yml +++ b/.github/workflows/sync-plugins.yml @@ -30,8 +30,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }} TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }} - # 探测安装方式:新收录的仓库 + 超过 7 天没探过的重探(作者可能发布 npm、 - # GitHub Release tarball 或补 prepare)。每个有效 bundle 最多请求一次 Releases API。 + # 探测安装方式:新收录、上次探测后有 push,或超过 7 天没探过的仓库。 + # 作者可能发布 npm / Release 或补 prepare;每个有效 bundle 最多请求一次 Releases API。 - run: pnpm probe:install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/lib/install-metadata.test.mjs b/scripts/lib/install-metadata.test.mjs new file mode 100644 index 0000000..37f2264 --- /dev/null +++ b/scripts/lib/install-metadata.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createClient } from "@libsql/client"; + +import { + installProbeFilter, + resolveLiveInstallDisplay, +} from "../../src/lib/install-metadata.mjs"; + +test("a repository pushed after its fresh install probe is selected again", async (t) => { + const client = createClient({ url: "file::memory:" }); + t.after(() => client.close()); + await client.execute(`CREATE TABLE plugins ( + full_name TEXT, is_present INTEGER, is_offtopic INTEGER, + pushed_at TEXT, install_probed_at TEXT, stars INTEGER + )`); + await client.batch( + [ + ["pushed/repo", "2026-08-16T01:00:00.000Z", "2026-08-15T01:00:00.000Z"], + ["quiet/repo", "2026-08-14T01:00:00.000Z", "2026-08-15T01:00:00.000Z"], + ].map(([fullName, pushedAt, probedAt], stars) => ({ + sql: "INSERT INTO plugins VALUES (?, 1, 0, ?, ?, ?)", + args: [fullName, pushedAt, probedAt, stars], + })), + "write", + ); + + const filter = installProbeFilter({ + only: [], + rederive: false, + all: false, + staleDays: 7, + now: Date.parse("2026-08-16T02:00:00.000Z"), + }); + const selected = await client.execute({ + sql: `SELECT full_name FROM plugins + WHERE is_present = 1 AND is_offtopic = 0${filter.sql} + ORDER BY stars DESC`, + args: filter.args, + }); + assert.deepEqual(selected.rows.map((row) => String(row.full_name)), ["pushed/repo"]); +}); + +test("a live null never falls back to a stale editorial install command", () => { + const display = resolveLiveInstallDisplay({ + liveCuratedCmd: null, + automaticCmd: null, + automaticKind: null, + editorialCmd: "dsh plugin add old-release.tgz", + }); + assert.deepEqual(display, { installCmd: null, installKind: null }); +}); diff --git a/scripts/lib/install.test.mjs b/scripts/lib/install.test.mjs index b5a4b4e..8ccb863 100644 --- a/scripts/lib/install.test.mjs +++ b/scripts/lib/install.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import { @@ -264,3 +265,13 @@ test("preserves npm, build-required, git, and not-installable fallbacks", () => ); assert.equal(deriveInstall(installFacts({ hasBundle: false })).kind, "not-installable"); }); + +test("probe preserves an intentional curated pin even if it equals an earlier automatic command", async () => { + // String equality cannot establish provenance: an operator may deliberately keep that exact pin. + // Guard the write statement itself so probing may refresh install_cmd_auto but never install_cmd. + const source = await readFile(new URL("../probe-install.mjs", import.meta.url), "utf8"); + const update = source.match(/sql: `UPDATE plugins SET([\s\S]*?)WHERE full_name = \?`/); + assert.ok(update, "probe UPDATE statement not found"); + assert.doesNotMatch(update[1], /(?:^|[,\s])install_cmd\s*=/m); + assert.match(update[1], /(?:^|[,\s])install_cmd_auto\s*=/m); +}); diff --git a/scripts/probe-install.mjs b/scripts/probe-install.mjs index 9098e84..a59601a 100644 --- a/scripts/probe-install.mjs +++ b/scripts/probe-install.mjs @@ -3,7 +3,7 @@ * 探测每个收录仓库「到底怎么装」,把事实与推导结论写回 Turso plugins 表。 * * 用法: - * pnpm probe:install # 探测所有从未探过 / 超过 7 天没探的仓库 + * pnpm probe:install # 探测新仓库、最近有 push 或超过 7 天没探的仓库 * pnpm probe:install --stale-days 30 # 换个新鲜度阈值 * pnpm probe:install --all # 无视新鲜度,全部重探 * pnpm probe:install --only owner/repo # 只探一个(可重复传) @@ -29,6 +29,7 @@ import { mergeReleaseProbe, probeTimestamp, } from "./lib/github-release-probe.mjs"; +import { installProbeFilter } from "../src/lib/install-metadata.mjs"; const CONCURRENCY = 8; const DEFAULT_STALE_DAYS = 7; @@ -217,16 +218,11 @@ let sql = `SELECT full_name, pkg_name, pkg_version, pkg_private, has_bundle, has release_asset_size, release_asset_digest, release_etag, install_probed_at FROM plugins WHERE is_present = 1 AND is_offtopic = 0`; const args = []; -if (only.length) { - sql += ` AND lower(full_name) IN (${only.map(() => "?").join(",")})`; - args.push(...only.map((s) => s.toLowerCase())); -} else if (rederive) { - sql += ` AND install_probed_at IS NOT NULL`; -} else if (!all) { - const cutoff = new Date(Date.now() - staleDays * 86400_000).toISOString(); - sql += ` AND (install_probed_at IS NULL OR install_probed_at < ?)`; - args.push(cutoff); -} +// sync:db 已在本任务前刷新 pushed_at。仓库刚有 push 时不等满 7 天,下一轮就重探; +// 没有活动的仓库仍按 staleDays 兜底,避免每天扫描全部安装元数据。 +const filter = installProbeFilter({ only, rederive, all, staleDays }); +sql += filter.sql; +args.push(...filter.args); sql += ` ORDER BY stars DESC`; const rows = (await client.execute({ sql, args })).rows; diff --git a/src/app/[locale]/plugins/[owner]/[repo]/page.tsx b/src/app/[locale]/plugins/[owner]/[repo]/page.tsx index 9583c1a..57e2b74 100644 --- a/src/app/[locale]/plugins/[owner]/[repo]/page.tsx +++ b/src/app/[locale]/plugins/[owner]/[repo]/page.tsx @@ -23,6 +23,7 @@ import { getPluginDetail } from "@/lib/plugins-db"; import { isLocale, type Locale } from "@/i18n/config"; import { pageAlternates, SITE_URL } from "@/lib/site"; import { ShareCardBox } from "@/components/share-card-box"; +import { resolveLiveInstallDisplay } from "@/lib/install-metadata.mjs"; type Params = Promise<{ locale: string; owner: string; repo: string }>; @@ -73,12 +74,16 @@ export default async function PluginDetailPage({ const live = plugin.i18n[loc]; const intro = live?.intro ?? editorial?.intro?.[loc]; const highlights = live?.highlights ?? editorial?.highlights?.[loc]; - // 安装命令三个来源,优先级递减:运营人工核对 → 构建期生成物 → 按 package.json/npm 推导。 + // 安装命令只读实时库:运营人工核对 → 按 package.json/npm 推导。安装目标会过期, + // DB 明确返回 null 时不能再让构建期快照复活一条已撤销的旧命令;DB 不可用时 + // getPluginDetail 会把安装结论整体置空并引导用户看仓库 README。 // 都没有时不再拿 fullName 硬拼 `github:` —— 仓库名不足以推出安装方式,编出来的命令 // 对索引仓库、非组合包、未构建的 TS 包一律是错的(判定见 scripts/lib/install.mjs)。 - const curatedCmd = plugin.installCmd ?? editorial?.installCmd ?? null; - const installCmd = curatedCmd ?? plugin.installCmdAuto; - const installKind = curatedCmd ? "curated" : plugin.installKind; + const { installCmd, installKind } = resolveLiveInstallDisplay({ + liveCuratedCmd: plugin.installCmd, + automaticCmd: plugin.installCmdAuto, + automaticKind: plugin.installKind, + }); const description = live?.description ?? localizePluginDescription(plugin.fullName, locale, plugin.description); diff --git a/src/lib/install-metadata.mjs b/src/lib/install-metadata.mjs new file mode 100644 index 0000000..00e1f8a --- /dev/null +++ b/src/lib/install-metadata.mjs @@ -0,0 +1,37 @@ +const DAY_MS = 86_400_000; + +/** Build the mutually exclusive scope filter used by the install probe query. */ +export function installProbeFilter({ + only, + rederive, + all, + staleDays, + now = Date.now(), +}) { + if (only.length) { + return { + sql: ` AND lower(full_name) IN (${only.map(() => "?").join(",")})`, + args: only.map((name) => name.toLowerCase()), + }; + } + if (rederive) return { sql: " AND install_probed_at IS NOT NULL", args: [] }; + if (all) return { sql: "", args: [] }; + + const cutoff = new Date(now - staleDays * DAY_MS).toISOString(); + return { + sql: " AND (install_probed_at IS NULL OR install_probed_at < ? OR pushed_at > install_probed_at)", + args: [cutoff], + }; +} + +/** Resolve install UI strictly from live database fields, never from an editorial snapshot. */ +export function resolveLiveInstallDisplay({ + liveCuratedCmd, + automaticCmd, + automaticKind, +}) { + return { + installCmd: liveCuratedCmd ?? automaticCmd, + installKind: liveCuratedCmd ? "curated" : automaticKind, + }; +}