diff --git a/CHANGELOG.md b/CHANGELOG.md index da17c93..5e96b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,39 @@ versions follow [SemVer](https://semver.org/). > section **above** Unreleased, or remove the Unreleased block for the > release commit. Otherwise the GitHub release ships boilerplate. +## [1.5.0] — 2026-05-27 + +Aligns the extension's version stream with the upstream +[`pipeline-check`](https://pypi.org/project/pipeline-check/) engine's +1.5.x line so users can read the two side-by-side without doing the +mapping in their head. No skipped functionality between 1.1.0 and +1.5.0 — the only user-visible change is the engine-update notifier +below. + +### Added + +- **Daily PyPI poll for a newer engine.** Once per 24 h (per-session + latch + per-day `globalState` timestamp), the extension queries + `https://pypi.org/pypi/pipeline-check/json` after a successful + preflight and surfaces a non-blocking notification when PyPI's + latest stable is newer than the engine the user has installed. + The **Upgrade in terminal** action runs the existing + [`upgradeInTerminal`](src/install.ts) flow — terminal opens, + `python -m pip install --upgrade "pipeline-check[lsp]"` is typed + but **not** auto-executed, same review-then-Enter pattern as the + install CTA. **Skip this version** silences the prompt for that + exact version only; a later release re-prompts. Dismissing the + toast (no choice) re-prompts on the next per-day window. Every + failure path (offline, PyPI 5xx, malformed JSON, timeout) is + silent — failures land in the Pipeline-Check output channel, not + in a user-facing toast for a background nicety they didn't ask + about. Independent of the hard `MIN_ENGINE_VERSION` floor in + [src/preflight.ts](src/preflight.ts), which still drives the + Upgrade welcome panel when the installed engine is too old to + support the extension's features. New setting + `pipelineCheck.engineUpdates.checkEnabled` (default `true`) turns + the check off entirely. + ## [1.1.0] — 2026-05-25 Feature batch on top of v1.0.3. Three user-visible additions plus diff --git a/package-lock.json b/package-lock.json index 7688f84..fb9c23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pipeline-check", - "version": "1.1.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pipeline-check", - "version": "1.1.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "vscode-languageclient": "^9.0.1" diff --git a/package.json b/package.json index 51b572c..cade500 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pipeline-check", "displayName": "Pipeline-Check", "description": "Lint CI/CD pipelines for 22 providers against OWASP Top 10 CI/CD Risks and 14 other compliance frameworks. 810+ rules, inline in your editor.", - "version": "1.1.0", + "version": "1.5.0", "publisher": "greylag-ci", "license": "MIT", "icon": "icon.png", @@ -333,6 +333,11 @@ ], "default": "off", "markdownDescription": "Traces LSP traffic between the editor and the `pipeline_check.lsp` server. Set to `verbose` when debugging." + }, + "pipelineCheck.engineUpdates.checkEnabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Check PyPI once per day for a newer `pipeline-check` engine and surface a non-blocking notification when one is available. The notification's **Upgrade in terminal** action opens a terminal with `python -m pip install --upgrade \"pipeline-check[lsp]\"` typed (but not auto-run, same pattern as the install CTA). **Skip this version** silences the prompt for the offered version only — a later release re-prompts. Set to `false` to disable the check entirely; this is independent of the hard `MIN_ENGINE_VERSION` floor that still drives the **Upgrade in terminal** welcome panel when the installed engine is too old to support the extension." } } } diff --git a/src/engineUpdates.test.ts b/src/engineUpdates.test.ts new file mode 100644 index 0000000..ec04ac0 --- /dev/null +++ b/src/engineUpdates.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Stub vscode so checkForEngineUpdate can read configuration and +// publish a notification without a real extension host. Mirrors +// the shape used by whatsNew.test.ts so the patterns are familiar. +vi.mock("vscode", () => { + const calls: { + showInformationMessage: Array<{ message: string; actions: string[] }>; + } = { showInformationMessage: [] }; + (globalThis as { __engineUpdatesCalls?: typeof calls }).__engineUpdatesCalls = + calls; + return { + window: { + showInformationMessage: (message: string, ...actions: string[]) => { + calls.showInformationMessage.push({ message, actions }); + const next = (globalThis as { __nextChoice?: string }).__nextChoice; + return Promise.resolve(next); + }, + }, + workspace: { + getConfiguration: (_section?: string) => ({ + get: (key: string, fallback?: T): T => { + const store = (globalThis as { __config?: Record }) + .__config ?? {}; + if (key in store) return store[key] as T; + return fallback as T; + }, + }), + }, + }; +}); + +// install.ts is pulled in transitively (upgradeInTerminal is the +// default `onUpgrade`). We mock it so production code never +// touches a real terminal during tests. +vi.mock("./install", () => ({ + upgradeInTerminal: () => { + (globalThis as { __upgradeCalls?: number }).__upgradeCalls = + ((globalThis as { __upgradeCalls?: number }).__upgradeCalls ?? 0) + 1; + }, +})); + +// log.ts is fine as-is (silent no-op without a channel), no mock +// needed. + +import { + _resetSessionLatchForTesting, + checkForEngineUpdate, + composeUpdateMessage, + DEFAULT_CHECK_INTERVAL_MS, + fetchLatestVersion, + LAST_CHECKED_STATE_KEY, + SKIPPED_VERSION_STATE_KEY, + shouldCheck, + type FetchImpl, +} from "./engineUpdates"; + +function getCalls() { + return ( + globalThis as { + __engineUpdatesCalls?: { + showInformationMessage: Array<{ message: string; actions: string[] }>; + }; + } + ).__engineUpdatesCalls!; +} + +function fakeContext(initial: Record = {}) { + const state: Record = { ...initial }; + return { + globalState: { + get(key: string): T | undefined { + return state[key] as T | undefined; + }, + async update(key: string, value: unknown): Promise { + state[key] = value; + }, + }, + } as unknown as import("vscode").ExtensionContext; +} + +function fakeFetch( + body: unknown, + options: { readonly ok?: boolean; readonly status?: number } = {}, +): FetchImpl { + return () => + Promise.resolve({ + ok: options.ok ?? true, + status: options.status ?? 200, + json: () => Promise.resolve(body), + }); +} + +beforeEach(() => { + _resetSessionLatchForTesting(); + getCalls().showInformationMessage.length = 0; + (globalThis as { __nextChoice?: string }).__nextChoice = undefined; + (globalThis as { __upgradeCalls?: number }).__upgradeCalls = 0; + (globalThis as { __config?: Record }).__config = {}; +}); + +// ─── shouldCheck ───────────────────────────────────────────────────── + +describe("shouldCheck", () => { + it("returns true when no prior check is recorded (first install)", () => { + expect(shouldCheck(1_000_000, undefined)).toBe(true); + }); + + it("returns true once the interval has elapsed", () => { + const oneDay = DEFAULT_CHECK_INTERVAL_MS; + expect(shouldCheck(oneDay + 1, 0)).toBe(true); + }); + + it("returns false within the interval", () => { + const oneHour = 60 * 60 * 1000; + expect(shouldCheck(oneHour, 0)).toBe(false); + }); + + it("returns true on exactly the interval boundary (>=, not >)", () => { + expect(shouldCheck(DEFAULT_CHECK_INTERVAL_MS, 0)).toBe(true); + }); + + it("recovers from a future-dated lastCheckedAt (clock skew defence)", () => { + // System clock moved backwards / corrupted state. Without the + // defence we'd be stuck waiting forever for `now` to catch up. + const farFuture = DEFAULT_CHECK_INTERVAL_MS * 10; + expect(shouldCheck(0, farFuture)).toBe(true); + }); +}); + +// ─── composeUpdateMessage ─────────────────────────────────────────── + +describe("composeUpdateMessage", () => { + it("pins both the current and latest version so the diff is visible at a glance", () => { + const msg = composeUpdateMessage("1.0.0", "1.5.0"); + expect(msg).toContain("v1.0.0"); + expect(msg).toContain("v1.5.0"); + }); +}); + +// ─── fetchLatestVersion ───────────────────────────────────────────── + +describe("fetchLatestVersion", () => { + it("returns the info.version string on a 200 response", async () => { + const v = await fetchLatestVersion({ + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + }); + expect(v).toBe("1.5.0"); + }); + + it("trims whitespace from the returned version", async () => { + const v = await fetchLatestVersion({ + fetchImpl: fakeFetch({ info: { version: " 1.5.0\n" } }), + }); + expect(v).toBe("1.5.0"); + }); + + it("returns undefined on a non-2xx response", async () => { + const v = await fetchLatestVersion({ + fetchImpl: fakeFetch({}, { ok: false, status: 503 }), + }); + expect(v).toBeUndefined(); + }); + + it("returns undefined when info.version is missing", async () => { + const v = await fetchLatestVersion({ fetchImpl: fakeFetch({ info: {} }) }); + expect(v).toBeUndefined(); + }); + + it("returns undefined when info.version is the empty string", async () => { + const v = await fetchLatestVersion({ + fetchImpl: fakeFetch({ info: { version: "" } }), + }); + expect(v).toBeUndefined(); + }); + + it("returns undefined on a fetch rejection (network error)", async () => { + const v = await fetchLatestVersion({ + fetchImpl: () => Promise.reject(new Error("ECONNREFUSED")), + }); + expect(v).toBeUndefined(); + }); + + it("returns undefined when the JSON body is null", async () => { + const v = await fetchLatestVersion({ fetchImpl: fakeFetch(null) }); + expect(v).toBeUndefined(); + }); + + it("returns undefined when no fetch implementation is available", async () => { + // Force the global-fetch lookup to fail by passing an explicit + // undefined override that bypasses globalThis.fetch. + const original = (globalThis as { fetch?: unknown }).fetch; + try { + (globalThis as { fetch?: unknown }).fetch = undefined; + const v = await fetchLatestVersion(); + expect(v).toBeUndefined(); + } finally { + (globalThis as { fetch?: unknown }).fetch = original; + } + }); +}); + +// ─── checkForEngineUpdate ─────────────────────────────────────────── + +describe("checkForEngineUpdate", () => { + // Each `it` uses a fresh fake context + the per-session latch + // reset in the global beforeEach. + + it("returns disabled when the setting is off", async () => { + (globalThis as { __config?: Record }).__config = { + "engineUpdates.checkEnabled": false, + }; + const ctx = fakeContext(); + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + }); + expect(outcome).toEqual({ kind: "disabled" }); + expect(getCalls().showInformationMessage).toHaveLength(0); + }); + + it("returns throttled when called twice in the same session", async () => { + const ctx = fakeContext(); + const first = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + onUpgrade: () => undefined, + }); + expect(first.kind).toBe("prompted"); + const second = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + }); + expect(second).toEqual({ kind: "throttled" }); + }); + + it("returns throttled when the per-day interval has not elapsed", async () => { + const now = 1_000_000_000; + const ctx = fakeContext({ + [LAST_CHECKED_STATE_KEY]: now - 60 * 1000, // 1 minute ago + }); + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + now: () => now, + }); + expect(outcome).toEqual({ kind: "throttled" }); + }); + + it("re-checks after the per-day interval elapses (cross-session)", async () => { + const now = 1_000_000_000; + const ctx = fakeContext({ + [LAST_CHECKED_STATE_KEY]: now - DEFAULT_CHECK_INTERVAL_MS - 1, + }); + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + now: () => now, + onUpgrade: () => undefined, + }); + expect(outcome.kind).toBe("prompted"); + }); + + it("returns fetch_failed when PyPI is unreachable", async () => { + const ctx = fakeContext(); + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: () => Promise.reject(new Error("ECONNREFUSED")), + }); + expect(outcome).toEqual({ kind: "fetch_failed" }); + expect(getCalls().showInformationMessage).toHaveLength(0); + }); + + it("returns no_newer when PyPI's latest equals the current version", async () => { + const ctx = fakeContext(); + const outcome = await checkForEngineUpdate(ctx, "1.5.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + }); + expect(outcome).toEqual({ kind: "no_newer", latestVersion: "1.5.0" }); + expect(getCalls().showInformationMessage).toHaveLength(0); + }); + + it("returns no_newer when the user is on a pre-release ahead of PyPI's stable", async () => { + // User installed 1.5.0rc1 manually; PyPI's stable is still + // 1.4.0. The pre-release outranks 1.4.0 numerically, so we + // should NOT prompt them to downgrade. + const ctx = fakeContext(); + const outcome = await checkForEngineUpdate(ctx, "1.5.0rc1", { + fetchImpl: fakeFetch({ info: { version: "1.4.0" } }), + }); + expect(outcome.kind).toBe("no_newer"); + expect(getCalls().showInformationMessage).toHaveLength(0); + }); + + it("returns skipped when the latest version matches a previously-skipped one", async () => { + const ctx = fakeContext({ + [SKIPPED_VERSION_STATE_KEY]: "1.5.0", + }); + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + }); + expect(outcome).toEqual({ kind: "skipped", latestVersion: "1.5.0" }); + expect(getCalls().showInformationMessage).toHaveLength(0); + }); + + it("prompts when a newer version is available and the user has not skipped it", async () => { + const ctx = fakeContext(); + (globalThis as { __nextChoice?: string }).__nextChoice = undefined; + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + onUpgrade: () => undefined, + }); + expect(outcome).toEqual({ + kind: "prompted", + latestVersion: "1.5.0", + choice: "dismissed", + }); + expect(getCalls().showInformationMessage).toHaveLength(1); + expect(getCalls().showInformationMessage[0].actions).toEqual([ + "Upgrade in terminal", + "Skip this version", + ]); + expect(getCalls().showInformationMessage[0].message).toContain("v1.5.0"); + expect(getCalls().showInformationMessage[0].message).toContain("v1.0.0"); + }); + + it("runs the upgrade action when the user picks Upgrade in terminal", async () => { + (globalThis as { __nextChoice?: string }).__nextChoice = + "Upgrade in terminal"; + const ctx = fakeContext(); + let upgradeFired = 0; + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + onUpgrade: () => { + upgradeFired += 1; + }, + }); + expect(outcome).toEqual({ + kind: "prompted", + latestVersion: "1.5.0", + choice: "upgrade", + }); + expect(upgradeFired).toBe(1); + }); + + it("persists the skipped version when the user picks Skip this version", async () => { + (globalThis as { __nextChoice?: string }).__nextChoice = "Skip this version"; + const ctx = fakeContext(); + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + onUpgrade: () => undefined, + }); + expect(outcome).toEqual({ + kind: "prompted", + latestVersion: "1.5.0", + choice: "skip", + }); + expect(ctx.globalState.get(SKIPPED_VERSION_STATE_KEY)).toBe("1.5.0"); + }); + + it("re-prompts for a NEWER release even after the user skipped an older one", async () => { + // The user said "Skip this version" for 1.5.0 last week. 1.6.0 + // shipped today. They should see THAT prompt — the skip is + // per-version, not "never bother me again". + const ctx = fakeContext({ + [SKIPPED_VERSION_STATE_KEY]: "1.5.0", + }); + const outcome = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: fakeFetch({ info: { version: "1.6.0" } }), + onUpgrade: () => undefined, + }); + expect(outcome.kind).toBe("prompted"); + expect((outcome as { latestVersion: string }).latestVersion).toBe("1.6.0"); + }); + + it("persists lastCheckedAt on a successful fetch (regardless of outcome)", async () => { + const now = 1_700_000_000_000; + const ctx = fakeContext(); + await checkForEngineUpdate(ctx, "1.5.0", { + // PyPI matches current → no_newer outcome + fetchImpl: fakeFetch({ info: { version: "1.5.0" } }), + now: () => now, + }); + expect(ctx.globalState.get(LAST_CHECKED_STATE_KEY)).toBe(now); + }); + + it("does NOT persist lastCheckedAt on a failed fetch (so the next session retries)", async () => { + const now = 1_700_000_000_000; + const ctx = fakeContext(); + await checkForEngineUpdate(ctx, "1.5.0", { + fetchImpl: () => Promise.reject(new Error("ECONNREFUSED")), + now: () => now, + }); + expect(ctx.globalState.get(LAST_CHECKED_STATE_KEY)).toBeUndefined(); + }); + + it("the per-session latch holds even when the fetch fails (no in-session retry storms)", async () => { + const ctx = fakeContext(); + const first = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: () => Promise.reject(new Error("ECONNREFUSED")), + }); + expect(first).toEqual({ kind: "fetch_failed" }); + let secondFetchCalled = false; + const second = await checkForEngineUpdate(ctx, "1.0.0", { + fetchImpl: () => { + secondFetchCalled = true; + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ info: { version: "1.5.0" } }), + }); + }, + }); + expect(second).toEqual({ kind: "throttled" }); + expect(secondFetchCalled).toBe(false); + }); +}); diff --git a/src/engineUpdates.ts b/src/engineUpdates.ts new file mode 100644 index 0000000..82832e0 --- /dev/null +++ b/src/engineUpdates.ts @@ -0,0 +1,274 @@ +// Daily PyPI poll for a newer `pipeline-check` engine. Fires a +// non-blocking notification when the latest release on PyPI is newer +// than the engine version the preflight just captured. The +// notification's primary action runs the existing `upgradeInTerminal` +// flow — terminal opens, command typed, user reviews and presses +// Enter. The extension never runs pip itself; the "no silent pip +// mutations against the wrong interpreter" invariant from +// [src/install.ts] holds here too. +// +// This is independent of the hard `MIN_ENGINE_VERSION` floor in +// [src/preflight.ts]: that floor drives the preflight's +// `out_of_date` rejection (and the welcome-panel Upgrade entry) when +// the user's engine is too old to support the extension's features. +// The check below is the opposite case — preflight succeeded, the +// engine is supported, but there's a newer one available. Two +// distinct UX paths, two distinct triggers. +// +// Throttle: +// - At most one PyPI fetch per session (module-level latch). A +// failed fetch does NOT update the per-day timestamp, but also +// does not retry within the same session — the next activation +// gets a fresh attempt. +// - At most one fetch per CHECK_INTERVAL_MS across sessions +// (globalState timestamp). Default 24 h. +// +// Persistence (globalState): +// - lastCheckedAt: ms epoch of the last successful PyPI fetch. +// - skippedVersion: version the user explicitly chose to skip via +// "Skip this version". A later release re-prompts. + +import * as vscode from "vscode"; +import { upgradeInTerminal } from "./install"; +import * as clientLog from "./log"; +import { isAtLeast } from "./preflight"; + +export const LAST_CHECKED_STATE_KEY = "pipelineCheck.engineUpdates.lastCheckedAt"; +export const SKIPPED_VERSION_STATE_KEY = "pipelineCheck.engineUpdates.skippedVersion"; + +export const SETTING_CHECK_ENABLED = "engineUpdates.checkEnabled"; + +// Default cadence: once per 24 hours. Chosen so a developer who +// keeps VS Code open across days sees an update within a day of it +// landing on PyPI, without paying the network round-trip on every +// activation. Exposed for tests that want to drive the throttle +// directly without mutating Date.now(). +export const DEFAULT_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +// PyPI's JSON endpoint for the `pipeline-check` package. The +// {info: {version: "X.Y.Z"}} field is the canonical "latest stable" +// release per PyPI's own classifier — pre-releases live under +// `releases` but don't take this slot, which matches what we want +// (a casual user shouldn't be nudged to install an RC). +export const PYPI_URL = "https://pypi.org/pypi/pipeline-check/json"; + +// Network-call ceiling. PyPI's JSON endpoint usually returns in +// well under a second; 5 s is generous without hanging activation +// on a slow / blocked link. +const DEFAULT_FETCH_TIMEOUT_MS = 5_000; + +// Per-session latch. Set on the first attempt (success OR failure) +// so we don't hit PyPI twice in one session — once is enough for +// the "is there a newer version?" question, and a transient failure +// during this VS Code window's lifetime shouldn't cause a retry +// loop. +let sessionChecked = false; + +/** + * Reset the per-session latch. Test-only; the production code + * never clears it (a new VS Code session is the natural reset). + */ +export function _resetSessionLatchForTesting(): void { + sessionChecked = false; +} + +/** + * Minimal fetch surface the orchestration depends on. Production + * uses Node's global `fetch` (Node 18+, available in every VS Code + * 1.85+ host); tests inject a deterministic implementation. + */ +export type FetchImpl = ( + url: string, + init?: { readonly signal?: AbortSignal }, +) => Promise<{ + readonly ok: boolean; + readonly status: number; + json: () => Promise; +}>; + +/** + * Query PyPI for the latest stable version of `pipeline-check`. + * Returns the version string on success, undefined on any failure + * (network error, non-2xx, malformed JSON, timeout). Failures are + * logged to the client log channel — the user never sees a toast + * about "PyPI was unreachable", because that's noise for a + * background nicety they didn't ask about. + */ +export async function fetchLatestVersion(options: { + readonly fetchImpl?: FetchImpl; + readonly timeoutMs?: number; +} = {}): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + const fetchImpl = options.fetchImpl ?? (globalThis.fetch as FetchImpl | undefined); + if (!fetchImpl) { + clientLog.warn("engine-updates: no fetch implementation available"); + return undefined; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetchImpl(PYPI_URL, { signal: controller.signal }); + if (!res.ok) { + clientLog.warn(`engine-updates: PyPI returned HTTP ${res.status}`); + return undefined; + } + const body = (await res.json()) as { info?: { version?: unknown } } | null; + const version = body?.info?.version; + if (typeof version !== "string" || version.length === 0) { + clientLog.warn("engine-updates: PyPI response missing info.version"); + return undefined; + } + return version.trim(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + clientLog.warn(`engine-updates: PyPI fetch failed — ${message}`); + return undefined; + } finally { + clearTimeout(timer); + } +} + +/** + * Pure throttle check. Returns true when enough time has elapsed + * since the last successful PyPI fetch to warrant another one. + * Treats undefined `lastCheckedAt` as "never checked, due now", + * which makes first-install fire on the user's first activation. + */ +export function shouldCheck( + now: number, + lastCheckedAt: number | undefined, + intervalMs: number = DEFAULT_CHECK_INTERVAL_MS, +): boolean { + if (lastCheckedAt === undefined) return true; + // Defensive against clock skew (system clock moved backwards while + // VS Code was running, or the persisted value got corrupted to a + // future timestamp). Either way, "more than intervalMs in the + // future" is bogus state; treat it as "due now" so we recover on + // the next fetch. + if (lastCheckedAt > now + intervalMs) return true; + return now - lastCheckedAt >= intervalMs; +} + +/** + * Compose the user-facing notification text. Exported for unit + * testing. Pins both versions so the user knows exactly what's + * changing without expanding the notification. + */ +export function composeUpdateMessage( + currentVersion: string, + latestVersion: string, +): string { + return `Pipeline-Check engine v${latestVersion} is available (you have v${currentVersion}).`; +} + +export interface CheckOptions { + /** Override the per-day cadence (tests). */ + readonly intervalMs?: number; + /** Inject a deterministic clock (tests). */ + readonly now?: () => number; + /** Inject a fake PyPI fetch (tests). */ + readonly fetchImpl?: FetchImpl; + /** Inject the upgrade trigger so tests can assert it without spawning a terminal. */ + readonly onUpgrade?: () => void; + /** + * Bypass the per-session latch. Tests that exercise multiple + * decision branches in one file need this; production never + * passes it. + */ + readonly bypassSessionLatch?: boolean; +} + +/** + * The outcome the caller (and tests) can dispatch on. `disabled` + * and `throttled` are the silent no-ops; `no_newer` means we + * checked and the user is already current; `skipped` means the + * latest version matches a previously-skipped one; `prompted` + * means the notification fired. Includes the chosen action when + * the user engaged. + */ +export type CheckOutcome = + | { readonly kind: "disabled" } + | { readonly kind: "throttled" } + | { readonly kind: "fetch_failed" } + | { readonly kind: "no_newer"; readonly latestVersion: string } + | { readonly kind: "skipped"; readonly latestVersion: string } + | { + readonly kind: "prompted"; + readonly latestVersion: string; + readonly choice: "upgrade" | "skip" | "dismissed"; + }; + +/** + * Run the daily PyPI check. Fire-and-forget from the caller's + * perspective — every failure path is silent and logged. Returns + * the outcome so tests (and any future telemetry surface) can + * confirm which branch fired. + * + * Wired from [src/extension.ts] after a successful preflight: at + * that point we know the engine version and the user has a + * working install, so the upgrade prompt is actionable. + */ +export async function checkForEngineUpdate( + context: vscode.ExtensionContext, + currentEngineVersion: string, + options: CheckOptions = {}, +): Promise { + const config = vscode.workspace.getConfiguration("pipelineCheck"); + if (!config.get(SETTING_CHECK_ENABLED, true)) { + return { kind: "disabled" }; + } + if (sessionChecked && !options.bypassSessionLatch) { + return { kind: "throttled" }; + } + const now = (options.now ?? Date.now)(); + const lastCheckedAt = context.globalState.get(LAST_CHECKED_STATE_KEY); + const intervalMs = options.intervalMs ?? DEFAULT_CHECK_INTERVAL_MS; + if (!shouldCheck(now, lastCheckedAt, intervalMs)) { + return { kind: "throttled" }; + } + // Latch BEFORE the await so a concurrent caller in the same + // session can't slip a second fetch in while we wait on the + // network. Cleared only by the per-session reset (test seam). + sessionChecked = true; + const latestVersion = await fetchLatestVersion({ fetchImpl: options.fetchImpl }); + if (!latestVersion) { + return { kind: "fetch_failed" }; + } + // Persist the success timestamp before any UI work so a missed + // toast (user closed the window before clicking) doesn't cause + // a re-prompt at the next activation. Same lesson as + // showWhatsNewIfUpgraded — persist-then-prompt is safer than + // prompt-then-persist for fire-and-forget surfaces. + await context.globalState.update(LAST_CHECKED_STATE_KEY, now); + if (!isAtLeast(latestVersion, currentEngineVersion) || + latestVersion === currentEngineVersion) { + // Either the same version or older (the user is on a + // pre-release ahead of PyPI's stable). Nothing to nudge about. + return { kind: "no_newer", latestVersion }; + } + // isAtLeast(latest, current) is true AND they aren't equal, so + // latest is strictly newer than current. + const skippedVersion = context.globalState.get(SKIPPED_VERSION_STATE_KEY); + if (skippedVersion === latestVersion) { + return { kind: "skipped", latestVersion }; + } + clientLog.info( + `engine-updates: prompting (current v${currentEngineVersion}, latest v${latestVersion})`, + ); + const UPGRADE = "Upgrade in terminal"; + const SKIP = "Skip this version"; + const choice = await vscode.window.showInformationMessage( + composeUpdateMessage(currentEngineVersion, latestVersion), + UPGRADE, + SKIP, + ); + if (choice === UPGRADE) { + (options.onUpgrade ?? upgradeInTerminal)(); + return { kind: "prompted", latestVersion, choice: "upgrade" }; + } + if (choice === SKIP) { + await context.globalState.update(SKIPPED_VERSION_STATE_KEY, latestVersion); + return { kind: "prompted", latestVersion, choice: "skip" }; + } + return { kind: "prompted", latestVersion, choice: "dismissed" }; +} diff --git a/src/extension.ts b/src/extension.ts index d768292..633b953 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -21,6 +21,7 @@ import { } from "vscode-languageclient/node"; import { PipelineCheckCodeActionProvider } from "./codeActions"; import { FindingsCodeLensProvider } from "./codeLens"; +import { checkForEngineUpdate } from "./engineUpdates"; import { FindingsTreeProvider } from "./findingsView"; import { copyInstallCommandToClipboard, @@ -102,6 +103,11 @@ let client: LanguageClient | undefined; // crash on the previous client would still fire into our handler and // flip `lspReady` against the live client. let clientStateChangeDisposable: vscode.Disposable | undefined; +// Captured at activate() so startClient (and any restart triggered +// later) can reach globalState for the engine-update check without +// the activate→startClient call having to plumb it through every +// internal hop. Cleared on deactivate. +let extensionContext: vscode.ExtensionContext | undefined; // Hard ceiling on how long `client.start()` is allowed to run before // we treat the LSP as broken. Without this, a `serverArgs: []` // (configured Python interpreter drops into the REPL waiting on @@ -212,6 +218,13 @@ async function startClient(): Promise { // user can confirm at a glance which engine they're talking to — // useful when triaging a "why isn't this rule firing?" report. setEngineVersion(version); + // Fire-and-forget the daily PyPI poll for a newer engine + // version. Every failure path inside `checkForEngineUpdate` is + // silent (logged, no toast); the function self-throttles via + // globalState so this call is safe on every startClient pass. + if (extensionContext) { + void checkForEngineUpdate(extensionContext, version); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); clientLog.error(`language server: preflight failed — ${message}`); @@ -394,6 +407,10 @@ async function stopClient(): Promise { export async function activate( context: vscode.ExtensionContext, ): Promise { + // Stash the context so startClient (and any subsequent restart) + // can reach globalState for the daily engine-update check. Cleared + // in deactivate to avoid leaking a reference across host restarts. + extensionContext = context; // The Findings tree reads from already-published diagnostics, so we // wire it up before starting the client. That way, if the server // takes a moment to come up (or fails outright), the panel is still @@ -636,4 +653,5 @@ export async function activate( export async function deactivate(): Promise { await stopClient(); + extensionContext = undefined; }