Ctrl K
ComunidadGuías…

src/config.js

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

Archivo original ↗Preguntar a KiBot
import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from 'fs';
import { resolve, dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { randomBytes } from 'crypto';
import net from 'net';
import { restrictFileToOwnerSync } from './fs-atomic.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');

// When packaged as a single .exe (pkg), __dirname/ROOT point INSIDE the
// read-only snapshot (C:\snapshot\...), so .env can't be read from there and
// DATA_DIR would try to mkdir in the snapshot (fails). The real writable
// location is the folder the .exe sits in. Use that as the base for .env and
// the default data dir so a double-clicked exe keeps its state in a `data/`
// folder next to itself — cleanly isolated from the bundled Linux snapshot.
const IS_PACKAGED = !!process.pkg;
const EXE_DIR = IS_PACKAGED ? dirname(process.execPath) : ROOT;

// Load .env file manually (zero dependencies)
function loadEnv() {
  // Packaged: read the .env sitting next to the .exe, not the snapshot copy.
  const envPath = resolve(EXE_DIR, '.env');
  if (!existsSync(envPath)) return;
  const content = readFileSync(envPath, 'utf-8');
  for (const line of content.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eqIdx = trimmed.indexOf('=');
    if (eqIdx === -1) continue;
    const key = trimmed.slice(0, eqIdx).trim();
    let val = trimmed.slice(eqIdx + 1).trim();
    if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
      val = val.slice(1, -1);
    } else {
      // Inline comment: everything from a whitespace-preceded `#` on is dropped
      // (`PORT=3003 # port` -> `3003`). That is the standard dotenv rule, so it stays.
      //
      // What was wrong is that it happened SILENTLY. Measured 2026-09-13: a rotated
      // secret stored with a trailing note —
      //   API_KEY=sk-live-abc123 # rotated 2026-09-01
      // — parses to `sk-live-abc123` under every standard parser, so the dashboard
      // password still works, the chat API answers 401, and nothing in the logs names
      // the file the operator just edited. The value is not recoverable here (a bare
      // value and a commented one are genuinely ambiguous), so the fix is to say so
      // loudly and to document the quoting rule in .env.example.
      const commentIdx = val.indexOf(' #');
      if (commentIdx !== -1) {
        const kept = val.slice(0, commentIdx).trim();
        const dropped = val.slice(commentIdx).trim();
        // Only warn when the dropped text is long enough to be a note rather than a
        // terse label: `PORT=3003 # port` is self-explanatory, a 20-char phrase after
        // a credential is exactly the shape that gets missed.
        if (kept && dropped.length > 12) {
          // The operator needs the fix, not the value: printing the kept or dropped
          // text put a rotated credential into stdout/container logs, which is the
          // one place a .env edit tends to end up. The hint is what the pinned test
          // (test/config-dotenv-inline-comment.test.js) actually requires.
          console.warn(`[config] .env ${key}: inline comment stripped (value omitted). If the '#' is part of the value, quote it: ${key}="…".`);
        }
        val = kept;
      }
    }
    if (!process.env[key]) {
      process.env[key] = val;
    }
  }
}

if (process.env.WINDSURFAPI_SKIP_DOTENV !== '1') {
  loadEnv();
}

// Packaged-exe sane defaults (only when the user hasn't set them). The Windows
// exe has no bundled Language Server (Linux-only) and is meant for local
// single-user use, so: DEVIN_CONNECT=1 (pure-HTTP path, skip the LS entirely),
// HOST=127.0.0.1 (loopback — don't expose an unauthenticated gateway to the
// LAN on first run). Both stay overridable via .env / env.
if (IS_PACKAGED) {
  if (!process.env.DEVIN_CONNECT && !process.env.DEVIN_ONLY) process.env.DEVIN_CONNECT = '1';
  if (!process.env.HOST && !process.env.BIND_HOST) process.env.HOST = '127.0.0.1';
}

