mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
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:
parent
769aa69b31
commit
de76599b39
22 changed files with 229 additions and 99 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.15.34",
|
||||
"version": "0.15.35",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
117
packages/cli/src/__tests__/paths.test.ts
Normal file
117
packages/cli/src/__tests__/paths.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
79
packages/cli/src/shared/paths.ts
Normal file
79
packages/cli/src/shared/paths.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue