← back to Kickbacks Ai Upstream

src/activation/selfUpdate.ts

186 lines

import * as vscode from "vscode";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeFileSync, readFileSync, statSync } from "node:fs";
import { UpdateClient, isNewer } from "../update/client";
import { timeoutFetch } from "../util/http";
import { buildVersion } from "../buildinfo";
import { dlog } from "../log";
import { errMsg } from "../util/errMsg";
import { DEFAULT_POLL_MS } from "../config";

const UPD_KEY = "vibe-ads.update.attempted";
const UPD_TRANSIENT_KEY = "vibe-ads.update.transient";
// Single-slot SUCCESS record {k, v, ts} (trey-nag-loop 2026-06-11): the
// artifact that last installed without throwing. Unlike the attempted ring
// it has NO cooldown — a successfully-installed artifact must never
// re-install/re-toast just because the user hasn't reloaded yet. A single
// slot (not a ring) keeps the rollback contract working: a rollback artifact
// differs from the latest success, so it is never suppressed by old history.
const UPD_INSTALLED_KEY = "vibe-ads.update.installed";
const UPD_COOLDOWN_MS = 30 * 60 * 1000;
const UPD_TRANSIENT_COOLDOWN_MS = 15 * 60 * 1000;
const UPD_RING_CAP = 16;

type InstalledRec = { k: string; v: string; ts: number };

function installedRec(ctx: vscode.ExtensionContext): InstalledRec | undefined {
  const raw = ctx.globalState.get<unknown>(UPD_INSTALLED_KEY) as
    InstalledRec | undefined;
  return raw && typeof raw.k === "string" && typeof raw.v === "string"
    ? raw : undefined;
}

type Ring = { k: string; ts: number }[];

function updKey(v: string, sha?: string): string {
  return sha ? `${v}@${sha.slice(0, 16)}` : v;
}

function ringGet(ctx: vscode.ExtensionContext, key: string): Ring {
  const raw = ctx.globalState.get<unknown>(key);
  return Array.isArray(raw) ? (raw as Ring).filter(
    (e) => e && typeof e.k === "string" && typeof e.ts === "number") : [];
}

function ringSeen(ctx: vscode.ExtensionContext, key: string, k: string, cooldown: number): boolean {
  const now = Date.now();
  return ringGet(ctx, key).some((e) => e.k === k && now - e.ts < cooldown);
}

function ringMark(ctx: vscode.ExtensionContext, key: string, k: string): void {
  const next = ringGet(ctx, key).filter((e) => e.k !== k);
  next.push({ k, ts: Date.now() });
  while (next.length > UPD_RING_CAP) next.shift();
  void ctx.globalState.update(key, next);
}

export interface SelfUpdateResult {
  updater: UpdateClient;
  installVsix: (vsix: ArrayBuffer) => Promise<void>;
}