// base64url token (no +/=): safe in .env / URL / header. Mirrors the strong
// random keys deploy/windows/start.ps1 writes for the source install.
function newSecret(bytes = 24) {
  return randomBytes(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

// Packaged first-run credential bootstrap. The source install gets random
// API_KEY + DASHBOARD_PASSWORD from start.ps1; the double-click exe had no such
// step, so it came up with NO auth. Fix: on a packaged run where these are
// unset, generate strong random values, PERSIST them to the .env beside the exe
// (so they're stable across restarts and the user can read them), and use them
// this run. `_generatedCreds` is surfaced so index.js can print them once,
// prominently, like start.ps1 does.
let _generatedCreds = null;
if (IS_PACKAGED) {
  const gen = {};
  if (!process.env.API_KEY) { const v = 'sk-windsurf-' + newSecret(24); process.env.API_KEY = v; gen.API_KEY = v; }
  if (!process.env.DASHBOARD_PASSWORD) { const v = newSecret(18); process.env.DASHBOARD_PASSWORD = v; gen.DASHBOARD_PASSWORD = v; }
  if (Object.keys(gen).length) {
    const envPath = resolve(EXE_DIR, '.env');
    try {
      const exists = existsSync(envPath);
      const lines = Object.entries(gen).map(([k, v]) => `${k}=${v}`);
      const block = (exists ? '\n' : '')
        + `# --- generated by windsurfapi.exe first run ${new Date().toISOString()} ---\n`
        + lines.join('\n') + '\n';
      if (exists) appendFileSync(envPath, block); else writeFileSync(envPath, block, { mode: 0o600 });
      // mode 0o600 is a no-op on Windows: the file inherits the directory ACL
      // (Users/Authenticated Users) and this .env carries the API key plus the
      // dashboard password. Best effort — a failure warns and must not turn a
      // successful credential bootstrap into a startup error.
      restrictFileToOwnerSync(envPath);
      _generatedCreds = { ...gen, envPath };
    } catch (err) {
      // Couldn't persist (read-only dir?). Still use them in-memory this run so
      // the gateway isn't left unauthenticated; they'll just regenerate next run.
      _generatedCreds = { ...gen, envPath: null, persistError: err.code || err.message };
    }
  }
}

// Disable Node's Happy Eyeballs (autoSelectFamily). On hosts where DNS returns
// an unroutable IPv6 (e.g. ULA fd00:696e:: for server.codeium.com) before the
// working IPv4, Node 20+ tries IPv6 first and the whole attempt dies with
// ETIMEDOUT in ~270ms — no IPv4 fallback. curl survives via Happy Eyeballs,
// but Node's implementation does not fall back when the IPv6 connect hangs.
// Disabling autoSelectFamily makes DNS return IPv4 first (default order),
// which connects cleanly. Zero-cost on hosts with no IPv6 — the flag only
// changes the address-selection strategy, not the DNS query itself.
if (typeof net.setDefaultAutoSelectFamily === 'function') {
  net.setDefaultAutoSelectFamily(false);
}

// `sharedDataDir` is the cluster-shared root: a single accounts.json lives
// here so add-account writes from any replica are visible to every replica
// after restart. `dataDir` is replica-local under REPLICA_ISOLATE=1 and is
// safe to use for telemetry that does not need cross-replica visibility.
// See issue #67 — when the two were collapsed into one path, every
// docker-compose upgrade orphaned the user's accounts.json under a stale
// `replica-${HOSTNAME}` subdir.
// Base that relative DATA_DIR / default data dir resolve against. Packaged:
// the .exe's folder (writable) — NEVER the snapshot ROOT. Default (DATA_DIR
// unset) for a packaged exe is `<exe-dir>/Windsurf_data` so a double-click
// drops all state (accounts/stats/logs) into one tidy folder beside the
// program, isolated from the bundled Linux snapshot.
const DATA_BASE = IS_PACKAGED ? EXE_DIR : ROOT;
const sharedDataDir = process.env.DATA_DIR
  ? resolve(DATA_BASE, process.env.DATA_DIR)
  : (IS_PACKAGED ? join(EXE_DIR, 'Windsurf_data') : ROOT);
const dataDir = (() => {
  let base = sharedDataDir;
  if (process.env.REPLICA_ISOLATE === '1' && process.env.HOSTNAME) {
    base = join(base, `replica-${process.env.HOSTNAME}`);
  }
  return base;
})();

// First-run detection (packaged only): capture whether the data folder exists
// BEFORE we mkdir it, so index.js can decide to auto-open the dashboard only on
// the very first launch (no Windsurf_data folder yet = fresh deploy).
const isFirstRun = IS_PACKAGED && !existsSync(sharedDataDir);

try {
  mkdirSync(sharedDataDir, { recursive: true });
  mkdirSync(dataDir, { recursive: true });
} catch (err) {
  // Don't swallow this — a non-writable DATA_DIR means accounts.json / stats.json
  // / logs all fail to persist later with a confusing downstream error. Surface
  // the path + code now. (log isn't defined yet at module-init time; use console.)
  console.warn(`[WARN] Could not create data dir "${dataDir}" (${err.code || err.message}). `
    + 'accounts/stats/logs may not persist — set DATA_DIR to a writable path.');
}

export function defaultLsBinaryPath(platform = process.platform, arch = process.arch, home = process.env.HOME) {
  if (platform === 'darwin') {
    const name = arch === 'arm64' ? 'language_server_macos_arm' : 'language_server_macos_x64';
    return `${home}/.windsurf/${name}`;
  }
  const name = arch === 'arm64' ? 'language_server_linux_arm' : 'language_server_linux_x64';
  return `/opt/windsurf/${name}`;
}

export const config = {
  port: parseInt(process.env.PORT || '3003', 10),
  // Bind host. Defaults to all interfaces. Set HOST=127.0.0.1 (or BIND_HOST=)
  // for localhost-only deployments — when bound non-locally, missing API_KEY /
  // DASHBOARD_PASSWORD switches to fail-closed instead of default-allow.
  host: process.env.HOST || process.env.BIND_HOST || '0.0.0.0',
  apiKey: process.env.API_KEY || '',
  dataDir,
  sharedDataDir,

  codeiumAuthToken: process.env.CODEIUM_AUTH_TOKEN || '',
  codeiumApiKey: process.env.CODEIUM_API_KEY || '',
  codeiumEmail: process.env.CODEIUM_EMAIL || '',
  codeiumPassword: process.env.CODEIUM_PASSWORD || '',

  codeiumApiUrl: process.env.CODEIUM_API_URL || 'https://server.self-serve.windsurf.com',
  // Astraflow — OpenAI-compatible aggregation platform by UCloud (200+ models)
  // Global:  https://api-us-ca.umodelverse.ai/v1  — signup: https://astraflow.ucloud-global.com
  // China:   https://api.modelverse.cn/v1          — signup: https://astraflow.ucloud.cn
  astraflowApiKey: process.env.ASTRAFLOW_API_KEY || '',
  astraflowApiKeyCn: process.env.ASTRAFLOW_CN_API_KEY || '',
  astraflowApiUrl: 'https://api-us-ca.umodelverse.ai/v1',
  astraflowApiUrlCn: 'https://api.modelverse.cn/v1',
  // Fallback model when a request omits `model`. MUST be a name that resolves to
  // a live DEVIN_CONNECT catalog selector (mapped:true) — the old
  // 'claude-4.5-sonnet-thinking' is a legacy word-order alias that resolveModel
  // accepts but resolveConnectSelector maps to `{mapped:false}`, so on a
  // DEVIN_CONNECT deployment an omitted-model request was rejected 400 (strict
  // mode) or silently downgraded to the free tier. 'claude-sonnet-4.6' maps to
  // the live `claude-sonnet-4-6-thinking` selector. Override with DEFAULT_MODEL.
  defaultModel: process.env.DEFAULT_MODEL || 'claude-sonnet-4.6',
  maxTokens: parseInt(process.env.MAX_TOKENS || '8192', 10),
  logLevel: process.env.LOG_LEVEL || 'info',

  // Language server
  lsBinaryPath: process.env.LS_BINARY_PATH || defaultLsBinaryPath(),
  lsPort: parseInt(process.env.LS_PORT || '42100', 10),

  // Dashboard
  dashboardPassword: process.env.DASHBOARD_PASSWORD || '',

  // Proxy testing
  allowPrivateProxyHosts: process.env.ALLOW_PRIVATE_PROXY_HOSTS === '1',

  // True when running as a packaged single-exe (pkg). Used to enable the
  // double-click desktop UX: data folder beside the exe, auto-open dashboard.
  isPackaged: IS_PACKAGED,
  // True on the very first packaged launch (Windsurf_data didn't exist yet).
  // index.js opens the dashboard in the browser only on this first deploy.
  isFirstRun,
  // Credentials auto-generated on a packaged run (null if none). index.js prints
  // them once, prominently, so the user can log in / call the API.
  generatedCreds: _generatedCreds,
};

const levels = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLevel = levels[config.logLevel] ?? 1;

export const log = {
  debug: (...args) => currentLevel <= 0 && console.log('[DEBUG]', ...args),
  info: (...args) => currentLevel <= 1 && console.log('[INFO]', ...args),
  warn: (...args) => currentLevel <= 2 && console.warn('[WARN]', ...args),
  error: (...args) => currentLevel <= 3 && console.error('[ERROR]', ...args),
};