Ctrl K
ComunidadGuías…

src/index.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
 * WindsurfAPI · DevinAPI — 把 Windsurf / Devin 的云端模型变成 OpenAI / Anthropic / Gemini 标准 API。
 * 零 npm 运行时依赖。
 *
 * 项目完整历史(提交/版本/PR/issue/自伤账本)可视化:
 *   https://dwgx.github.io/WindsurfAPI/HISTORY-LEDGER-VIZ.html
 *   总账: docs/HISTORY-LEDGER.md · 逐条账: docs/HISTORY-LEDGER-2026-*.md
 */

// Logger must be imported first to patch log functions before other modules use them
import './dashboard/logger.js';
import { initAuth, isAuthenticated } from './auth.js';
import { configureLanguageServer, startLanguageServer, waitForReady, isLanguageServerRunning, stopLanguageServer, stopLanguageServerAndWait, cleanupOrphanLanguageServers, shouldPrewarmDefaultLs } from './langserver.js';
import { startServer } from './server.js';
import { config, log } from './config.js';
import { existsSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { execSync } from 'child_process';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { VERSION, BRAND } from './version.js';
import { abortActiveSse } from './sse-registry.js';
import { registerServer } from './server-registry.js';
import { buildSubprocessEnv } from './log-safety.js';
export { VERSION, BRAND };

// v2.0.146 (audit F-1): last-resort process safety nets. This service must
// not crash — it has fire-and-forget async paths (LS warmup, restart driver,
// self-update timers) where a stray rejection or a throw inside an async
// callback would otherwise terminate the process on Node >=15. A rejection is
// logged and the process keeps serving; an uncaught exception is logged and we
// exit non-zero so the supervisor (systemd) restarts from a clean state rather
// than continuing in an unknown one. Registered here (after the logger import,
// before main()) so the nets are live before any other module does async work.
let _crashNetsInstalled = false;
function installProcessCrashNets() {
  if (_crashNetsInstalled) return;
  _crashNetsInstalled = true;
  process.on('unhandledRejection', (reason) => {
    try { log.error('unhandledRejection:', reason instanceof Error ? (reason.stack || reason.message) : reason); }
    catch { console.error('unhandledRejection:', reason); }
  });
  process.on('uncaughtException', (err) => {
    try { log.error('uncaughtException — exiting for clean restart:', err?.stack || err); }
    catch { console.error('uncaughtException:', err); }
    process.exit(1);
  });
}
installProcessCrashNets();

function workspaceBase() {
  const tmpDir = process.env.TEMP || process.env.TMP || tmpdir();
  const suffix = process.env.HOSTNAME ? `-${process.env.HOSTNAME}` : '';
  return join(tmpDir, `windsurf-workspace${suffix}`);
}

function resetWorkspace() {
  // Wipe the workspace on every startup. If we don't, files created by
  // previous chat sessions (e.g. Claude "editing" config.yaml/lru_cache.py
  // via the baked-in Cascade tool prompts) persist and pollute the next
  // request — the model sees them at session init and starts narrating
  // edits to files the caller never mentioned.
  //
  // Using Node fs APIs instead of an `execSync('mkdir -p ... && rm -rf')`
  // shell pipeline so this is correct on Windows, macOS, and Linux without
  // depending on a POSIX shell.
  const wsBase = workspaceBase();
  try {
    mkdirSync(wsBase, { recursive: true });
    for (const name of readdirSync(wsBase)) {
      try { rmSync(join(wsBase, name), { recursive: true, force: true }); } catch {}
    }
  } catch {}
}

async function main() {
  const banner = `
   _    _ _           _                   __    _    ____ ___
  | |  | (_)         | |                 / _|  / \\  |  _ \\_ _|
  | |  | |_ _ __   __| |___ _   _ _ __ _| |_  / _ \\ | |_) | |
  | |/\\| | | '_ \\ / _\` / __| | | | '__|_   _|/ ___ \\|  __/| |
  \\  /\\  / | | | | (_| \\__ \\ |_| | |    |_| /_/   \\_\\_|  |___|
   \\/  \\/|_|_| |_|\\__,_|___/\\__,_|_|
                                          ${BRAND} v${VERSION}
`;
  console.log(banner);
  console.log(`  OpenAI-compatible proxy for Windsurf — by dwgx1337\n`);

  // Start language server binary.
  // Auto-install if missing — users repeatedly miss the manual install step
  // and open "request crashes" issues (see #18), so we just do it ourselves.
  // Skipped on Windows (LS is Linux-only) and when install-ls.sh isn't present.
  //
  // DEVIN_CONNECT / DEVIN_ONLY are the binary-less pivot backends (pure HTTP to
  // Devin cloud — no language_server needed). In those modes the whole LS block
  // is pure friction: a fresh Docker boot would otherwise shell out to download
  // a ~100MB+ binary the deploy never uses, delaying startup. Skip it entirely.
  const lsBackendUnused = String(process.env.DEVIN_CONNECT || '').trim() === '1'
    || String(process.env.DEVIN_ONLY || '').trim() === '1';
  const binaryPath = config.lsBinaryPath;
  if (lsBackendUnused) {
    log.info('DEVIN_CONNECT/DEVIN_ONLY enabled — skipping language server startup (binary-less backend).');
  } else if (!existsSync(binaryPath) && process.platform === 'win32') {
    log.warn('Windows detected: the Language Server binary is Linux/macOS only.');
    log.warn('Options: (1) Use Docker (see docker-compose.yml), (2) Use WSL2, or');
    log.warn('(3) Point LS_BINARY_PATH to a Windsurf desktop app language_server binary.');
  }
  if (!lsBackendUnused && !existsSync(binaryPath) && process.platform !== 'win32') {
    const scriptPath = (() => {
      try {
        const here = dirname(fileURLToPath(import.meta.url));
        return join(here, '..', 'install-ls.sh');
      } catch { return null; }
    })();
    if (scriptPath && existsSync(scriptPath)) {
      log.info(`Language server binary missing at ${binaryPath}`);
      log.info(`Auto-installing via ${scriptPath} — this runs once.`);
      try {
        // Bounded so a slow/black-holed network can't hang boot forever. The
        // upstream desktop archive is ~345 MB; install-ls.sh allows 60 s for
        // the manifest plus 900 s for the archive, so leave headroom for
        // extraction and hashing.
        execSync(`bash "${scriptPath}"`, {
          stdio: 'inherit',
          // Denylist env (L3b env-leak fix): the installer needs the general
          // env — PATH for curl/node/sha256sum, HOME, TMPDIR, proxy + CA-trust
          // vars for the downloads, WINDSURFAPI_LS_* overrides — but not the
          // proxy's credentials. Nothing install-ls.sh reads is secret-shaped.
          env: { ...buildSubprocessEnv(), LS_INSTALL_PATH: binaryPath },
          timeout: 1020000,
        });
        log.info('Language server binary installed.');
      } catch (err) {
        log.error(`Auto-install failed: ${err.message}`);
        log.error('Run manually:  bash install-ls.sh  (or set LS_BINARY_PATH to point at an existing binary)');
      }
    }
  }

  if (!lsBackendUnused && existsSync(binaryPath)) {
    resetWorkspace();

    // v2.0.85 (#127 123cek): kill any leftover language_server_linux_x64
    // processes from prior runs (PM2 SIGKILL / dashboard self-update via
    // process.exit() / earlier crash) before we start ours. Otherwise
    // they keep their LS pool ports occupied and accumulate over self-
    // update cycles. Setting WINDSURFAPI_SKIP_LS_CLEANUP=1 disables
    // (e.g. multi-WindsurfAPI on a shared host).
    if (process.env.WINDSURFAPI_SKIP_LS_CLEANUP !== '1') {
      try {
        const r = cleanupOrphanLanguageServers();
        if (r.killed > 0) log.info(`LS cleanup: scanned ${r.scanned} candidate(s), killed ${r.killed} orphan(s)`);
      } catch (e) { log.warn(`LS cleanup error (non-fatal): ${e.message}`); }
    }

    const lsConfig = {
      binaryPath,
      port: config.lsPort,
      apiServerUrl: config.codeiumApiUrl,
    };
    configureLanguageServer(lsConfig);
    if (shouldPrewarmDefaultLs()) {
      await startLanguageServer(lsConfig);

    try {
      await waitForReady(30000);
      // v2.0.93: if default LS started but proxy-LS crashed, give the
      // manage child (port 42101) a moment to restart before syncing models.
      log.info('LS ready — fetching model catalog');
    } catch (err) {
      log.error(`Language server failed to start: ${err.message}`);
      log.error('Chat completions will not work without the language server.');
      log.error('Run: bash install-ls.sh (pinned upstream manifest; no fallback source)');
    }
    } else {
      log.info('LS default prewarm disabled (LS_PREWARM_DEFAULT=0 or LS_MAX_INSTANCES=1); LS starts lazily on first request');
    }
  } else if (!lsBackendUnused) {
    log.warn(`Language server binary not found at ${binaryPath}`);
    log.warn('Install it with: download Windsurf Linux tarball and extract language_server_linux_x64');
  }

  // Init auth pool
  await initAuth();

  if (!isAuthenticated()) {
    log.warn('No accounts configured. Add via:');
    log.warn('  POST /auth/login {"token":"..."}');
    log.warn('  POST /auth/login {"api_key":"..."}');
  }

  const server = startServer();
  // Expose the live server so dashboard-triggered restart / self-update paths
  // in api.js can drain in-flight requests before exiting (they otherwise have
  // no reference to it). Mirrors the sse-registry pattern.
  registerServer(server);

  // Packaged first-run: print the auto-generated credentials prominently (like
  // start.ps1 does for the source install) so the user can log in / call the
  // API. They're already persisted to the .env beside the exe.
  if (config.generatedCreds) {
    const c = config.generatedCreds;
    log.warn('################################################################');
    log.warn('#           已为你自动生成密钥(请截图妥善保存)              #');
    log.warn('################################################################');
    // D6-LOGS (audit 2026-09-22): only these two lines carry generated credentials,
    // and `log.warn` is patched by dashboard/logger.js to copy every call into the
    // dashboard ring buffer, the live SSE subscribers and logs/{app,error}-<date>.jsonl
    // — sinks that outlive the session and travel into bug reports, container logs and
    // backups. The first-run display exists for the operator in front of the console,
    // so it goes straight to console.warn; the banner, the .env path and the failure
    // explanation below stay on the logger, where operators expect to find them.
    if (c.DASHBOARD_PASSWORD) console.warn(`  面板登录密码 (DASHBOARD_PASSWORD): ${c.DASHBOARD_PASSWORD}`);
    if (c.API_KEY) console.warn(`  网关调用密钥 (API_KEY):            ${c.API_KEY}`);
    if (c.envPath) log.warn(`  已写入 ${c.envPath} — 下次启动沿用,无需重设。`);
    else log.warn(`  (写入 .env 失败${c.persistError ? ': ' + c.persistError : ''} — 本次有效,重启会重新生成)`);
    log.warn('################################################################');
  }

  // Packaged single-exe desktop UX: on the FIRST deploy (no Windsurf_data folder
  // existed yet), pop the dashboard in the default browser so the initial
  // double-click lands on a usable setup page. Subsequent launches don't reopen
  // it. Best effort — a headless/CI run (WINDSURFAPI_NO_OPEN=1) or a missing
  // opener just skips it. Delayed slightly so the "Server on ..." line prints.
  if (config.isFirstRun && process.env.WINDSURFAPI_NO_OPEN !== '1') {
    const url = `http://127.0.0.1:${config.port}/dashboard`;
    setTimeout(() => {
      try {
        // Windows: `cmd /c start "" <url>` opens the default browser without a
        // shell window lingering. execFile avoids spawning a persistent shell.
        import('child_process').then(({ execFile }) => {
          if (process.platform === 'win32') {
            execFile('cmd', ['/c', 'start', '', url], { windowsHide: true }, () => {});
          } else if (process.platform === 'darwin') {
            execFile('open', [url], () => {});
          } else {
            execFile('xdg-open', [url], () => {});
          }
        }).catch(() => {});
        log.info(`First run — opening dashboard in your browser: ${url}`);
      } catch { /* opener unavailable — user can open the URL manually */ }
    }, 800);
  }

  let shuttingDown = false;
  const shutdown = (signal) => {
    if (shuttingDown) return;
    shuttingDown = true;
    const inflight = server.getActiveRequests?.() ?? '?';
    log.info(`${signal} received — draining ${inflight} in-flight requests (up to 30s)...`);
    const abortedSse = abortActiveSse('server shutting down');
    if (abortedSse) log.warn(`Aborted ${abortedSse} active SSE stream(s): server shutting down`);
    // Detach P1: entries are in-memory and hold no LS lease, but a drain must
    // refuse new admissions and drop what exists so a dying process does not
    // leave claims behind while the LS pool is being stopped.
    import('./detached-registry.js').then(m => m.beginDetachDrain()).catch(() => {});
    if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
    // v2.0.146 (audit F-2): await LS children actually exiting before
    // process.exit, mirroring the self-update path. A synchronous
    // stopLanguageServer() + immediate exit reparents still-living LS
    // children to PID 1, leaving them holding pool ports (42100, 42101…)
    // that the freshly-restarted replica then races (the H-4 orphan race).
    const finalize = async (reason) => {
      // K7: shutdown DRAINS, it does not write the pool back. The periodic
      // dirty-flush keeps accounts.json current (≤30s stale) and status flips
      // save immediately, so a shutdown rewrite buys nothing — and it used to
      // clobber an operator's external accounts.json write, forcing the
      // "stop → wait for the flush hook → write → start" deploy dance. Removing
      // it lets an external write land safely once the process is stopped.
      try { await stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }); }
      catch { try { stopLanguageServer(); } catch {} }
      log.info(`Shutdown complete (${reason})`);
      process.exit(0);
    };
    server.close(() => {
      log.info('HTTP server closed, stopping language server');
      void finalize('drained');
    });
    setTimeout(() => {
      log.warn('Drain timeout, forcing exit');
      void finalize('drain-timeout');
    }, 30_000);
  };
  process.on('SIGINT', () => shutdown('SIGINT'));
  process.on('SIGTERM', () => shutdown('SIGTERM'));
}

main().catch(err => { console.error('Fatal:', err); process.exit(1); });