mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
fix: use process.env.HOME instead of os.homedir() for test sandboxing (#2417)
Bun's os.homedir() reads from getpwuid() and ignores runtime changes to process.env.HOME. Named imports capture the native function binding, so patching os.homedir on the default export doesn't propagate. This caused all test files using homedir() to write .spawn-test-* dirs to the real home directory instead of the preload sandbox. Add getUserHome() helper to shared/ui.ts that prefers process.env.HOME, replace all direct homedir() calls in production and test code. Co-authored-by: lab <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1afa4615f
commit
486aba49f6
21 changed files with 72 additions and 58 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.15.33",
|
||||
"version": "0.15.34",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { clearHistory, filterHistory, getHistoryPath, loadHistory, saveSpawnRecord } from "../history.js";
|
||||
import { mockClackPrompts } from "./test-helpers";
|
||||
|
|
@ -21,7 +20,7 @@ describe("clearHistory", () => {
|
|||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
@ -294,7 +293,7 @@ describe("cmdListClear", () => {
|
|||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { homedir } from "node:os";
|
||||
import { loadManifest } from "../manifest";
|
||||
import { isString } from "../shared/type-guards";
|
||||
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
|
||||
|
|
@ -65,7 +64,7 @@ describe("cmdInteractive", () => {
|
|||
|
||||
// Isolate from host history so getActiveServers() returns []
|
||||
originalSpawnHome = process.env.SPAWN_HOME;
|
||||
process.env.SPAWN_HOME = `${homedir()}/.spawn-test-${Date.now()}`;
|
||||
process.env.SPAWN_HOME = `${process.env.HOME ?? ""}/.spawn-test-${Date.now()}`;
|
||||
mockLogError.mockClear();
|
||||
mockLogInfo.mockClear();
|
||||
mockLogStep.mockClear();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history";
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
|
||||
|
||||
|
|
@ -54,7 +53,7 @@ describe("cmdLast", () => {
|
|||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(homedir(), `spawn-cmdlast-test-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `spawn-cmdlast-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history";
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
|
||||
|
||||
|
|
@ -63,7 +62,7 @@ describe("cmdList integration", () => {
|
|||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(homedir(), `spawn-cmdlist-test-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `spawn-cmdlist-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadManifest } from "../manifest";
|
||||
import { isString } from "../shared/type-guards";
|
||||
|
|
@ -100,7 +99,7 @@ describe("cmdRun --name duplicate detection", () => {
|
|||
originalSpawnHome = process.env.SPAWN_HOME;
|
||||
originalSpawnName = process.env.SPAWN_NAME;
|
||||
|
||||
historyDir = join(homedir(), `spawn-dup-test-${Date.now()}-${Math.random()}`);
|
||||
historyDir = join(process.env.HOME ?? "", `spawn-dup-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(historyDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { HISTORY_SCHEMA_VERSION } from "../history.js";
|
||||
import { loadManifest } from "../manifest";
|
||||
|
|
@ -134,7 +133,7 @@ describe("cmdRun happy-path pipeline", () => {
|
|||
originalFetch = global.fetch;
|
||||
|
||||
// Set up isolated history directory
|
||||
historyDir = join(homedir(), `spawn-test-history-${Date.now()}-${Math.random()}`);
|
||||
historyDir = join(process.env.HOME ?? "", `spawn-test-history-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(historyDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
@ -340,7 +339,7 @@ describe("cmdRun happy-path pipeline", () => {
|
|||
|
||||
it("should still execute script when history save fails", async () => {
|
||||
// Make history dir read-only to force saveSpawnRecord failure
|
||||
const readOnlyDir = join(homedir(), `spawn-test-readonly-${Date.now()}`);
|
||||
const readOnlyDir = join(process.env.HOME ?? "", `spawn-test-readonly-${Date.now()}`);
|
||||
mkdirSync(readOnlyDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadHistory, saveSpawnRecord } from "../history.js";
|
||||
|
||||
|
|
@ -12,7 +11,7 @@ describe("history corruption recovery", () => {
|
|||
let consoleErrorSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(homedir(), `.spawn-test-corrupt-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `.spawn-test-corrupt-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import type { SpawnRecord } from "../history.js";
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
generateSpawnId,
|
||||
|
|
@ -30,7 +29,7 @@ describe("history spawn IDs", () => {
|
|||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { filterHistory, HISTORY_SCHEMA_VERSION, loadHistory, saveSpawnRecord } from "../history.js";
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ describe("History Trimming and Boundaries", () => {
|
|||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(homedir(), `spawn-history-trim-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `spawn-history-trim-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
filterHistory,
|
||||
|
|
@ -19,7 +18,7 @@ describe("history", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
// Use a directory within home directory for testing (required by security validation)
|
||||
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
@ -43,14 +42,14 @@ describe("history", () => {
|
|||
|
||||
describe("getSpawnDir", () => {
|
||||
it("returns SPAWN_HOME when set to valid path within home", () => {
|
||||
const validPath = join(homedir(), "custom", "spawn", "dir");
|
||||
const validPath = join(process.env.HOME ?? "", "custom", "spawn", "dir");
|
||||
process.env.SPAWN_HOME = validPath;
|
||||
expect(getSpawnDir()).toBe(validPath);
|
||||
});
|
||||
|
||||
it("falls back to ~/.spawn when SPAWN_HOME is not set", () => {
|
||||
delete process.env.SPAWN_HOME;
|
||||
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
|
||||
expect(getSpawnDir()).toBe(join(process.env.HOME ?? "", ".spawn"));
|
||||
});
|
||||
|
||||
it("throws for relative SPAWN_HOME path", () => {
|
||||
|
|
@ -64,13 +63,13 @@ describe("history", () => {
|
|||
});
|
||||
|
||||
it("resolves .. segments in absolute SPAWN_HOME within home", () => {
|
||||
const pathWithDots = join(homedir(), "foo", "..", "bar");
|
||||
const pathWithDots = join(process.env.HOME ?? "", "foo", "..", "bar");
|
||||
process.env.SPAWN_HOME = pathWithDots;
|
||||
expect(getSpawnDir()).toBe(join(homedir(), "bar"));
|
||||
expect(getSpawnDir()).toBe(join(process.env.HOME ?? "", "bar"));
|
||||
});
|
||||
|
||||
it("accepts normal absolute SPAWN_HOME within home", () => {
|
||||
const validPath = join(homedir(), ".spawn");
|
||||
const validPath = join(process.env.HOME ?? "", ".spawn");
|
||||
process.env.SPAWN_HOME = validPath;
|
||||
expect(getSpawnDir()).toBe(validPath);
|
||||
});
|
||||
|
|
@ -83,14 +82,14 @@ describe("history", () => {
|
|||
it("throws for path traversal attempt to escape home directory", () => {
|
||||
// Attempt to traverse outside home using .. segments
|
||||
// e.g., /home/user/../../etc/.spawn
|
||||
const traversalPath = join(homedir(), "..", "..", "etc", ".spawn");
|
||||
const traversalPath = join(process.env.HOME ?? "", "..", "..", "etc", ".spawn");
|
||||
process.env.SPAWN_HOME = traversalPath;
|
||||
expect(() => getSpawnDir()).toThrow("must be within your home directory");
|
||||
});
|
||||
|
||||
it("accepts home directory itself as SPAWN_HOME", () => {
|
||||
process.env.SPAWN_HOME = homedir();
|
||||
expect(getSpawnDir()).toBe(homedir());
|
||||
process.env.SPAWN_HOME = process.env.HOME ?? "";
|
||||
expect(getSpawnDir()).toBe(process.env.HOME ?? "");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -247,7 +246,7 @@ describe("history", () => {
|
|||
|
||||
describe("saveSpawnRecord", () => {
|
||||
it("creates directory and file when neither exist", () => {
|
||||
const nestedDir = join(homedir(), ".spawn-test", "nested", "spawn");
|
||||
const nestedDir = join(process.env.HOME ?? "", ".spawn-test", "nested", "spawn");
|
||||
process.env.SPAWN_HOME = nestedDir;
|
||||
|
||||
saveSpawnRecord({
|
||||
|
|
@ -263,7 +262,7 @@ describe("history", () => {
|
|||
expect(data.records[0].agent).toBe("claude");
|
||||
|
||||
// Clean up
|
||||
rmSync(join(homedir(), ".spawn-test"), {
|
||||
rmSync(join(process.env.HOME ?? "", ".spawn-test"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { isNumber } from "../shared/type-guards.js";
|
||||
|
||||
|
|
@ -110,7 +109,7 @@ describe("runOrchestration", () => {
|
|||
beforeEach(() => {
|
||||
capturedExitCode = undefined;
|
||||
// Isolate history writes to a temp directory so tests never pollute ~/.spawn
|
||||
testDir = join(homedir(), `.spawn-test-orch-${Date.now()}-${Math.random()}`);
|
||||
testDir = join(process.env.HOME ?? "", `.spawn-test-orch-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import os, { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// ── Stray test file cleanup ──────────────────────────────────────────────────
|
||||
|
|
@ -67,6 +67,22 @@ process.env.XDG_CACHE_HOME = join(TEST_HOME, ".cache");
|
|||
process.env.XDG_CONFIG_HOME = join(TEST_HOME, ".config");
|
||||
process.env.XDG_DATA_HOME = join(TEST_HOME, ".local", "share");
|
||||
|
||||
// ── IMPORTANT: Bun's os.homedir() ignores process.env.HOME ──────────────
|
||||
//
|
||||
// Bun's os.homedir() reads from getpwuid() and never re-checks env vars.
|
||||
// Named imports (`import { homedir } from "node:os"`) capture a binding to
|
||||
// the native function, so patching `os.homedir` on the default export does
|
||||
// NOT propagate to other modules' destructured imports.
|
||||
//
|
||||
// The ONLY reliable way to sandbox homedir in tests is to ensure all code
|
||||
// uses `process.env.HOME` (which the preload controls) rather than calling
|
||||
// `homedir()` directly. Production code uses `getUserHome()` from
|
||||
// shared/ui.ts; test files should use `process.env.HOME ?? ""`.
|
||||
//
|
||||
// This default-export patch catches direct `os.homedir()` calls (rare) but
|
||||
// cannot fix `import { homedir } from "node:os"` in other modules.
|
||||
os.homedir = () => TEST_HOME;
|
||||
|
||||
// Pre-create common directories tests might expect
|
||||
mkdirSync(join(TEST_HOME, ".cache"), {
|
||||
recursive: true,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { VMConnection } from "../history.js";
|
|||
import type { CloudInitTier } from "../shared/agents";
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
|
||||
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
|
||||
|
|
@ -19,6 +18,7 @@ import {
|
|||
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys";
|
||||
import {
|
||||
getServerNameFromEnv,
|
||||
getUserHome,
|
||||
logError,
|
||||
logInfo,
|
||||
logStep,
|
||||
|
|
@ -177,7 +177,7 @@ function getGcloudCmd(): string | null {
|
|||
}
|
||||
// Check common install locations
|
||||
const paths = [
|
||||
join(process.env.HOME || homedir(), "google-cloud-sdk/bin/gcloud"),
|
||||
join(getUserHome(), "google-cloud-sdk/bin/gcloud"),
|
||||
"/usr/lib/google-cloud-sdk/bin/gcloud",
|
||||
"/snap/bin/gcloud",
|
||||
];
|
||||
|
|
@ -389,7 +389,7 @@ export async function ensureGcloudCli(): Promise<void> {
|
|||
}
|
||||
|
||||
// Add to PATH
|
||||
const sdkBin = join(process.env.HOME || homedir(), "google-cloud-sdk/bin");
|
||||
const sdkBin = join(getUserHome(), "google-cloud-sdk/bin");
|
||||
if (!process.env.PATH?.includes(sdkBin)) {
|
||||
process.env.PATH = `${sdkBin}:${process.env.PATH}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,11 @@ import {
|
|||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import * as v from "valibot";
|
||||
import { tryCatch } from "./shared/result.js";
|
||||
import { getErrorMessage } from "./shared/type-guards.js";
|
||||
import { logDebug, logWarn } from "./shared/ui.js";
|
||||
import { getUserHome, logDebug, logWarn } from "./shared/ui.js";
|
||||
|
||||
export interface VMConnection {
|
||||
ip: string;
|
||||
|
|
@ -87,7 +86,7 @@ export function generateSpawnId(): string {
|
|||
export function getSpawnDir(): string {
|
||||
const spawnHome = process.env.SPAWN_HOME;
|
||||
if (!spawnHome) {
|
||||
return join(homedir(), ".spawn");
|
||||
return join(getUserHome(), ".spawn");
|
||||
}
|
||||
// Require absolute path to prevent path traversal via relative paths
|
||||
if (!isAbsolute(spawnHome)) {
|
||||
|
|
@ -102,7 +101,7 @@ export function getSpawnDir(): string {
|
|||
// Even though the path is absolute, resolve() can normalize paths like
|
||||
// /tmp/../../root/.spawn to /root/.spawn, potentially allowing unauthorized
|
||||
// file writes to sensitive directories.
|
||||
const userHome = homedir();
|
||||
const userHome = getUserHome();
|
||||
if (!resolved.startsWith(userHome + "/") && resolved !== userHome) {
|
||||
throw new Error("SPAWN_HOME must be within your home directory.\n" + `Got: ${resolved}\n` + `Home: ${userHome}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// local/local.ts — Core local provider: runs commands on the user's machine
|
||||
|
||||
import { copyFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import { spawnInteractive } from "../shared/ssh";
|
||||
import { getUserHome } from "../shared/ui";
|
||||
|
||||
// ─── Execution ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ export async function runLocal(cmd: string): Promise<void> {
|
|||
|
||||
/** Copy a file locally, expanding ~ in the destination path. */
|
||||
export function uploadFile(localPath: string, remotePath: string): void {
|
||||
const expanded = remotePath.replace(/^~/, process.env.HOME || homedir());
|
||||
const expanded = remotePath.replace(/^~/, getUserHome());
|
||||
mkdirSync(dirname(expanded), {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getErrorMessage } from "./shared/type-guards.js";
|
||||
import { getUserHome } from "./shared/ui.js";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ const SPAWN_CDN = "https://openrouter.ai/labs/spawn" as const;
|
|||
const VERSION_URL = `https://github.com/${REPO}/releases/download/cli-latest/version` as const;
|
||||
// Dynamic getters so tests can override XDG_CACHE_HOME at runtime
|
||||
function getCacheDir(): string {
|
||||
return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "spawn");
|
||||
return join(process.env.XDG_CACHE_HOME || join(getUserHome(), ".cache"), "spawn");
|
||||
}
|
||||
function getCacheFile(): string {
|
||||
return join(getCacheDir(), "manifest.json");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// shared/ssh-keys.ts — SSH key discovery, selection, and generation
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { logInfo, logStep } from "./ui";
|
||||
import { getUserHome, logInfo, logStep } from "./ui";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -29,7 +28,7 @@ export function _resetCache(): void {
|
|||
|
||||
/** Scan ~/.ssh/ for valid key pairs and extract key types. */
|
||||
export function discoverSshKeys(): SshKeyPair[] {
|
||||
const sshDir = join(process.env.HOME || homedir(), ".ssh");
|
||||
const sshDir = join(getUserHome(), ".ssh");
|
||||
if (!existsSync(sshDir)) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -115,7 +114,7 @@ function getKeyType(pubPath: string): string {
|
|||
|
||||
/** Generate a new ed25519 key at ~/.ssh/id_ed25519. Returns the pair. */
|
||||
export function generateSshKey(): SshKeyPair {
|
||||
const sshDir = join(process.env.HOME || homedir(), ".ssh");
|
||||
const sshDir = join(getUserHome(), ".ssh");
|
||||
const privPath = `${sshDir}/id_ed25519`;
|
||||
const pubPath = `${privPath}.pub`;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,18 @@ import { join } from "node:path";
|
|||
import * as p from "@clack/prompts";
|
||||
import { isString } from "./type-guards";
|
||||
|
||||
/**
|
||||
* Return the user's home directory, preferring process.env.HOME.
|
||||
*
|
||||
* Bun's os.homedir() reads from getpwuid() and ignores runtime changes to
|
||||
* process.env.HOME. Named imports (`import { homedir } from "node:os"`)
|
||||
* capture a binding to the native function that cannot be patched by test
|
||||
* preloads. Using process.env.HOME first ensures the test sandbox is respected.
|
||||
*/
|
||||
export function getUserHome(): string {
|
||||
return process.env.HOME || homedir();
|
||||
}
|
||||
|
||||
const RED = "\x1b[0;31m";
|
||||
const GREEN = "\x1b[0;32m";
|
||||
const YELLOW = "\x1b[1;33m";
|
||||
|
|
@ -232,7 +244,7 @@ export async function withRetry<T>(
|
|||
* Shared by all cloud modules to avoid repeating the same path construction.
|
||||
*/
|
||||
export function getSpawnCloudConfigPath(cloud: string): string {
|
||||
return join(process.env.HOME || homedir(), ".config", "spawn", `${cloud}.json`);
|
||||
return join(getUserHome(), ".config", "spawn", `${cloud}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
import type { VMConnection } from "../history.js";
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { killWithTimeout, sleep, spawnInteractive } from "../shared/ssh";
|
||||
import { getErrorMessage } from "../shared/type-guards";
|
||||
import {
|
||||
getServerNameFromEnv,
|
||||
getUserHome,
|
||||
logError,
|
||||
logInfo,
|
||||
logStep,
|
||||
|
|
@ -112,7 +112,7 @@ function getSpriteCmd(): string | null {
|
|||
return "sprite";
|
||||
}
|
||||
const commonPaths = [
|
||||
join(process.env.HOME || homedir(), ".local/bin/sprite"),
|
||||
join(getUserHome(), ".local/bin/sprite"),
|
||||
"/data/data/com.termux/files/usr/bin/sprite",
|
||||
"/usr/local/bin/sprite",
|
||||
"/usr/bin/sprite",
|
||||
|
|
@ -168,7 +168,7 @@ export async function ensureSpriteCli(): Promise<void> {
|
|||
}
|
||||
|
||||
// Add to PATH
|
||||
const localBin = join(process.env.HOME || homedir(), ".local/bin");
|
||||
const localBin = join(getUserHome(), ".local/bin");
|
||||
if (!process.env.PATH?.includes(localBin)) {
|
||||
process.env.PATH = `${localBin}:${process.env.PATH}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import type { ExecFileSyncOptions } from "node:child_process";
|
|||
|
||||
import { execFileSync as nodeExecFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { RAW_BASE, SPAWN_CDN, VERSION_URL } from "./manifest.js";
|
||||
import { PkgVersionSchema, parseJsonWith } from "./shared/parse";
|
||||
import { getErrorMessage, hasStatus } from "./shared/type-guards";
|
||||
import { logDebug, logWarn } from "./shared/ui";
|
||||
import { getUserHome, logDebug, logWarn } from "./shared/ui";
|
||||
|
||||
const VERSION = pkg.version;
|
||||
|
||||
|
|
@ -84,7 +83,7 @@ function compareVersions(current: string, latest: string): boolean {
|
|||
// ── Failure Backoff ──────────────────────────────────────────────────────────
|
||||
|
||||
function getUpdateFailedPath(): string {
|
||||
return path.join(process.env.HOME || homedir(), ".config", "spawn", ".update-failed");
|
||||
return path.join(getUserHome(), ".config", "spawn", ".update-failed");
|
||||
}
|
||||
|
||||
function isUpdateBackedOff(): boolean {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue