From 63130d39440166e1b67ac50620aef529f23158de Mon Sep 17 00:00:00 2001 From: Paulius Krutkis Date: Thu, 18 Jun 2026 09:04:18 +0300 Subject: [PATCH 1/2] Use ~/.config/decodo on all platforms and migrate macOS legacy config. macOS previously stored config under ~/Library/Preferences via env-paths, which diverged from docs and left tokens orphaned after upgrades. Co-authored-by: Cursor --- README.md | 2 +- package.json | 5 +-- pnpm-lock.yaml | 9 ---- src/auth/services/config.ts | 36 ++++++++++++++-- src/platform/services/paths.ts | 13 +++++- tests/auth/services/config.test.ts | 55 ++++++++++++++++++++++++ tests/platform/services/paths.test.ts | 61 +++++++++++++++++++++++++++ 7 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 tests/platform/services/paths.test.ts diff --git a/README.md b/README.md index 87aa1b6..6cd47bb 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ decodo scrape https://example.com --full --format ndjson | Variable | Description | | --- | --- | | `DECODO_AUTH_TOKEN` | Basic auth token (overrides saved config, below `--token`) | -| `DECODO_CONFIG_HOME` | Override config directory (default: OS-specific `env-paths` location) | +| `DECODO_CONFIG_HOME` | Override config directory (default: `~/.config/decodo`) | ## Exit codes diff --git a/package.json b/package.json index 7d0f188..fe61e48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@decodo/cli", - "version": "0.1.7", + "version": "0.1.8", "description": "Official CLI for the Decodo APIs", "license": "MIT", "type": "module", @@ -38,8 +38,7 @@ "packageManager": "pnpm@10.33.3", "dependencies": { "@decodo/sdk-ts": "^2.1.2", - "commander": "^14.0.0", - "env-paths": "^3.0.0" + "commander": "^14.0.0" }, "devDependencies": { "@biomejs/biome": "2.4.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c43f13..8e0cb2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: commander: specifier: ^14.0.0 version: 14.0.3 - env-paths: - specifier: ^3.0.0 - version: 3.0.0 devDependencies: '@biomejs/biome': specifier: 2.4.15 @@ -626,10 +623,6 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1279,8 +1272,6 @@ snapshots: deepmerge@4.3.1: {} - env-paths@3.0.0: {} - es-module-lexer@1.7.0: {} esbuild@0.27.7: diff --git a/src/auth/services/config.ts b/src/auth/services/config.ts index 5d8cfc8..568114c 100644 --- a/src/auth/services/config.ts +++ b/src/auth/services/config.ts @@ -1,6 +1,9 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { getConfigDir } from "../../platform/services/paths.js"; +import { + getConfigDir, + getLegacyConfigDir, +} from "../../platform/services/paths.js"; import { ConfigParseError } from "../errors/config-parse-error.js"; import type { DecodoConfig } from "../types/config.js"; @@ -31,9 +34,9 @@ function parseConfig( return; } -export async function readConfig(): Promise { - const configPath = getConfigPath(); - +async function readConfigAt( + configPath: string +): Promise { try { const raw = await readFile(configPath, "utf8"); @@ -47,6 +50,31 @@ export async function readConfig(): Promise { } } +export async function readConfig(): Promise { + const configPath = getConfigPath(); + const config = await readConfigAt(configPath); + + if (config) { + return config; + } + + const legacyDir = getLegacyConfigDir(); + + if (!legacyDir) { + return; + } + + const legacyConfig = await readConfigAt(join(legacyDir, CONFIG_FILE)); + + if (!legacyConfig) { + return; + } + + await writeConfig(legacyConfig); + + return legacyConfig; +} + export async function writeConfig(config: DecodoConfig): Promise { const configPath = getConfigPath(); await mkdir(dirname(configPath), { recursive: true }); diff --git a/src/platform/services/paths.ts b/src/platform/services/paths.ts index fea5df6..9afc660 100644 --- a/src/platform/services/paths.ts +++ b/src/platform/services/paths.ts @@ -1,4 +1,5 @@ -import envPaths from "env-paths"; +import { homedir } from "node:os"; +import { join } from "node:path"; export function getConfigDir(): string { const override = process.env.DECODO_CONFIG_HOME; @@ -7,5 +8,13 @@ export function getConfigDir(): string { return override; } - return envPaths("decodo", { suffix: "" }).config; + return join(homedir(), ".config", "decodo"); +} + +export function getLegacyConfigDir(): string | undefined { + if (process.platform !== "darwin") { + return; + } + + return join(homedir(), "Library", "Preferences", "decodo"); } diff --git a/tests/auth/services/config.test.ts b/tests/auth/services/config.test.ts index f4dba89..6bc6fa5 100644 --- a/tests/auth/services/config.test.ts +++ b/tests/auth/services/config.test.ts @@ -62,4 +62,59 @@ describe("auth config", () => { await clearConfig(); expect(await readConfig()).toBeUndefined(); }); + + it("migrates config from legacy macOS path when new path is missing", async () => { + const { mkdir, mkdtemp, readFile, rm, writeFile } = await import( + "node:fs/promises" + ); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const originalPlatform = process.platform; + const originalHome = process.env.HOME; + const originalConfigHome = process.env.DECODO_CONFIG_HOME; + + const fakeHome = await mkdtemp(join(tmpdir(), "decodo-home-")); + process.env.HOME = fakeHome; + delete process.env.DECODO_CONFIG_HOME; + Object.defineProperty(process, "platform", { value: "darwin" }); + vi.resetModules(); + + const configDir = join(fakeHome, ".config", "decodo"); + const legacyDir = join(fakeHome, "Library", "Preferences", "decodo"); + const legacyPath = join(legacyDir, "config.json"); + + await mkdir(legacyDir, { recursive: true }); + await writeFile( + legacyPath, + `${JSON.stringify({ authToken: "legacy-token" }, null, 2)}\n`, + "utf8" + ); + + try { + const { readConfig } = await import( + "../../../src/auth/services/config.js" + ); + + expect(await readConfig()).toEqual({ + authToken: "legacy-token", + }); + expect(await readFile(join(configDir, "config.json"), "utf8")).toContain( + "legacy-token" + ); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalConfigHome === undefined) { + delete process.env.DECODO_CONFIG_HOME; + } else { + process.env.DECODO_CONFIG_HOME = originalConfigHome; + } + await rm(fakeHome, { recursive: true, force: true }); + vi.resetModules(); + } + }); }); diff --git a/tests/platform/services/paths.test.ts b/tests/platform/services/paths.test.ts new file mode 100644 index 0000000..143379d --- /dev/null +++ b/tests/platform/services/paths.test.ts @@ -0,0 +1,61 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("getConfigDir", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("returns ~/.config/decodo when no override is set", async () => { + vi.stubEnv("DECODO_CONFIG_HOME", undefined); + + const { getConfigDir } = await import( + "../../../src/platform/services/paths.js" + ); + + expect(getConfigDir()).toBe(join(homedir(), ".config", "decodo")); + }); + + it("returns DECODO_CONFIG_HOME when set", async () => { + vi.stubEnv("DECODO_CONFIG_HOME", "/custom/config"); + + const { getConfigDir } = await import( + "../../../src/platform/services/paths.js" + ); + + expect(getConfigDir()).toBe("/custom/config"); + }); +}); + +describe("getLegacyConfigDir", () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + vi.resetModules(); + }); + + it("returns macOS Library/Preferences path on darwin", async () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + + const { getLegacyConfigDir } = await import( + "../../../src/platform/services/paths.js" + ); + + expect(getLegacyConfigDir()).toBe( + join(homedir(), "Library", "Preferences", "decodo") + ); + }); + + it("returns undefined on non-darwin platforms", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + + const { getLegacyConfigDir } = await import( + "../../../src/platform/services/paths.js" + ); + + expect(getLegacyConfigDir()).toBeUndefined(); + }); +}); From 02200ad632748081be805cf0bd3bb94c65b70169 Mon Sep 17 00:00:00 2001 From: Paulius Krutkis Date: Thu, 18 Jun 2026 09:36:18 +0300 Subject: [PATCH 2/2] Simplify config dir fix: drop pre-release legacy migration, honor XDG_CONFIG_HOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI is not released, so migrating tokens from the brief env-paths (~/Library/Preferences) window isn't worth the code — internal testers can re-auth. Remove getLegacyConfigDir and the readConfig migration path. getConfigDir now resolves DECODO_CONFIG_HOME > XDG_CONFIG_HOME > ~/.config/decodo. --- README.md | 2 +- src/auth/services/config.ts | 36 ++---------------- src/platform/services/paths.ts | 10 ++--- tests/auth/services/config.test.ts | 55 --------------------------- tests/platform/services/paths.test.ts | 38 +++++------------- 5 files changed, 19 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 6cd47bb..a3418dd 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ decodo scrape https://example.com --full --format ndjson | Variable | Description | | --- | --- | | `DECODO_AUTH_TOKEN` | Basic auth token (overrides saved config, below `--token`) | -| `DECODO_CONFIG_HOME` | Override config directory (default: `~/.config/decodo`) | +| `DECODO_CONFIG_HOME` | Override config directory (default: `$XDG_CONFIG_HOME/decodo`, else `~/.config/decodo`) | ## Exit codes diff --git a/src/auth/services/config.ts b/src/auth/services/config.ts index 568114c..5d8cfc8 100644 --- a/src/auth/services/config.ts +++ b/src/auth/services/config.ts @@ -1,9 +1,6 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { - getConfigDir, - getLegacyConfigDir, -} from "../../platform/services/paths.js"; +import { getConfigDir } from "../../platform/services/paths.js"; import { ConfigParseError } from "../errors/config-parse-error.js"; import type { DecodoConfig } from "../types/config.js"; @@ -34,9 +31,9 @@ function parseConfig( return; } -async function readConfigAt( - configPath: string -): Promise { +export async function readConfig(): Promise { + const configPath = getConfigPath(); + try { const raw = await readFile(configPath, "utf8"); @@ -50,31 +47,6 @@ async function readConfigAt( } } -export async function readConfig(): Promise { - const configPath = getConfigPath(); - const config = await readConfigAt(configPath); - - if (config) { - return config; - } - - const legacyDir = getLegacyConfigDir(); - - if (!legacyDir) { - return; - } - - const legacyConfig = await readConfigAt(join(legacyDir, CONFIG_FILE)); - - if (!legacyConfig) { - return; - } - - await writeConfig(legacyConfig); - - return legacyConfig; -} - export async function writeConfig(config: DecodoConfig): Promise { const configPath = getConfigPath(); await mkdir(dirname(configPath), { recursive: true }); diff --git a/src/platform/services/paths.ts b/src/platform/services/paths.ts index 9afc660..e99f99c 100644 --- a/src/platform/services/paths.ts +++ b/src/platform/services/paths.ts @@ -8,13 +8,11 @@ export function getConfigDir(): string { return override; } - return join(homedir(), ".config", "decodo"); -} + const xdgConfigHome = process.env.XDG_CONFIG_HOME; -export function getLegacyConfigDir(): string | undefined { - if (process.platform !== "darwin") { - return; + if (xdgConfigHome) { + return join(xdgConfigHome, "decodo"); } - return join(homedir(), "Library", "Preferences", "decodo"); + return join(homedir(), ".config", "decodo"); } diff --git a/tests/auth/services/config.test.ts b/tests/auth/services/config.test.ts index 6bc6fa5..f4dba89 100644 --- a/tests/auth/services/config.test.ts +++ b/tests/auth/services/config.test.ts @@ -62,59 +62,4 @@ describe("auth config", () => { await clearConfig(); expect(await readConfig()).toBeUndefined(); }); - - it("migrates config from legacy macOS path when new path is missing", async () => { - const { mkdir, mkdtemp, readFile, rm, writeFile } = await import( - "node:fs/promises" - ); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - const originalPlatform = process.platform; - const originalHome = process.env.HOME; - const originalConfigHome = process.env.DECODO_CONFIG_HOME; - - const fakeHome = await mkdtemp(join(tmpdir(), "decodo-home-")); - process.env.HOME = fakeHome; - delete process.env.DECODO_CONFIG_HOME; - Object.defineProperty(process, "platform", { value: "darwin" }); - vi.resetModules(); - - const configDir = join(fakeHome, ".config", "decodo"); - const legacyDir = join(fakeHome, "Library", "Preferences", "decodo"); - const legacyPath = join(legacyDir, "config.json"); - - await mkdir(legacyDir, { recursive: true }); - await writeFile( - legacyPath, - `${JSON.stringify({ authToken: "legacy-token" }, null, 2)}\n`, - "utf8" - ); - - try { - const { readConfig } = await import( - "../../../src/auth/services/config.js" - ); - - expect(await readConfig()).toEqual({ - authToken: "legacy-token", - }); - expect(await readFile(join(configDir, "config.json"), "utf8")).toContain( - "legacy-token" - ); - } finally { - Object.defineProperty(process, "platform", { value: originalPlatform }); - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } - if (originalConfigHome === undefined) { - delete process.env.DECODO_CONFIG_HOME; - } else { - process.env.DECODO_CONFIG_HOME = originalConfigHome; - } - await rm(fakeHome, { recursive: true, force: true }); - vi.resetModules(); - } - }); }); diff --git a/tests/platform/services/paths.test.ts b/tests/platform/services/paths.test.ts index 143379d..78acdcb 100644 --- a/tests/platform/services/paths.test.ts +++ b/tests/platform/services/paths.test.ts @@ -10,6 +10,7 @@ describe("getConfigDir", () => { it("returns ~/.config/decodo when no override is set", async () => { vi.stubEnv("DECODO_CONFIG_HOME", undefined); + vi.stubEnv("XDG_CONFIG_HOME", undefined); const { getConfigDir } = await import( "../../../src/platform/services/paths.js" @@ -18,44 +19,25 @@ describe("getConfigDir", () => { expect(getConfigDir()).toBe(join(homedir(), ".config", "decodo")); }); - it("returns DECODO_CONFIG_HOME when set", async () => { - vi.stubEnv("DECODO_CONFIG_HOME", "/custom/config"); + it("honors XDG_CONFIG_HOME when set and no override", async () => { + vi.stubEnv("DECODO_CONFIG_HOME", undefined); + vi.stubEnv("XDG_CONFIG_HOME", "/xdg-config"); const { getConfigDir } = await import( "../../../src/platform/services/paths.js" ); - expect(getConfigDir()).toBe("/custom/config"); - }); -}); - -describe("getLegacyConfigDir", () => { - const originalPlatform = process.platform; - - afterEach(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - vi.resetModules(); + expect(getConfigDir()).toBe(join("/xdg-config", "decodo")); }); - it("returns macOS Library/Preferences path on darwin", async () => { - Object.defineProperty(process, "platform", { value: "darwin" }); - - const { getLegacyConfigDir } = await import( - "../../../src/platform/services/paths.js" - ); - - expect(getLegacyConfigDir()).toBe( - join(homedir(), "Library", "Preferences", "decodo") - ); - }); - - it("returns undefined on non-darwin platforms", async () => { - Object.defineProperty(process, "platform", { value: "linux" }); + it("returns DECODO_CONFIG_HOME when set (precedence over XDG)", async () => { + vi.stubEnv("DECODO_CONFIG_HOME", "/custom/config"); + vi.stubEnv("XDG_CONFIG_HOME", "/xdg-config"); - const { getLegacyConfigDir } = await import( + const { getConfigDir } = await import( "../../../src/platform/services/paths.js" ); - expect(getLegacyConfigDir()).toBeUndefined(); + expect(getConfigDir()).toBe("/custom/config"); }); });