From b25b957b6604790bd6c3ecc45e7044959e0fb757 Mon Sep 17 00:00:00 2001 From: spark Date: Fri, 7 Aug 2026 01:22:48 +0800 Subject: [PATCH] Keep daily generation reliable across proxy and LLM failures Constraint: macOS system proxy is not inherited automatically by terminal fetches Confidence: high Scope-risk: moderate Tested: npm test; TypeScript check; sources check; dry-run fetched 580 articles Not-tested: GitHub Pages publication --- .env.example | 9 +++ lib/ai/backends/claude-cli.test.ts | 28 ++++++++ lib/ai/backends/claude-cli.ts | 23 ++++++- lib/ai/backends/openai-compat.test.ts | 93 +++++++++++++++++++++++++++ lib/ai/backends/openai-compat.ts | 52 +++++++++++++-- lib/ai/errors.ts | 11 ++++ lib/ai/llm.ts | 11 +++- lib/ai/pipeline.test.ts | 70 ++++++++++++++++++++ lib/ai/pipeline.ts | 86 +++++++++++++++++++++++-- lib/sources/rss.ts | 10 ++- package.json | 9 +-- scripts/proxy-env.mjs | 73 +++++++++++++++++++++ scripts/proxy-env.test.mjs | 55 ++++++++++++++++ scripts/run-tsx.mjs | 52 +++++++++++++++ 14 files changed, 564 insertions(+), 18 deletions(-) create mode 100644 lib/ai/backends/claude-cli.test.ts create mode 100644 lib/ai/backends/openai-compat.test.ts create mode 100644 lib/ai/errors.ts create mode 100644 lib/ai/pipeline.test.ts create mode 100644 scripts/proxy-env.mjs create mode 100644 scripts/proxy-env.test.mjs create mode 100644 scripts/run-tsx.mjs diff --git a/.env.example b/.env.example index 6127f8cac..54b481753 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,15 @@ # keys never leave the machine. Leave everything commented to get the # default behaviour (claude-cli backend, Max subscription billing). +# ----------------------------------------------------------------------------- +# Optional outbound proxy +# ----------------------------------------------------------------------------- +# On macOS, npm run daily/dry-run automatically imports the active HTTP(S) +# proxy from System Settings. On other platforms, or to override it explicitly: +# HTTPS_PROXY=http://127.0.0.1:7890 +# HTTP_PROXY=http://127.0.0.1:7890 +# NO_PROXY=localhost,127.0.0.1 + # ----------------------------------------------------------------------------- # LLM backend selector (default: claude-cli) # ----------------------------------------------------------------------------- diff --git a/lib/ai/backends/claude-cli.test.ts b/lib/ai/backends/claude-cli.test.ts new file mode 100644 index 000000000..06983348f --- /dev/null +++ b/lib/ai/backends/claude-cli.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateClaudeCliAvailable } from "./claude-cli"; + +test("validateClaudeCliAvailable accepts an executable CLI", () => { + const previous = process.env.CLAUDE_CLI_PATH; + process.env.CLAUDE_CLI_PATH = process.execPath; + try { + assert.doesNotThrow(() => validateClaudeCliAvailable()); + } finally { + if (previous === undefined) delete process.env.CLAUDE_CLI_PATH; + else process.env.CLAUDE_CLI_PATH = previous; + } +}); + +test("validateClaudeCliAvailable fails fast with configuration guidance", () => { + const previous = process.env.CLAUDE_CLI_PATH; + process.env.CLAUDE_CLI_PATH = "/definitely/missing/claude"; + try { + assert.throws( + () => validateClaudeCliAvailable(), + /LLM_BACKEND=claude-cli.*\.env\.local.*LLM_BACKEND=deepseek/, + ); + } finally { + if (previous === undefined) delete process.env.CLAUDE_CLI_PATH; + else process.env.CLAUDE_CLI_PATH = previous; + } +}); diff --git a/lib/ai/backends/claude-cli.ts b/lib/ai/backends/claude-cli.ts index 266dc958e..41bda02df 100644 --- a/lib/ai/backends/claude-cli.ts +++ b/lib/ai/backends/claude-cli.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import path from "node:path"; import { classifyError, logLlmCall } from "../log"; import type { LlmRunOptions, LlmRunResult } from "../llm"; @@ -13,6 +13,22 @@ function resolveCliPath(): string { return "claude"; } +export function validateClaudeCliAvailable(): void { + const cli = resolveCliPath(); + const probe = spawnSync(cli, ["--version"], { + encoding: "utf8", + shell: process.platform === "win32", + stdio: "ignore", + }); + if (probe.error || probe.status !== 0) { + throw new Error( + "LLM_BACKEND=claude-cli but the 'claude' CLI is unavailable. " + + "Install/login to Claude Code, or create .env.local with an API backend " + + "such as LLM_BACKEND=deepseek and its matching API key.", + ); + } +} + /** * Invoke the local `claude` CLI in print mode against the Max subscription. * Writes the user prompt over stdin to bypass shell argument length limits. @@ -38,7 +54,10 @@ export function runClaudeCli({ return new Promise((resolve, reject) => { const child = spawn(cli, args, { - shell: true, + // Unix can execute the binary directly, preserving every prompt + // argument literally. Windows npm installs a .cmd shim, which still + // requires the command shell. + shell: process.platform === "win32", stdio: ["pipe", "pipe", "pipe"], }); diff --git a/lib/ai/backends/openai-compat.test.ts b/lib/ai/backends/openai-compat.test.ts new file mode 100644 index 000000000..e6835e248 --- /dev/null +++ b/lib/ai/backends/openai-compat.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { LlmIncompleteResponseError } from "../errors"; +import { PRESETS, runOpenAICompat } from "./openai-compat"; + +test("DeepSeek requests JSON in non-thinking mode and rejects truncation", async () => { + const requestBodies: Array> = []; + let requestCount = 0; + const server = http.createServer((req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + requestBodies.push(JSON.parse(body) as Record); + requestCount += 1; + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + id: `test-${requestCount}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "deepseek-v4-flash", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: + requestCount === 1 ? '{"ok":true}' : '{"ok":', + }, + finish_reason: requestCount === 1 ? "stop" : "length", + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 10, + total_tokens: 20, + }, + }), + ); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert(address && typeof address !== "string"); + + const previousApiKey = process.env.DEEPSEEK_API_KEY; + const previousCwd = process.cwd(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "daily-brief-test-")); + process.env.DEEPSEEK_API_KEY = "test-key-openai-compat"; + process.chdir(tempDir); + + const cfg = { + ...PRESETS.deepseek, + defaultBaseUrl: `http://127.0.0.1:${address.port}/v1`, + }; + const options = { + systemPrompt: "Return JSON.", + userPrompt: "Return an object.", + }; + + try { + const result = await runOpenAICompat(options, cfg); + assert.equal(result.text, '{"ok":true}'); + + await assert.rejects( + runOpenAICompat(options, cfg), + LlmIncompleteResponseError, + ); + + assert.equal(requestBodies.length, 2); + for (const body of requestBodies) { + assert.deepEqual(body.response_format, { type: "json_object" }); + assert.deepEqual(body.thinking, { type: "disabled" }); + assert.equal(body.max_tokens, 8192); + } + } finally { + process.chdir(previousCwd); + if (previousApiKey === undefined) delete process.env.DEEPSEEK_API_KEY; + else process.env.DEEPSEEK_API_KEY = previousApiKey; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/lib/ai/backends/openai-compat.ts b/lib/ai/backends/openai-compat.ts index 0be3163a1..a815f9569 100644 --- a/lib/ai/backends/openai-compat.ts +++ b/lib/ai/backends/openai-compat.ts @@ -1,4 +1,5 @@ import OpenAI from "openai"; +import { LlmIncompleteResponseError } from "../errors"; import { classifyError, logLlmCall } from "../log"; import type { LlmRunOptions, LlmRunResult } from "../llm"; @@ -83,6 +84,18 @@ export async function runOpenAICompat( const timeoutMs = opts.timeoutMs ?? 180_000; try { + // DeepSeek V4 defaults to thinking mode. Digest/enrichment requests are + // structured extraction tasks, so thinking spends output budget without + // improving the JSON. DeepSeek's native JSON mode also prevents the + // malformed/truncated objects that previously escaped this backend as a + // successful response and later crashed JSON.parse in pipeline.ts. + const structuredOutputOptions = + cfg.backend === "deepseek" + ? { + response_format: { type: "json_object" as const }, + thinking: { type: "disabled" as const }, + } + : {}; const resp = await client.chat.completions.create( { model, @@ -97,14 +110,42 @@ export async function runOpenAICompat( // entries parseable. 8192 covers all observed daily batches with // generous headroom. Match the explicit value Anthropic SDK uses. max_tokens: 8192, - // Don't force JSON mode — not all OpenAI-compat providers support - // response_format=json_object, and our prompts + jsonrepair already - // handle the slop. + // JSON/thinking controls are enabled only for the DeepSeek preset; + // generic OpenAI-compatible providers keep their existing behavior. + ...structuredOutputOptions, }, { timeout: timeoutMs }, ); - const text = (resp.choices[0]?.message?.content ?? "").trim(); + const choice = resp.choices[0]; + const text = (choice?.message?.content ?? "").trim(); const durationMs = Date.now() - started; + const finishReason = choice?.finish_reason ?? null; + const incompleteReason = !choice + ? "response contained no choices" + : !text + ? "response content was empty" + : finishReason && finishReason !== "stop" + ? `finish_reason=${finishReason}` + : null; + + if (incompleteReason) { + const error = new LlmIncompleteResponseError( + `${cfg.backend} returned an incomplete response: ${incompleteReason}`, + ); + logLlmCall({ + ts: new Date(started).toISOString(), + backend: cfg.backend, + model, + durationMs, + success: false, + inputChars, + outputChars: text.length, + errorCategory: "other", + errorSnippet: error.message, + }); + throw error; + } + logLlmCall({ ts: new Date(started).toISOString(), backend: cfg.backend, @@ -118,6 +159,9 @@ export async function runOpenAICompat( }); return { text, durationMs }; } catch (err) { + // Incomplete responses were already logged above with their real partial + // output length. Avoid writing a second, misleading zero-length record. + if (err instanceof LlmIncompleteResponseError) throw err; const durationMs = Date.now() - started; const msg = err instanceof Error ? err.message : String(err); logLlmCall({ diff --git a/lib/ai/errors.ts b/lib/ai/errors.ts new file mode 100644 index 000000000..f4e54595d --- /dev/null +++ b/lib/ai/errors.ts @@ -0,0 +1,11 @@ +/** + * The provider returned a response, but it cannot be consumed as a complete + * model output (for example, finish_reason=length or an empty content field). + * Callers may retry or use a local fallback without hiding API/auth failures. + */ +export class LlmIncompleteResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "LlmIncompleteResponseError"; + } +} diff --git a/lib/ai/llm.ts b/lib/ai/llm.ts index 17df79c55..1466344cd 100644 --- a/lib/ai/llm.ts +++ b/lib/ai/llm.ts @@ -16,7 +16,11 @@ * See .env.example for the full list. */ -import { CLAUDE_MODEL, runClaudeCli } from "./backends/claude-cli"; +import { + CLAUDE_MODEL, + runClaudeCli, + validateClaudeCliAvailable, +} from "./backends/claude-cli"; import { PRESETS as ANTHROPIC_PRESETS, anthropicCompatModel, @@ -116,7 +120,10 @@ export async function runLlm(opts: LlmRunOptions): Promise { */ export function validateBackendCredentials(): void { const backend = getBackend(); - if (backend === "claude-cli") return; + if (backend === "claude-cli") { + validateClaudeCliAvailable(); + return; + } const required: Record, string> = { anthropic: "ANTHROPIC_API_KEY", diff --git a/lib/ai/pipeline.test.ts b/lib/ai/pipeline.test.ts new file mode 100644 index 000000000..e6b3144df --- /dev/null +++ b/lib/ai/pipeline.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildFallbackDailyReport, + generateDailyReport, + type ArticleInput, +} from "./pipeline"; + +function article( + category: ArticleInput["category"], + index: number, + summary?: string, +): ArticleInput { + return { + sourceId: `${category}-source-${index % 2}`, + source: `Source ${index % 2}`, + title: `${category} title ${index}`, + url: `https://example.com/${category}/${index}`, + excerpt: `${category} excerpt ${index}`, + summary, + category, + publishedAt: new Date(Date.now() - index * 1_000), + }; +} + +test("buildFallbackDailyReport preserves enriched summaries and category caps", () => { + const articles = [ + ...Array.from({ length: 7 }, (_, i) => + article("tech", i, i === 0 ? "enriched tech summary" : undefined), + ), + ...Array.from({ length: 6 }, (_, i) => article("finance", i)), + ...Array.from({ length: 4 }, (_, i) => article("politics", i)), + ]; + + const report = buildFallbackDailyReport(articles); + + assert.equal(report.tech_briefs.length, 5); + assert.equal(report.finance_briefs.length, 5); + assert.equal(report.politics_briefs.length, 3); + assert.equal(report.tech_briefs[0]?.summary, "enriched tech summary"); + assert.equal(report.finance_briefs[0]?.summary, "finance excerpt 0"); +}); + +test("generateDailyReport falls back after two truncated JSON responses", async () => { + const articles = [article("tech", 0, "summary")]; + let attempts = 0; + + const { report } = await generateDailyReport(articles, async () => { + attempts += 1; + throw new SyntaxError("Unexpected end of JSON input"); + }); + + assert.equal(attempts, 2); + assert.equal(report.tech_briefs.length, 1); + assert.equal(report.tech_briefs[0]?.summary, "summary"); +}); + +test("generateDailyReport does not hide provider/auth failures", async () => { + const articles = [article("tech", 0)]; + let attempts = 0; + + await assert.rejects( + generateDailyReport(articles, async () => { + attempts += 1; + throw new Error("401 invalid API key"); + }), + /401 invalid API key/, + ); + assert.equal(attempts, 2); +}); diff --git a/lib/ai/pipeline.ts b/lib/ai/pipeline.ts index 769aebf05..b43deec75 100644 --- a/lib/ai/pipeline.ts +++ b/lib/ai/pipeline.ts @@ -1,4 +1,5 @@ import { jsonrepair } from "jsonrepair"; +import { LlmIncompleteResponseError } from "./errors"; import { runLlm } from "./llm"; import { extractJson } from "./json-util"; import { SYSTEM_PROMPT_DIGEST_EN, SYSTEM_PROMPT_DIGEST_ZH } from "./prompts"; @@ -52,6 +53,12 @@ const PER_CATEGORY_LIMIT: Record = { const MAX_AGE_DAYS = 14; +const FALLBACK_BRIEF_LIMIT: Record = { + tech: 5, + finance: 5, + politics: 3, +}; + /** * Pick `limit` items from `items` so every source gets a fair shot. * @@ -102,6 +109,58 @@ function selectRoundRobin( return out; } +/** + * Keep the report publishable when both digest-generation attempts return + * malformed/truncated JSON. The HTML view is driven by the raw article + * sidecar, while this fallback preserves useful Markdown/JSON brief items + * using summaries that earlier enrichment stages already produced. + */ +export function buildFallbackDailyReport( + articles: ArticleInput[], +): DailyReport { + const grouped: Record = { + tech: [], + finance: [], + politics: [], + }; + for (const article of articles) grouped[article.category].push(article); + + const toBriefs = (category: Category): BriefItem[] => + selectRoundRobin( + grouped[category], + FALLBACK_BRIEF_LIMIT[category], + ).map((article) => { + const summary = + article.summary?.trim() || + article.excerpt?.trim() || + article.title.trim(); + return { + title: article.title, + url: article.url, + source: article.source, + summary: summary.slice(0, 400), + importance: 5, + }; + }); + + return { + hero_headline: "", + daily_overview: "", + tech_briefs: toBriefs("tech"), + finance_briefs: toBriefs("finance"), + politics_briefs: toBriefs("politics"), + editor_note: "", + keywords: [], + }; +} + +function isRecoverableDigestOutputError(error: unknown): boolean { + return ( + error instanceof SyntaxError || + error instanceof LlmIncompleteResponseError + ); +} + async function callOnce(userPayloadJson: string): Promise { // Claude Code CLI's built-in system prompt biases the model toward // conversational markdown output. Anchor the format expectation in the @@ -195,6 +254,7 @@ async function callOnce(userPayloadJson: string): Promise { export async function generateDailyReport( articles: ArticleInput[], + callDigest: (userPayloadJson: string) => Promise = callOnce, ): Promise<{ report: DailyReport; tokensUsed: number }> { const grouped: Record = { tech: [], @@ -220,16 +280,32 @@ export async function generateDailyReport( let report: DailyReport; try { - report = await callOnce(userPayloadJson); + report = await callDigest(userPayloadJson); } catch (firstErr) { - // One retry — claude CLI occasionally wraps in narration on the first - // pass but obeys when the same prompt is repeated. + // One retry covers transient provider interruptions and occasional + // narration/malformed JSON on the first response. console.warn( - `[pipeline] first claude CLI call failed, retrying: ${ + `[pipeline] first digest call failed, retrying: ${ firstErr instanceof Error ? firstErr.message : String(firstErr) }`, ); - report = await callOnce(userPayloadJson); + try { + report = await callDigest(userPayloadJson); + } catch (secondErr) { + if ( + isRecoverableDigestOutputError(firstErr) && + isRecoverableDigestOutputError(secondErr) + ) { + console.warn( + "[pipeline] digest output invalid after 2 attempts; using enriched-article fallback", + ); + report = buildFallbackDailyReport(articles); + } else { + // Auth, quota, networking, and configuration failures remain fatal; + // silently publishing through those would hide actionable problems. + throw secondErr; + } + } } // Max subscription has no per-call token meter — we expose 0 for schema diff --git a/lib/sources/rss.ts b/lib/sources/rss.ts index bbb724089..1c042835e 100644 --- a/lib/sources/rss.ts +++ b/lib/sources/rss.ts @@ -28,9 +28,17 @@ export async function fetchRss( options: { limit?: number; useCurl?: boolean } = {}, ): Promise { const limit = options.limit ?? 30; + const proxyConfigured = Boolean( + process.env.HTTPS_PROXY?.trim() || + process.env.https_proxy?.trim() || + process.env.ALL_PROXY?.trim() || + process.env.all_proxy?.trim(), + ); let feed; - if (options.useCurl) { + // rss-parser uses node:https directly and does not honor proxy environment + // variables. curl does, so route RSS through curl whenever a proxy is active. + if (options.useCurl || proxyConfigured) { const xml = await curlFetch(url, CURL_HEADERS); feed = await parser.parseString(xml); } else { diff --git a/package.json b/package.json index b95511e6b..4604657c8 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,12 @@ "version": "0.1.0", "private": true, "scripts": { - "dry-run": "tsx scripts/dry-run.ts", - "daily": "tsx scripts/daily.ts", + "test": "tsx --test lib/ai/*.test.ts lib/ai/backends/*.test.ts scripts/*.test.mjs", + "dry-run": "node scripts/run-tsx.mjs scripts/dry-run.ts", + "daily": "node scripts/run-tsx.mjs scripts/daily.ts", "render": "tsx scripts/render.ts", - "regen-trading": "tsx scripts/regen-trading.ts", - "regen-enrich": "tsx scripts/regen-enrich.ts", + "regen-trading": "node scripts/run-tsx.mjs scripts/regen-trading.ts", + "regen-enrich": "node scripts/run-tsx.mjs scripts/regen-enrich.ts", "quota-report": "tsx scripts/quota-report.ts", "sources": "tsx scripts/sources.ts", "sources:check": "tsx scripts/sources.ts check", diff --git a/scripts/proxy-env.mjs b/scripts/proxy-env.mjs new file mode 100644 index 000000000..69a8a7824 --- /dev/null +++ b/scripts/proxy-env.mjs @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; + +const PROXY_ENV_KEYS = [ + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", +]; + +export function hasProxyEnvironment(env) { + return PROXY_ENV_KEYS.some((key) => Boolean(env[key]?.trim())); +} + +export function parseScutilProxy(raw) { + const values = {}; + for (const line of raw.split(/\r?\n/)) { + const match = /^\s*([A-Za-z]+)\s*:\s*(.*?)\s*$/.exec(line); + if (match) values[match[1]] = match[2]; + } + return values; +} + +function proxyUrl(host, port) { + const formattedHost = host.includes(":") && !host.startsWith("[") + ? `[${host}]` + : host; + return `http://${formattedHost}:${port}`; +} + +/** + * Return a child-process environment with explicit proxy variables. + * Existing environment variables always win. On macOS, CLI programs do not + * automatically inherit the proxy configured in System Settings, so read the + * active HTTP(S) proxy from scutil when no explicit environment is present. + */ +export function prepareProxyEnvironment( + baseEnv = process.env, + platform = process.platform, + readMacProxy = () => + spawnSync("scutil", ["--proxy"], { encoding: "utf8" }).stdout ?? "", +) { + const env = { ...baseEnv }; + if (hasProxyEnvironment(env)) { + return { env, source: "environment", address: null }; + } + if (platform !== "darwin") { + return { env, source: null, address: null }; + } + + const proxy = parseScutilProxy(readMacProxy()); + const httpsEnabled = proxy.HTTPSEnable === "1"; + const httpEnabled = proxy.HTTPEnable === "1"; + const host = httpsEnabled + ? proxy.HTTPSProxy + : httpEnabled + ? proxy.HTTPProxy + : undefined; + const port = httpsEnabled + ? proxy.HTTPSPort + : httpEnabled + ? proxy.HTTPPort + : undefined; + if (!host || !port) { + return { env, source: null, address: null }; + } + + const address = proxyUrl(host, port); + env.HTTPS_PROXY = address; + env.HTTP_PROXY = address; + return { env, source: "macos-system", address }; +} diff --git a/scripts/proxy-env.test.mjs b/scripts/proxy-env.test.mjs new file mode 100644 index 000000000..544623b4d --- /dev/null +++ b/scripts/proxy-env.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + hasProxyEnvironment, + parseScutilProxy, + prepareProxyEnvironment, +} from "./proxy-env.mjs"; + +const MAC_PROXY = ` + { + HTTPEnable : 1 + HTTPPort : 9567 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 1 + HTTPSPort : 9567 + HTTPSProxy : 127.0.0.1 +} +`; + +test("parseScutilProxy extracts active proxy fields", () => { + const parsed = parseScutilProxy(MAC_PROXY); + assert.equal(parsed.HTTPSEnable, "1"); + assert.equal(parsed.HTTPSProxy, "127.0.0.1"); + assert.equal(parsed.HTTPSPort, "9567"); +}); + +test("prepareProxyEnvironment imports the macOS system proxy", () => { + const result = prepareProxyEnvironment({}, "darwin", () => MAC_PROXY); + assert.equal(result.source, "macos-system"); + assert.equal(result.env.HTTPS_PROXY, "http://127.0.0.1:9567"); + assert.equal(result.env.HTTP_PROXY, "http://127.0.0.1:9567"); +}); + +test("explicit proxy environment takes precedence", () => { + const result = prepareProxyEnvironment( + { HTTPS_PROXY: "http://proxy.example:8080" }, + "darwin", + () => { + throw new Error("scutil should not be called"); + }, + ); + assert.equal(result.source, "environment"); + assert.equal(result.env.HTTPS_PROXY, "http://proxy.example:8080"); + assert.equal(hasProxyEnvironment(result.env), true); +}); + +test("disabled macOS proxy leaves the environment unchanged", () => { + const result = prepareProxyEnvironment( + {}, + "darwin", + () => "HTTPEnable : 0\nHTTPSEnable : 0\n", + ); + assert.equal(result.source, null); + assert.equal(hasProxyEnvironment(result.env), false); +}); diff --git a/scripts/run-tsx.mjs b/scripts/run-tsx.mjs new file mode 100644 index 000000000..d6fab727e --- /dev/null +++ b/scripts/run-tsx.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { config } from "dotenv"; +import { prepareProxyEnvironment } from "./proxy-env.mjs"; + +const [entry, ...entryArgs] = process.argv.slice(2); +if (!entry) { + console.error("Usage: node scripts/run-tsx.mjs [...args]"); + process.exit(2); +} + +// The proxy must exist in the child environment before Node starts. Loading +// .env.local only inside scripts/_env.ts is too late for --use-env-proxy. +config({ path: ".env.local", quiet: true }); +const proxy = prepareProxyEnvironment(process.env); + +if (proxy.source === "macos-system") { + console.log(`[proxy] using macOS system proxy ${proxy.address}`); +} else if (proxy.source === "environment") { + console.log("[proxy] using proxy from environment/.env.local"); +} + +const nodeArgs = []; +if (proxy.source) { + if (process.allowedNodeEnvironmentFlags.has("--use-env-proxy")) { + nodeArgs.push("--use-env-proxy"); + } else { + console.warn( + "[proxy] this Node version cannot proxy built-in fetch; use Node 24+ or a TUN/VPN network mode", + ); + } +} +nodeArgs.push("--import", "tsx", entry, ...entryArgs); + +const child = spawn(process.execPath, nodeArgs, { + env: proxy.env, + stdio: "inherit", +}); + +child.on("error", (error) => { + console.error(`[runner] failed to start ${entry}: ${error.message}`); + process.exitCode = 1; +}); +child.on("exit", (code, signal) => { + if (signal) { + console.error(`[runner] ${entry} stopped by ${signal}`); + process.exitCode = 1; + return; + } + process.exitCode = code ?? 1; +});