export function setupSelfUpdate(
  ctx: vscode.ExtensionContext,
  updateBase: string,
  currentVersion: string,
  localVsixPath: string | undefined,
  lastLocalVsixMtime: number,
  watchFileFn: typeof import("node:fs").watchFile,
  timers: NodeJS.Timeout[],
  updatePollIntervalMs: number | undefined,
): SelfUpdateResult {
  // Convergence check: this runs at ACTIVATION, i.e. after a window
  // (re)load. If the build now running is still OLDER than the version we
  // recorded as successfully installed, the install did not actually take
  // (silent installExtension failure, rolled-back extensions dir, …) —
  // clear the record so the updater gets ONE fresh attempt per activation
  // (still rate-fenced by the attempted-ring cooldown), instead of being
  // suppressed forever on a build that never converged.
  {
    const rec = installedRec(ctx);
    if (rec && isNewer(rec.v, currentVersion)) {
      dlog("ext", "selfupdate.notconverged",
        { recorded: rec.v, running: currentVersion });
      void ctx.globalState.update(UPD_INSTALLED_KEY, undefined);
    }
  }

  const installVsix = async (vsix: ArrayBuffer): Promise<void> => {
    const p = join(tmpdir(), `vibe-ads-update-${Date.now()}.vsix`);
    writeFileSync(p, Buffer.from(vsix));
    await vscode.commands.executeCommand(
      "workbench.extensions.installExtension", vscode.Uri.file(p));
    // Re-arm injection for the new build, but PRESERVE a deliberate user
    // disable (audit EXT-01 / 2A-02). K_ON === false means the user explicitly
    // ran "Disable Kickbacks"; undefined/true means default-on or already-on.
    // The old unconditional `= true` stomped an explicit opt-out on every
    // self-update, so the only durable opt-out was uninstall.
    if (ctx.globalState.get<boolean>("kickbacks.debug.on") !== false) {
      await ctx.globalState.update("kickbacks.debug.on", true);
    }
    dlog("ext", "selfupdate.installed", { path: p });
    void (async () => {
      try {
        const choice = await vscode.window.showInformationMessage?.(
          "Kickbacks updated. Reload window to activate the new build?",
          { modal: false }, "Reload Window", "Later");
        dlog("ext", "selfupdate.toast", { choice: choice || "dismissed" });
        if (choice === "Reload Window") {
          // The reload tears this extension host down mid-await, so the
          // command "rejects" with Canceled on every successful reload —
          // swallow it here instead of logging a misleading toast.err.
          try {
            await vscode.commands.executeCommand(
              "workbench.action.reloadWindow");
          } catch { /* expected: host dies under us */ }
        }
      } catch (e) {
        dlog("ext", "selfupdate.toast.err",
          { msg: errMsg(e) });
      }
    })();
  };

  // Local-vsix watcher
  if (localVsixPath) {
    let localMtime = lastLocalVsixMtime;
    try {
      watchFileFn(localVsixPath, { interval: 2000 }, (curr) => {
        if (!curr.mtimeMs || curr.mtimeMs === localMtime) return;
        localMtime = curr.mtimeMs;
        try {
          const bytes = readFileSync(localVsixPath);
          dlog("ext", "selfupdate.local", { path: localVsixPath, bytes: bytes.length });
          void installVsix(bytes.buffer.slice(
            bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
        } catch (e) {
          dlog("ext", "selfupdate.local.err",
            { msg: errMsg(e) });
        }
      });
    } catch { /* never block activation */ }
  }

  const onUpdateAvailable = (info: { version: string; current: string;
                                      rollback: boolean }) => {
    try {
      const msg = info.rollback
        ? `Kickbacks: rolling back to v${info.version} (from v${info.current})…`
        : `Kickbacks: v${info.version} available — installing now…`;
      void vscode.window.showInformationMessage?.(msg);
    } catch { /* toast best-effort */ }
  };

  // audit-2026-06-09 #38: passing bare global `fetch` here silently bypassed
  // the class's timeoutFetch(120000) default — a black-holed manifest/VSIX
  // connection (2A-01 hang class) would park checkOnce forever and, with the
  // #31 single-flight guard, wedge every later poll behind it. Same 120s
  // budget as the class default.
  const updater = new UpdateClient(updateBase, currentVersion,
      timeoutFetch(120000), installVsix, {
    attempted: (v, sha) => ringSeen(ctx, UPD_KEY, updKey(v, sha), UPD_COOLDOWN_MS),
    markAttempted: (v, sha) => { ringMark(ctx, UPD_KEY, updKey(v, sha)); },
    installed: (v, sha) => installedRec(ctx)?.k === updKey(v, sha),
    markInstalled: (v, sha) => {
      void ctx.globalState.update(UPD_INSTALLED_KEY,
        { k: updKey(v, sha), v, ts: Date.now() } satisfies InstalledRec);
    },
    transientFailed: (v, sha) =>
      ringSeen(ctx, UPD_TRANSIENT_KEY, updKey(v, sha), UPD_TRANSIENT_COOLDOWN_MS),
    markTransientFailed: (v, sha) => { ringMark(ctx, UPD_TRANSIENT_KEY, updKey(v, sha)); },
    recordLkg: (v, vsix) => {
      try {
        const lkgPath = join(tmpdir(), `kickbacks-lkg-${v}.vsix`);
        writeFileSync(lkgPath, vsix);
        void ctx.globalState.update("vibe-ads.update.lkg", { v, path: lkgPath });
      } catch { /* best-effort */ }
    },
  }, onUpdateAvailable);

  timers.push(setInterval(() => void updater.checkOnce(),
    updatePollIntervalMs || DEFAULT_POLL_MS));

  return { updater, installVsix };
}