refactor: centralize path resolution into shared/paths.ts (#2422)

Move all filesystem path helpers (getUserHome, getSpawnDir, getHistoryPath,
getSpawnCloudConfigPath, getCacheDir, getCacheFile, getUpdateFailedPath,
getSshDir, getTmpDir) into a single shared/paths.ts module. This eliminates
scattered homedir()/process.env.HOME patterns across 8+ files and provides
a single import source for all path resolution.

- Create packages/cli/src/shared/paths.ts with 9 exported functions
- Update 17 source files to import from paths.ts
- Add re-exports in ui.ts and history.ts for backward compatibility
- Remove direct homedir() imports from gcp, sprite, local, ssh-keys, etc.
- Add comprehensive unit tests in paths.test.ts
- Bump CLI version to 0.15.34

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:
A 2026-03-10 00:48:03 -07:00 committed by GitHub
parent 769aa69b31
commit de76599b39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 229 additions and 99 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.15.34",
"version": "0.15.35",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -3,7 +3,8 @@ import type { SpawnRecord } from "../history.js";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { clearHistory, filterHistory, getHistoryPath, loadHistory, saveSpawnRecord } from "../history.js";
import { clearHistory, filterHistory, loadHistory, saveSpawnRecord } from "../history.js";
import { getHistoryPath } from "../shared/paths.js";
import { mockClackPrompts } from "./test-helpers";
/**

View file

@ -16,13 +16,13 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
generateSpawnId,
getHistoryPath,
loadHistory,
markRecordDeleted,
removeRecord,
saveLaunchCmd,
saveSpawnRecord,
} from "../history.js";
import { getHistoryPath } from "../shared/paths.js";
describe("history spawn IDs", () => {
let testDir: string;

View file

@ -3,14 +3,8 @@ 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 { join } from "node:path";
import {
filterHistory,
getHistoryPath,
getSpawnDir,
HISTORY_SCHEMA_VERSION,
loadHistory,
saveSpawnRecord,
} from "../history.js";
import { filterHistory, HISTORY_SCHEMA_VERSION, loadHistory, saveSpawnRecord } from "../history.js";
import { getHistoryPath, getSpawnDir, getUserHome } from "../shared/paths.js";
describe("history", () => {
let testDir: string;
@ -49,7 +43,7 @@ describe("history", () => {
it("falls back to ~/.spawn when SPAWN_HOME is not set", () => {
delete process.env.SPAWN_HOME;
expect(getSpawnDir()).toBe(join(process.env.HOME ?? "", ".spawn"));
expect(getSpawnDir()).toBe(join(getUserHome(), ".spawn"));
});
it("throws for relative SPAWN_HOME path", () => {

View file

@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import {
getCacheDir,
getCacheFile,
getHistoryPath,
getSpawnCloudConfigPath,
getSpawnDir,
getSshDir,
getTmpDir,
getUpdateFailedPath,
getUserHome,
} from "../shared/paths";
describe("paths", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = {
...process.env,
};
});
afterEach(() => {
process.env = originalEnv;
});
describe("getUserHome", () => {
it("returns HOME env var when set", () => {
process.env.HOME = "/custom/home";
expect(getUserHome()).toBe("/custom/home");
});
it("falls back to os.homedir() when HOME is unset", () => {
delete process.env.HOME;
expect(getUserHome()).toBe(homedir());
});
});
describe("getSpawnDir", () => {
it("returns ~/.spawn by default", () => {
delete process.env.SPAWN_HOME;
expect(getSpawnDir()).toBe(join(getUserHome(), ".spawn"));
});
it("uses SPAWN_HOME when set to valid absolute path", () => {
const testPath = join(getUserHome(), ".custom-spawn");
process.env.SPAWN_HOME = testPath;
expect(getSpawnDir()).toBe(testPath);
});
it("rejects relative SPAWN_HOME", () => {
process.env.SPAWN_HOME = "relative/path";
expect(() => getSpawnDir()).toThrow("must be an absolute path");
});
it("rejects path traversal outside home directory", () => {
process.env.SPAWN_HOME = "/tmp/../../root/.spawn";
expect(() => getSpawnDir()).toThrow("must be within your home directory");
});
});
describe("getHistoryPath", () => {
it("returns history.json inside spawn dir", () => {
delete process.env.SPAWN_HOME;
expect(getHistoryPath()).toBe(join(getUserHome(), ".spawn", "history.json"));
});
});
describe("getSpawnCloudConfigPath", () => {
it("returns ~/.config/spawn/{cloud}.json", () => {
expect(getSpawnCloudConfigPath("aws")).toBe(join(getUserHome(), ".config", "spawn", "aws.json"));
});
it("works for different cloud names", () => {
expect(getSpawnCloudConfigPath("hetzner")).toBe(join(getUserHome(), ".config", "spawn", "hetzner.json"));
});
});
describe("getCacheDir", () => {
it("returns XDG_CACHE_HOME/spawn when XDG_CACHE_HOME is set", () => {
process.env.XDG_CACHE_HOME = "/custom/cache";
expect(getCacheDir()).toBe("/custom/cache/spawn");
});
it("falls back to ~/.cache/spawn", () => {
delete process.env.XDG_CACHE_HOME;
expect(getCacheDir()).toBe(join(getUserHome(), ".cache", "spawn"));
});
});
describe("getCacheFile", () => {
it("returns manifest.json inside cache dir", () => {
delete process.env.XDG_CACHE_HOME;
expect(getCacheFile()).toBe(join(getUserHome(), ".cache", "spawn", "manifest.json"));
});
});
describe("getUpdateFailedPath", () => {
it("returns ~/.config/spawn/.update-failed", () => {
expect(getUpdateFailedPath()).toBe(join(getUserHome(), ".config", "spawn", ".update-failed"));
});
});
describe("getSshDir", () => {
it("returns ~/.ssh", () => {
expect(getSshDir()).toBe(join(getUserHome(), ".ssh"));
});
});
describe("getTmpDir", () => {
it("returns os.tmpdir()", () => {
expect(getTmpDir()).toBe(tmpdir());
});
});
});

View file

@ -9,6 +9,7 @@ import * as v from "valibot";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
import { parseJsonWith } from "../shared/parse";
import { getSpawnCloudConfigPath } from "../shared/paths";
import {
killWithTimeout,
SSH_BASE_OPTS,
@ -21,7 +22,6 @@ import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys";
import { getErrorMessage } from "../shared/type-guards";
import {
getServerNameFromEnv,
getSpawnCloudConfigPath,
jsonEscape,
logError,
logInfo,

View file

@ -3,8 +3,8 @@ import type { Manifest } from "../manifest.js";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { getHistoryPath } from "../history.js";
import { validateConnectionIP, validateLaunchCmd, validateServerIdentifier, validateUsername } from "../security.js";
import { getHistoryPath } from "../shared/paths.js";
import { SSH_INTERACTIVE_OPTS, spawnInteractive } from "../shared/ssh.js";
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys.js";
import { getErrorMessage } from "./shared.js";

View file

@ -12,9 +12,10 @@ import {
resolveProject as gcpResolveProject,
} from "../gcp/gcp.js";
import { ensureHcloudToken, destroyServer as hetznerDestroyServer } from "../hetzner/hetzner.js";
import { getActiveServers, getHistoryPath, markRecordDeleted } from "../history.js";
import { getActiveServers, markRecordDeleted } from "../history.js";
import { loadManifest } from "../manifest.js";
import { validateMetadataValue, validateServerIdentifier } from "../security.js";
import { getHistoryPath } from "../shared/paths.js";
import { ensureSpriteAuthenticated, ensureSpriteCli, destroyServer as spriteDestroyServer } from "../sprite/sprite.js";
import { activeServerPicker, resolveListFilters } from "./list.js";
import { getErrorMessage, isInteractiveTTY } from "./shared.js";

View file

@ -8,8 +8,8 @@ import pkg from "../../package.json" with { type: "json" };
import { agentKeys, cloudKeys, isStaleCache, loadManifest, matrixStatus } from "../manifest.js";
import { validateIdentifier, validatePrompt } from "../security.js";
import { PkgVersionSchema } from "../shared/parse.js";
import { getSpawnCloudConfigPath } from "../shared/paths.js";
import { getErrorMessage, isString } from "../shared/type-guards.js";
import { getSpawnCloudConfigPath } from "../shared/ui.js";
// ── Constants ────────────────────────────────────────────────────────────────

View file

@ -8,6 +8,7 @@ import { handleBillingError, isBillingError, showNonBillingError } from "../shar
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
import { OAUTH_CSS } from "../shared/oauth";
import { parseJsonObj } from "../shared/parse";
import { getSpawnCloudConfigPath } from "../shared/paths";
import {
killWithTimeout,
SSH_BASE_OPTS,
@ -21,7 +22,6 @@ import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "..
import {
defaultSpawnName,
getServerNameFromEnv,
getSpawnCloudConfigPath,
loadApiToken,
logError,
logInfo,

View file

@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
import { getUserHome } from "../shared/paths";
import {
killWithTimeout,
SSH_BASE_OPTS,
@ -18,7 +19,6 @@ import {
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys";
import {
getServerNameFromEnv,
getUserHome,
logError,
logInfo,
logStep,

View file

@ -7,6 +7,7 @@ import { mkdirSync, readFileSync } from "node:fs";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
import { parseJsonObj } from "../shared/parse";
import { getSpawnCloudConfigPath } from "../shared/paths";
import {
killWithTimeout,
SSH_BASE_OPTS,
@ -19,7 +20,6 @@ import { ensureSshKeys, getSshFingerprint, getSshKeyOpts } from "../shared/ssh-k
import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "../shared/type-guards";
import {
getServerNameFromEnv,
getSpawnCloudConfigPath,
jsonEscape,
loadApiToken,
logError,

View file

@ -9,11 +9,12 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { join } from "node:path";
import * as v from "valibot";
import { getHistoryPath, getSpawnDir } from "./shared/paths.js";
import { tryCatch } from "./shared/result.js";
import { getErrorMessage } from "./shared/type-guards.js";
import { getUserHome, logDebug, logWarn } from "./shared/ui.js";
import { logDebug, logWarn } from "./shared/ui.js";
export interface VMConnection {
ip: string;
@ -80,39 +81,6 @@ export function generateSpawnId(): string {
return randomUUID();
}
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) {
return join(getUserHome(), ".spawn");
}
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` + "Example: export SPAWN_HOME=/home/user/.spawn",
);
}
// Resolve to canonical form (collapses .. segments)
const resolved = resolve(spawnHome);
// SECURITY: Prevent path traversal to system directories
// 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 = getUserHome();
if (!resolved.startsWith(userHome + "/") && resolved !== userHome) {
throw new Error("SPAWN_HOME must be within your home directory.\n" + `Got: ${resolved}\n` + `Home: ${userHome}`);
}
return resolved;
}
export function getHistoryPath(): string {
return join(getSpawnDir(), "history.json");
}
/** Atomically write a JSON file: write to .tmp, then rename into place. */
function atomicWriteJson(filePath: string, data: unknown): void {
const tmpPath = filePath + ".tmp";

View file

@ -2,8 +2,8 @@
import { copyFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { getUserHome } from "../shared/paths";
import { spawnInteractive } from "../shared/ssh";
import { getUserHome } from "../shared/ui";
// ─── Execution ───────────────────────────────────────────────────────────────

View file

@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCacheDir, getCacheFile } from "./shared/paths.js";
import { getErrorMessage } from "./shared/type-guards.js";
import { getUserHome } from "./shared/ui.js";
// ── Types ──────────────────────────────────────────────────────────────────────
@ -72,13 +72,6 @@ const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main` as const;
const SPAWN_CDN = "https://openrouter.ai/labs/spawn" as const;
/** Static URL for version checks — GitHub release artifact, never changes with repo structure */
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(getUserHome(), ".cache"), "spawn");
}
function getCacheFile(): string {
return join(getCacheDir(), "manifest.json");
}
const CACHE_TTL = 3600; // 1 hour in seconds
const FETCH_TIMEOUT = 10_000; // 10 seconds

View file

@ -5,8 +5,8 @@ import type { AgentConfig } from "./agents";
import type { Result } from "./ui";
import { unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getTmpDir } from "./paths";
import { getErrorMessage } from "./type-guards";
import { Err, jsonEscape, logError, logInfo, logStep, logWarn, Ok, withRetry } from "./ui";
@ -61,7 +61,7 @@ async function installAgent(
* Upload a config file to the remote machine via a temp file and mv.
*/
async function uploadConfigFile(runner: CloudRunner, content: string, remotePath: string): Promise<void> {
const tmpFile = join(tmpdir(), `spawn_config_${Date.now()}_${Math.random().toString(36).slice(2)}`);
const tmpFile = join(getTmpDir(), `spawn_config_${Date.now()}_${Math.random().toString(36).slice(2)}`);
writeFileSync(tmpFile, content, {
mode: 0o600,
});
@ -243,7 +243,7 @@ export async function offerGithubAuth(runner: CloudRunner): Promise<void> {
let localTmpFile = "";
if (githubToken) {
const escaped = githubToken.replace(/'/g, "'\\''");
localTmpFile = join(tmpdir(), `gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`);
localTmpFile = join(getTmpDir(), `gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`);
writeFileSync(localTmpFile, `export GITHUB_TOKEN='${escaped}'`, {
mode: 0o600,
});

View file

@ -5,8 +5,9 @@ import { dirname } from "node:path";
import * as v from "valibot";
import { OAUTH_CODE_REGEX } from "./oauth-constants";
import { parseJsonWith } from "./parse";
import { getSpawnCloudConfigPath } from "./paths";
import { getErrorMessage, isString } from "./type-guards";
import { getSpawnCloudConfigPath, logDebug, logError, logInfo, logStep, logWarn, openBrowser, prompt } from "./ui";
import { logDebug, logError, logInfo, logStep, logWarn, openBrowser, prompt } from "./ui";
// ─── Schemas ─────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,79 @@
// shared/paths.ts — Centralized filesystem path resolution
//
// All path helpers live here. Production code imports from this module;
// no other module should call homedir() or construct spawn-specific paths directly.
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
/** Return the user's home directory, preferring $HOME over os.homedir(). */
export function getUserHome(): string {
return process.env.HOME || homedir();
}
/** Returns the directory for spawn data, respecting SPAWN_HOME env var.
* SPAWN_HOME must be an absolute path if set; relative paths are rejected
* to prevent unintended file writes. */
export function getSpawnDir(): string {
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) {
return join(getUserHome(), ".spawn");
}
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
throw new Error(
`SPAWN_HOME must be an absolute path (got "${spawnHome}").\n` + "Example: export SPAWN_HOME=/home/user/.spawn",
);
}
// Resolve to canonical form (collapses .. segments)
const resolved = resolve(spawnHome);
// SECURITY: Prevent path traversal to system directories
// 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 = getUserHome();
if (!resolved.startsWith(userHome + "/") && resolved !== userHome) {
throw new Error("SPAWN_HOME must be within your home directory.\n" + `Got: ${resolved}\n` + `Home: ${userHome}`);
}
return resolved;
}
/** Path to the spawn history file. */
export function getHistoryPath(): string {
return join(getSpawnDir(), "history.json");
}
/**
* Return the path to the per-cloud config file: ~/.config/spawn/{cloud}.json
* Shared by all cloud modules to avoid repeating the same path construction.
*/
export function getSpawnCloudConfigPath(cloud: string): string {
return join(getUserHome(), ".config", "spawn", `${cloud}.json`);
}
/** Return the cache directory for spawn, respecting XDG_CACHE_HOME. */
export function getCacheDir(): string {
return join(process.env.XDG_CACHE_HOME || join(getUserHome(), ".cache"), "spawn");
}
/** Return the path to the cached manifest file. */
export function getCacheFile(): string {
return join(getCacheDir(), "manifest.json");
}
/** Return the path to the update-failed sentinel file. */
export function getUpdateFailedPath(): string {
return join(getUserHome(), ".config", "spawn", ".update-failed");
}
/** Return the path to the user's ~/.ssh directory. */
export function getSshDir(): string {
return join(getUserHome(), ".ssh");
}
/** Return the system temp directory (wraps os.tmpdir()). */
export function getTmpDir(): string {
return tmpdir();
}

View file

@ -1,8 +1,8 @@
// shared/ssh-keys.ts — SSH key discovery, selection, and generation
import { existsSync, mkdirSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { getUserHome, logInfo, logStep } from "./ui";
import { getSshDir } from "./paths";
import { logInfo, logStep } from "./ui";
// ─── Types ──────────────────────────────────────────────────────────────────
@ -28,7 +28,7 @@ export function _resetCache(): void {
/** Scan ~/.ssh/ for valid key pairs and extract key types. */
export function discoverSshKeys(): SshKeyPair[] {
const sshDir = join(getUserHome(), ".ssh");
const sshDir = getSshDir();
if (!existsSync(sshDir)) {
return [];
}
@ -114,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(getUserHome(), ".ssh");
const sshDir = getSshDir();
const privPath = `${sshDir}/id_ed25519`;
const pubPath = `${privPath}.pub`;

View file

@ -2,23 +2,10 @@
// @clack/prompts is bundled into cli.js at build time.
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import * as p from "@clack/prompts";
import { getSpawnCloudConfigPath } from "./paths";
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";
@ -239,14 +226,6 @@ export async function withRetry<T>(
throw new Error("unreachable");
}
/**
* Return the path to the per-cloud config file: ~/.config/spawn/{cloud}.json
* Shared by all cloud modules to avoid repeating the same path construction.
*/
export function getSpawnCloudConfigPath(cloud: string): string {
return join(getUserHome(), ".config", "spawn", `${cloud}.json`);
}
/**
* Load an API token from the per-cloud config file.
* Reads `api_key` or `token` field and validates allowed characters.

View file

@ -4,11 +4,11 @@ import type { VMConnection } from "../history.js";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { getUserHome } from "../shared/paths";
import { killWithTimeout, sleep, spawnInteractive } from "../shared/ssh";
import { getErrorMessage } from "../shared/type-guards";
import {
getServerNameFromEnv,
getUserHome,
logError,
logInfo,
logStep,

View file

@ -8,8 +8,9 @@ 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 { getUpdateFailedPath } from "./shared/paths";
import { getErrorMessage, hasStatus } from "./shared/type-guards";
import { getUserHome, logDebug, logWarn } from "./shared/ui";
import { logDebug, logWarn } from "./shared/ui";
const VERSION = pkg.version;
@ -82,10 +83,6 @@ function compareVersions(current: string, latest: string): boolean {
// ── Failure Backoff ──────────────────────────────────────────────────────────
function getUpdateFailedPath(): string {
return path.join(getUserHome(), ".config", "spawn", ".update-failed");
}
function isUpdateBackedOff(): boolean {
try {
const failedPath = getUpdateFailedPath();