diff --git a/README.md b/README.md index 28dc2cf..db805d8 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ sandbase runs list | sandbase balance `-j` prints JSON; errors are `{"error":{"code","message","hint"}}` with the next step in `hint`. Exit codes: `0` success or still running, `1` failure (including a failed run), `2` usage. -If `api.sandbase.ai` is unreachable, the CLI and the MCP bridge fall back to `www.sandbase.ai` automatically, and the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server. +Sync tools (LLMs, most data APIs) return their result directly; async tools (image, video, audio jobs) return a run id — wait with `-w`, or fetch later with `sandbase runs get -r `. If `api.sandbase.ai` is unreachable, the bridge keeps the SandBase tools visible with an actionable error instead of dropping the server. ### MCP Tools (available to your agent after connecting) diff --git a/package-lock.json b/package-lock.json index ff85080..832022a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "license": "Apache-2.0", "dependencies": { "jsonc-parser": "3.3.1", diff --git a/package.json b/package.json index ae88804..1ef45f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "mcpName": "io.github.sandbaseai/cli", "description": "Give 25 AI clients—Claude Code, Codex, Cursor, Hermes, OpenClaw and more—2,000+ models and APIs through one MCP server and an agent-friendly CLI", "type": "module", diff --git a/server.json b/server.json index 1755a6b..6bfc5c6 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.sandbaseai/cli", "title": "SandBase", "description": "Connect MCP clients to 2,000+ AI models without managing provider API keys.", - "version": "0.3.2", + "version": "0.3.3", "repository": { "url": "https://github.com/sandbaseai/cli", "source": "github" @@ -26,7 +26,7 @@ { "registryType": "npm", "identifier": "@sandbaseai/cli", - "version": "0.3.2", + "version": "0.3.3", "packageArguments": [ { "type": "positional", diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index 4b3ef1e..1be83e0 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -1,6 +1,6 @@ --- name: sandbase -version: 0.3.2 +version: 0.3.3 description: >- Discover better ways to complete tasks. Proactively search SandBase (the sandbase_discover MCP tool, or `sandbase discover` in a shell) before writing a @@ -67,10 +67,10 @@ Discovery, inspection, run status, and balance are **free**; only `sandbase_run` 1. `sandbase_discover({"q": "twitter posts"})` → pick a `tool_name` from `data` (each result carries `description`, `pricing`, `vendor`, `run_count`). 2. `sandbase_inspect({"name": "sandbase_twitter_web_search_timeline"})` → read `inputSchema` and `pricing`; copy `execute_as`. -3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})`. - - Fast tools return `status: "completed"` with `output` directly. - - Slow tools (video, music, large scrapes) return `status: "running"` plus `prediction_id` / `taskId` after up to ~20 s. -4. If still running, call `sandbase_run_get({"run_id": ""})` every 5–10 seconds until `status` is `completed` or `failed`. The finished `output` is in the response. If a completed run shows no `output`, fetch it with the CLI: `sandbase runs get -r -o result.json`. +3. `sandbase_run({"name": "sandbase_twitter_web_search_timeline", "arguments": {"keyword": "AI agents"}})`. There are two kinds of tools: + - **Sync** (LLMs, most data APIs): the call waits for the upstream and returns `status: "completed"` with `output` directly. + - **Async** (image, video, audio, other long jobs): the call returns at once with `status: "running"` and a `run_id` (also `prediction_id` / `taskId`). +4. For an async run, call `sandbase_run_get({"run_id": ""})` every 5–10 seconds until `status` is `completed` or `failed`; the finished `output` is in that response. A run that has finished but is still copying its files also reports `running` — keep polling. Never call `sandbase_run` again for the same job (that starts and bills a new run). **Agent Services** (`svc_…` names from `type: "service"`): pass `arguments: {"idempotency_key": "", "input": "..."}`; reuse the same key and input on retries. Poll with `sandbase_run_get({"run_id": ..., "service_id": "svc_…"})`; `succeeded`, `failed`, and `cancelled` are terminal. @@ -158,22 +158,6 @@ Tool failures come back as error results whose text names the cause **and the ne --- -## 7b. Health - -`discover` and `inspect` may include a `metrics` block per tool: `status` (healthy / stable / degraded / outage / unknown) plus `run_time_ms` (p50 and p95). The CLI shows a HEALTH column in discover and a Health line in inspect. - -| Status | Meaning | -|---|---| -| `healthy` | Confirmed working in the last few minutes | -| `stable` | No very recent sample, but a strong 24h record | -| `degraded` | Elevated failures — usually still works | -| `outage` | Not working right now | -| `unknown` *(or missing)* | Not enough data — never a warning | - -**Use health to break ties, never to filter.** Between two tools that both fit, prefer the healthier and faster one. Never skip a tool because its status is unknown or absent — that just means low traffic. Metrics are a platform-wide aggregate and can be briefly missing on a given call; if you need it, call again. - ---- - ## 8. Hints Responses may include `hints` (and `warnings`): suggested next steps, caveats, or alternatives from the server. Read them before deciding your next move and prefer them over guessing. diff --git a/skills/sandbase/skill.json b/skills/sandbase/skill.json index 440b3fe..d36b234 100644 --- a/skills/sandbase/skill.json +++ b/skills/sandbase/skill.json @@ -1,6 +1,6 @@ { "name": "sandbase", - "version": "0.3.2", + "version": "0.3.3", "description": "Discover and run 2,000+ AI models and APIs (media generation, social data, search, scraping) through one MCP interface or CLI, paid per call from one balance.", "author": "SandBase AI", "license": "Apache-2.0", diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 2f87a4a..a123f97 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -27,7 +27,9 @@ export function detectClient(client: Client): {installed:boolean; detail:string} if (!profile.executable) return profile.mode === "auto" && existsSync(path) ? {installed:true,detail:"existing configuration"} : {installed:false,detail:"not detected"}; const result=spawnSync(profile.executable,["--version"],{encoding:"utf8",timeout:5000}); const detail=(result.stdout || result.stderr || "").trim().split("\n")[0] || "not found"; if (result.status===0) return {installed:true,detail}; if (client === "codex" && existsSync(path)) return {installed:true,detail:"Codex CLI probe failed; existing Codex configuration detected"}; return {installed:false,detail}; } const start = "# >>> sandbase managed >>>", end = "# <<< sandbase managed <<<"; function block(client: Client, bridge: string): string { - if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\nenv = { SANDBASE_CLI_MANAGED = "1" }\n${end}`; + // tool_timeout_sec: Codex cancels tool calls after 60s by default; a sync model + // (e.g. a long LLM or scrape call) can legitimately take longer. + if (client === "codex") return `${start}\n[mcp_servers.sandbase]\ncommand = "node"\nargs = ["${bridge.replaceAll("\\", "\\\\").replaceAll('"','\\"')}", "--client", "codex"]\ntool_timeout_sec = 1800\nenv = { SANDBASE_CLI_MANAGED = "1" }\n${end}`; return ` ${start}\n sandbase:\n command: node\n args:\n - ${JSON.stringify(bridge)}\n - --client\n - ${client}\n ${end}`; } function managedText(raw: string, value: string): string { const re = new RegExp(`${start.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}`, "m"); return re.test(raw) ? raw.replace(re, value) : raw.replace(/\s*$/, "") + (raw.trim() ? "\n\n" : "") + value + "\n"; } diff --git a/src/agent/commands.ts b/src/agent/commands.ts index 39bbd2c..c3a7a6b 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -47,6 +47,8 @@ class Output { else this.err(`Error [${error.code}]: ${error.message}${error.runId ? `\nRun: ${error.runId}` : ""}${error.hint ? `\nNext: ${error.hint}` : ""}`); } notice(text: string): void { if (this.json) this.notes.push(text); else this.err(text); } + /** Always stderr (also in -j mode), so stdout stays parseable. */ + progress(text: string): void { this.err(text); } } function truncate(value: string, width: number): string { const flat = value.replace(/\s+/g, " ").trim(); return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat; } @@ -79,29 +81,6 @@ function collectUrls(value: unknown, found = new Set()): Set { return found; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -/** Verbose health line for inspect, e.g. "stable · 3.5s typical, 27.7s tail · 75 samples". */ -export function healthLine(metrics: unknown): string { - if (!metrics || typeof metrics !== "object") return ""; - const m = metrics as { status?: unknown; run_time_ms?: { p50?: unknown; p95?: unknown }; sample_size?: unknown }; - const status = typeof m.status === "string" ? m.status : ""; - if (!status) return ""; - const parts = [status]; - const p50 = m.run_time_ms && typeof m.run_time_ms.p50 === "number" ? m.run_time_ms.p50 : undefined; - const p95 = m.run_time_ms && typeof m.run_time_ms.p95 === "number" ? m.run_time_ms.p95 : undefined; - if (p50 !== undefined) parts.push(p95 !== undefined ? `${(p50 / 1000).toFixed(1)}s typical, ${(p95 / 1000).toFixed(1)}s tail` : `${(p50 / 1000).toFixed(1)}s typical`); - if (typeof m.sample_size === "number") parts.push(`${m.sample_size} samples`); - return parts.join(" · "); -} -/** Compact health cell for the discover table, e.g. "healthy 4.2s" or "stable". Blank when absent. */ -export function formatHealth(metrics: unknown): string { - if (!metrics || typeof metrics !== "object") return ""; - const m = metrics as { status?: unknown; run_time_ms?: { p50?: unknown } }; - const status = typeof m.status === "string" ? m.status : ""; - const p50 = m.run_time_ms && typeof m.run_time_ms.p50 === "number" ? m.run_time_ms.p50 : undefined; - if (!status) return ""; - return p50 !== undefined ? `${status} ${(p50 / 1000).toFixed(1)}s` : status; -} - type OptionSpec = Record; const commonOptions = { json: { type: "boolean", short: "j" }, client: { type: "string" }, help: { type: "boolean", short: "h" } } as const; function parse(argv: string[], options: T) { @@ -167,14 +146,19 @@ async function pollRun(client: SandBaseMCPClient, initial: RunView, timeoutSecon let view = initial, delay = 1000, emptyCompletions = 0; for (;;) { const phase = phaseOf(view.status); - // A completed media run can briefly report no output while its files are transferred. - if (phase === "failed" || (phase === "succeeded" && (hasOutput(view) || emptyCompletions >= 8))) return view; - if (phase === "succeeded") emptyCompletions++; + // A completed media run reports no output while its files are transferred: + // keep waiting for those until the timeout; other empty completions (e.g. no + // stored output) settle after a few reads. + const transferring = phase === "succeeded" && !hasOutput(view) && /still being transferred/i.test(JSON.stringify(view.hints ?? "")); + if (phase === "failed" || (phase === "succeeded" && !transferring && (hasOutput(view) || emptyCompletions >= 3))) return view; + if (phase === "succeeded" && !transferring) emptyCompletions++; if (deps.now() + delay > deadline) return { ...view, timed_out: true }; await deps.sleep(delay); delay = Math.min(Math.round(delay * 1.5), 10_000); const next = await readRun(client, initial.run_id, initial.service_id); const tool = view.tool ?? next.tool; - view = { ...view, ...next, ...(tool ? { tool } : {}) }; + // Hints describe the latest state only; a stale "still in progress" must not survive completion. + const { hints: _staleHints, ...rest } = view; + view = { ...rest, ...next, ...(tool ? { tool } : {}) }; } } @@ -226,8 +210,8 @@ async function discover(argv: string[], out: Output, env: NodeJS.ProcessEnv, fet const items = (Array.isArray(data.data) ? data.data : []) as Array>; if (out.json) { out.data(data); return 0; } if (!items.length) { out.print(`No tools matched "${q}". Try a shorter noun phrase, a synonym, or filter with -t.`); printHints(out, data.hints, data.warnings); return 0; } - const rows = items.map(item => [String(item.tool_name ?? item.name ?? item.id ?? ""), String(item.category ?? item.type ?? ""), String(item.vendor ?? item.vendor_slug ?? ""), formatPrice(item.pricing), formatHealth(item.metrics), truncate(String(item.description ?? ""), 60)]); - out.print([`Found ${items.length} tool${items.length === 1 ? "" : "s"}${q ? ` for "${q}"` : ""}:`, "", table(["TOOL", "TYPE", "VENDOR", "PRICE", "HEALTH", "DESCRIPTION"], rows), "", "Next: sandbase inspect (input schema, exact price, and a ready-to-run command)", "HEALTH is advisory: prefer healthy/stable and lower latency to break ties; blank/unknown means no data, not unhealthy."].join("\n")); + const rows = items.map(item => [String(item.tool_name ?? item.name ?? item.id ?? ""), String(item.category ?? item.type ?? ""), String(item.vendor ?? item.vendor_slug ?? ""), formatPrice(item.pricing), truncate(String(item.description ?? ""), 60)]); + out.print([`Found ${items.length} tool${items.length === 1 ? "" : "s"}${q ? ` for "${q}"` : ""}:`, "", table(["TOOL", "TYPE", "VENDOR", "PRICE", "DESCRIPTION"], rows), "", "Next: sandbase inspect (input schema, exact price, and a ready-to-run command)"].join("\n")); printHints(out, data.hints, data.warnings); return 0; } @@ -278,8 +262,6 @@ async function inspect(argv: string[], out: Output, env: NodeJS.ProcessEnv, fetc const priceLine = [formatPrice(pricing), typeof pricing.formula === "string" && pricing.formula ? `formula: ${pricing.formula}` : ""].filter(Boolean).join(" "); const meta = [data.model, data.category ?? data.type, data.vendor].filter(Boolean).join(" · "); const lines = [`${tool}${meta ? ` (${meta})` : ""}`, String(data.description ?? "").trim(), "", `Price: ${priceLine}`]; - const health = healthLine(data.metrics); - if (health) lines.push(`Health: ${health}`); lines.push("", rows.length ? `Input (send as JSON with -i):\n${table(["FIELD", "TYPE", "REQUIRED", "DESCRIPTION"], rows)}` : "Input: no parameters."); lines.push("", "Run:", ` sandbase run ${tool} -i ${shellQuote(JSON.stringify(example))}${/video|image|audio|music|speech/i.test(`${data.category ?? ""} ${data.description ?? ""} ${tool}`) ? " -w -o result.json" : ""}`); lines.push(`MCP: sandbase_run ${JSON.stringify({ name: tool, arguments: example })}`); @@ -314,7 +296,11 @@ async function run(argv: string[], out: Output, env: NodeJS.ProcessEnv, deps: Re if (typeof args.idempotency_key === "string" && view.service_id) view.idempotency_key = args.idempotency_key; if (!view.run_id) return finishRun(out, view, values.output); try { - if (values.wait && phaseOf(view.status) === "running") view = await pollRun(api, view, timeout, deps); + if (values.wait && phaseOf(view.status) === "running") { + // stderr, also in -j mode: an agent whose shell times out still sees the run id to resume. + out.progress(`Run ${view.run_id} started; waiting for the result (up to ${timeout}s). If interrupted, resume with: sandbase runs get -r ${view.run_id}${view.service_id ? ` --service ${view.service_id}` : ""} -w${values.output ? ` -o ${values.output}` : ""}`); + view = await pollRun(api, view, timeout, deps); + } else if (phaseOf(view.status) !== "running" && view.cost === undefined && !view.service_id) { try { const { data: detail } = await api.tool("sandbase_run_get", { run_id: view.run_id }); if (detail.cost !== undefined) view.cost = detail.cost; } catch { /* cost is informational */ } } diff --git a/test/adapters.test.ts b/test/adapters.test.ts index beaae80..df57df2 100644 --- a/test/adapters.test.ts +++ b/test/adapters.test.ts @@ -17,6 +17,14 @@ for (const client of ["codex","claude-code","cursor","hermes"] as Client[]) test assert.equal(await unregister(client,env),true); const removed=await readFile(path,"utf8"); assert.ok(!removed.includes("safe bridge")); assert.ok(removed.includes("other") || removed.includes("model")); await rollback(one); assert.equal(await readFile(path,"utf8"),original); }); +test("Codex config raises the tool timeout so long sync calls are not cut at 60s", async () => { + const home=await mkdtemp(join(tmpdir(),"sandbase-codex-timeout-")); const env={HOME:home,CODEX_HOME:join(home,".codex")}; await mkdir(env.CODEX_HOME,{recursive:true}); + await configure("codex","/sandbase-mcp-bridge.mjs",env); + const text=await readFile(join(env.CODEX_HOME,"config.toml"),"utf8"); + assert.match(text,/^tool_timeout_sec = 1800$/m); + assert.equal(await isConfigured("codex",env),true); +}); + test("Codex and Hermes leave a pre-existing unowned sandbase entry untouched",async()=>{ for(const client of ["codex","hermes"] as Client[]){ const home=await mkdtemp(join(tmpdir(),"sandbase-existing-")); const env={HOME:home,CODEX_HOME:join(home,".codex"),HERMES_HOME:join(home,".hermes")}; const path=client==="codex"?join(env.CODEX_HOME,"config.toml"):join(env.HERMES_HOME,"config.yaml"); const original=client==="codex"?'[mcp_servers.sandbase]\ncommand="old"\n[mcp_servers.other]\ncommand="keep"\n':'mcp_servers:\n sandbase:\n command: old\n other:\n command: keep\n'; await mkdir(join(path,".."),{recursive:true}); await writeFile(path,original); await assert.rejects(configure(client,"/new",env),/invalid/); assert.equal(await readFile(path,"utf8"),original); }}); test("Claude Code and Cursor leave a pre-existing unowned sandbase key untouched",async()=>{ for(const client of ["claude-code","cursor"] as Client[]){ const home=await mkdtemp(join(tmpdir(),"sandbase-json-existing-")); const path=client==="claude-code"?join(home,".claude.json"):join(home,".cursor","mcp.json"); const original=JSON.stringify({theme:"keep",mcpServers:{sandbase:{command:"old"},other:{command:"keep"}}},null,2); await mkdir(join(path,".."),{recursive:true}); await writeFile(path,original); await assert.rejects(configure(client,"/new bridge",{HOME:home}),/invalid/); assert.equal(await readFile(path,"utf8"),original); }}); diff --git a/test/agent.test.ts b/test/agent.test.ts index 8e65fd1..7e2f8da 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -77,17 +77,32 @@ test("network and HTTP failures carry a code and a next step", async () => { assert.equal(JSON.parse(json.out).error.code, "NETWORK"); assert.doesNotMatch(json.out + json.err, /test-credential-value/); }); -test("discover shows a HEALTH column and inspect shows a health line", async () => { +test("discover and inspect show no health column or line", async () => { const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); - const discover = fakeFetch(() => toolResult({ data: [{ tool_name: "sandbase_a", category: "api", vendor: "V", pricing: { base_price: "0.01" }, description: "x", metrics: { status: "stable", run_time_ms: { p50: 3543, p95: 27748 }, sample_size: 75 } }], count: 1 })); + const discover = fakeFetch(() => toolResult({ data: [{ tool_name: "sandbase_a", category: "api", vendor: "V", pricing: { base_price: "0.01" }, description: "x", metrics: { status: "stable" } }], count: 1 })); const found = await cli("discover", ["-q", "a"], env, discover.fetch); - assert.match(found.out, /HEALTH/); assert.match(found.out, /stable 3\.5s/); assert.match(found.out, /break ties/); - const inspect = fakeFetch(() => toolResult({ tool_name: "sandbase_a", description: "x", pricing: { base_price: "0.01" }, inputSchema: { type: "object", properties: {} }, metrics: { status: "stable", run_time_ms: { p50: 3543, p95: 27748 }, sample_size: 75 } })); - const detail = await cli("inspect", ["sandbase_a"], env, inspect.fetch); - assert.match(detail.out, /Health: stable · 3\.5s typical, 27\.7s tail · 75 samples/); - // No metrics -> no HEALTH noise beyond the (empty) column. - const bare = fakeFetch(() => toolResult({ tool_name: "sandbase_b", description: "y", pricing: {}, inputSchema: { type: "object", properties: {} } })); - assert.doesNotMatch((await cli("inspect", ["sandbase_b"], env, bare.fetch)).out, /Health:/); + assert.match(found.out, /TOOL\s+TYPE\s+VENDOR\s+PRICE\s+DESCRIPTION/); assert.doesNotMatch(found.out, /HEALTH|stable/); + const inspect = fakeFetch(() => toolResult({ tool_name: "sandbase_a", description: "x", pricing: { base_price: "0.01" }, inputSchema: { type: "object", properties: {} }, metrics: { status: "stable" } })); + assert.doesNotMatch((await cli("inspect", ["sandbase_a"], env, inspect.fetch)).out, /Health/); +}); + +test("run -w keeps waiting while files are transferred, drops stale hints, and announces the run id on stderr", async () => { + const env = await home(); await connected(env, "codex", "http://127.0.0.1:9/v1/mcp"); + let polls = 0; + const { fetch } = fakeFetch(call => { + const name = (call.params as { name?: string } | undefined)?.name; + if (name === "sandbase_run") return toolResult({ prediction_id: "pred_t", status: "running", output: null, hints: ["Run is still in progress."] }); + if (name === "sandbase_run_get") return toolResult(++polls < 6 ? { run_id: "pred_t", status: "completed", hints: ["The run completed and its files are still being transferred. Poll sandbase_run_get again in a few seconds."] } : { run_id: "pred_t", status: "completed", output: ["https://cdn.test/t.mp4"] }); + if (call.method === "tasks/result") return { body: { result: { structuredContent: { status: "completed", output: null } } } }; + return { body: { error: { code: -32601, message: "unexpected" } } }; + }); + const json = await cli("run", ["sandbase_video", "-i", "{}", "-w", "-j"], env, fetch); + assert.equal(json.code, 0, json.err); + assert.deepEqual(JSON.parse(json.out).output, ["https://cdn.test/t.mp4"]); + assert.match(json.err, /Run pred_t started; waiting for the result.*sandbase runs get -r pred_t -w/); + polls = 0; + const text = await cli("run", ["sandbase_video", "-i", "{}", "-w"], env, fetch); + assert.match(text.out, /Status: completed/); assert.doesNotMatch(text.out, /still in progress|being transferred/); }); test("discover renders a table with price and next step; zero results show server hints", async () => { @@ -314,7 +329,7 @@ test("setup refreshes the bridge and SandBase-owned Skills but leaves edited Ski const lines: string[] = []; assert.equal(await setup(new FileCredentialStore(env.SANDBASE_HOME!), line => void lines.push(line)), true); assert.match(lines.join("\n"), /bridge: status=updated/); assert.match(lines.join("\n"), /claude-code: skill=updated/); assert.match(lines.join("\n"), /kiro: skill=modified/); - assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.2/); + assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.3/); assert.match(await readFile(join(kiroDir, "SKILL.md"), "utf8"), /user edit/); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } });