From 192094d728a6bd666033ea3fb97df590495111cd Mon Sep 17 00:00:00 2001 From: Paulius Krutkis Date: Wed, 10 Jun 2026 07:52:41 +0300 Subject: [PATCH 1/3] Refactor CLI commands and enhance error handling - Updated README.md to replace 'decodo search' with 'decodo google-search' for consistency. - Removed 'dcd' alias from install scripts and README, ensuring 'decodo' is the sole command. - Introduced ConfigParseError for improved error handling when configuration files are invalid. - Enhanced prompt for hidden input in setup command to improve user experience. - Added tests for new error handling and hidden prompt functionality. --- README.md | 6 +- docs/install.ps1 | 5 +- docs/install.sh | 9 +-- src/auth/commands/setup.ts | 19 +---- src/auth/errors/config-parse-error.ts | 8 ++ src/auth/services/config.ts | 16 +++- src/index.ts | 12 +-- src/platform/services/prompt-hidden.ts | 75 +++++++++++++++++++ tests/auth/commands/setup.test.ts | 23 +++--- tests/auth/services/config.test.ts | 16 ++++ tests/platform/services/prompt-hidden.test.ts | 24 ++++++ .../scrape/services/run-target-scrape.test.ts | 3 +- 12 files changed, 166 insertions(+), 50 deletions(-) create mode 100644 src/auth/errors/config-parse-error.ts create mode 100644 src/platform/services/prompt-hidden.ts create mode 100644 tests/platform/services/prompt-hidden.test.ts diff --git a/README.md b/README.md index e58e5df..c669be7 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Once installed and authenticated, try: ```bash decodo scrape https://ip.decodo.com -decodo search "top articles hacker news" --limit 5 --parse +decodo google-search "top articles hacker news" --limit 5 --parse ``` You should see markdown or parsed JSON within seconds. If you see an auth error, double-check your @@ -207,7 +207,7 @@ decodo google-search "query" --format ndjson --full | jq -c '.results[]' ```bash # Search and extract titles -decodo search "rust web scraping" --limit 3 --parse | jq '.[].title' +decodo google-search "rust web scraping" --limit 3 --parse | jq '.[].title' # Scrape JSON API endpoint decodo scrape https://ip.decodo.com/json | jq '.ip' @@ -221,7 +221,7 @@ decodo screenshot https://example.com -o shot.png ```bash # Request from a specific country decodo scrape https://example.com --country us -decodo search "shoes" --geo de --parse +decodo search "shoes" --geo de decodo google-search "shoes" --geo de --parse ``` diff --git a/docs/install.ps1 b/docs/install.ps1 index 1c579bb..cf85a4b 100644 --- a/docs/install.ps1 +++ b/docs/install.ps1 @@ -3,7 +3,6 @@ $ErrorActionPreference = 'Stop' $PackageName = '@decodo/cli' $CommandName = 'decodo' -$CommandAlias = 'dcd' $MinNodeMajor = 18 function Write-Info([string]$Message) { @@ -57,8 +56,6 @@ npm install -g $PackageName $installedVersion = $null if (Get-Command $CommandName -ErrorAction SilentlyContinue) { $installedVersion = & $CommandName --version 2>$null -} elseif (Get-Command $CommandAlias -ErrorAction SilentlyContinue) { - $installedVersion = & $CommandAlias --version 2>$null } if ($installedVersion) { @@ -84,5 +81,5 @@ Write-Host 'Next step: configure your auth token with decodo setup' Write-Host 'Get started:' Write-Host ' decodo scrape https://ip.decodo.com' Write-Host ' decodo search "decodo scraping api"' -Write-Host ' dcd whoami # shorthand alias' +Write-Host ' decodo whoami' Write-Host '' diff --git a/docs/install.sh b/docs/install.sh index bad6755..ec30c9b 100755 --- a/docs/install.sh +++ b/docs/install.sh @@ -3,7 +3,6 @@ set -e PACKAGE_NAME="@decodo/cli" COMMAND_NAME="decodo" -COMMAND_ALIAS="dcd" MIN_NODE_MAJOR=18 if [ -t 1 ]; then @@ -66,12 +65,10 @@ main() { if command -v "$COMMAND_NAME" >/dev/null 2>&1; then installed_version=$("$COMMAND_NAME" --version 2>/dev/null || echo "unknown") printf "\n${GREEN}${BOLD}Success!${RESET} ${PACKAGE_NAME} ${installed_version} is installed.\n" - elif command -v "$COMMAND_ALIAS" >/dev/null 2>&1; then - installed_version=$("$COMMAND_ALIAS" --version 2>/dev/null || echo "unknown") - printf "\n${GREEN}${BOLD}Success!${RESET} ${PACKAGE_NAME} ${installed_version} is installed.\n" else printf "\n${GREEN}${BOLD}Installed!${RESET} You may need to restart your shell or add the npm global bin directory to your PATH.\n" - npm_bin=$(npm bin -g 2>/dev/null) || true + npm_prefix=$(npm config get prefix 2>/dev/null) || true + npm_bin="${npm_prefix:+$npm_prefix/bin}" if [ -n "$npm_bin" ] && ! echo "$PATH" | tr ':' '\n' | grep -qx "$npm_bin"; then warn "${npm_bin} is not in your PATH. Add it with:" printf " export PATH=\"%s:\$PATH\"\n\n" "$npm_bin" @@ -82,7 +79,7 @@ main() { printf "Get started:\n" printf " ${BOLD}decodo scrape${RESET} https://ip.decodo.com\n" printf " ${BOLD}decodo search${RESET} \"decodo scraping api\"\n" - printf " ${BOLD}dcd whoami${RESET} # shorthand alias\n\n" + printf " ${BOLD}decodo whoami${RESET}\n\n" } main diff --git a/src/auth/commands/setup.ts b/src/auth/commands/setup.ts index 4831305..213a0d2 100644 --- a/src/auth/commands/setup.ts +++ b/src/auth/commands/setup.ts @@ -1,28 +1,16 @@ -import { stdin as input, stdout as output } from "node:process"; -import { createInterface } from "node:readline/promises"; import { Command } from "commander"; import { getRootOpts } from "../../cli/services/global-opts.js"; import { CliUsageError, handleCliError, } from "../../platform/services/handle-cli-error.js"; +import { promptHidden } from "../../platform/services/prompt-hidden.js"; import { validateAuthToken } from "../../scrape/services/auth-validation.js"; import { PLAYGROUND_URL } from "../constants.js"; import { getConfigPath, writeConfig } from "../services/config.js"; const TOKEN_PROMPT = `Paste your Web Scraping API basic auth token (${PLAYGROUND_URL}): `; -async function promptForToken(): Promise { - const rl = createInterface({ input, output }); - try { - const token = await rl.question(TOKEN_PROMPT); - - return token.trim(); - } finally { - rl.close(); - } -} - export const setupCommand = new Command("setup") .description("Configure the Decodo CLI with your auth token") .option( @@ -31,10 +19,11 @@ export const setupCommand = new Command("setup") ) .action(async (options: { token?: string }, command) => { const rootOpts = getRootOpts(command); - const token = + const token = ( options.token?.trim() || rootOpts.token?.trim() || - (await promptForToken()); + (await promptHidden(TOKEN_PROMPT)) + ).trim(); if (!token) { handleCliError(new CliUsageError("auth token is required.")); diff --git a/src/auth/errors/config-parse-error.ts b/src/auth/errors/config-parse-error.ts new file mode 100644 index 0000000..ee04ba6 --- /dev/null +++ b/src/auth/errors/config-parse-error.ts @@ -0,0 +1,8 @@ +export class ConfigParseError extends Error { + constructor(configPath: string) { + super( + `Configuration file is invalid (${configPath}). Run \`decodo setup\` to reconfigure.` + ); + this.name = "ConfigParseError"; + } +} diff --git a/src/auth/services/config.ts b/src/auth/services/config.ts index 448b8fe..5d8cfc8 100644 --- a/src/auth/services/config.ts +++ b/src/auth/services/config.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { getConfigDir } from "../../platform/services/paths.js"; +import { ConfigParseError } from "../errors/config-parse-error.js"; import type { DecodoConfig } from "../types/config.js"; const CONFIG_FILE = "config.json"; @@ -9,8 +10,17 @@ export function getConfigPath(): string { return join(getConfigDir(), CONFIG_FILE); } -function parseConfig(raw: string): DecodoConfig | undefined { - const parsed = JSON.parse(raw) as Partial; +function parseConfig( + raw: string, + configPath: string +): DecodoConfig | undefined { + let parsed: Partial; + + try { + parsed = JSON.parse(raw) as Partial; + } catch { + throw new ConfigParseError(configPath); + } if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) { return { @@ -27,7 +37,7 @@ export async function readConfig(): Promise { try { const raw = await readFile(configPath, "utf8"); - return parseConfig(raw); + return parseConfig(raw, configPath); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { return; diff --git a/src/index.ts b/src/index.ts index 7aa2b60..537f2cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,11 +8,13 @@ import { configureCommanderExit } from "./cli/services/configure-commander-exit. import { handleCliError } from "./platform/services/handle-cli-error.js"; function readVersion(): string { - const __filename = fileURLToPath(import.meta.url); - const packageJsonPath = join(dirname(__filename), "..", "..", "package.json"); - const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { - version: string; - }; + const pkgPath = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "package.json" + ); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }; return pkg.version; } diff --git a/src/platform/services/prompt-hidden.ts b/src/platform/services/prompt-hidden.ts new file mode 100644 index 0000000..8297f52 --- /dev/null +++ b/src/platform/services/prompt-hidden.ts @@ -0,0 +1,75 @@ +import { stdin, stdout } from "node:process"; +import { createInterface } from "node:readline/promises"; + +interface HiddenPromptState { + cleanup: () => void; + input: string; + reject: (reason: Error) => void; + resolve: (value: string) => void; +} + +function handleHiddenPromptChar(char: string, state: HiddenPromptState): void { + if (char === "\u0003") { + state.cleanup(); + stdout.write("\n"); + state.reject(new Error("Cancelled.")); + return; + } + + if (char === "\r" || char === "\n") { + state.cleanup(); + stdout.write("\n"); + state.resolve(state.input.trim()); + return; + } + + if (char === "\u007f" || char === "\b") { + if (state.input.length > 0) { + state.input = state.input.slice(0, -1); + stdout.write("\b \b"); + } + return; + } + + state.input += char; +} + +export async function promptHidden(message: string): Promise { + if (!stdin.isTTY) { + const rl = createInterface({ input: stdin, output: stdout }); + try { + return (await rl.question(message)).trim(); + } finally { + rl.close(); + } + } + + stdout.write(message); + + return new Promise((resolve, reject) => { + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + + const state: HiddenPromptState = { + input: "", + cleanup: () => undefined, + resolve, + reject, + }; + + const onData = (chunk: string): void => { + for (const char of chunk) { + handleHiddenPromptChar(char, state); + } + }; + + state.cleanup = (): void => { + stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener("data", onData); + }; + + stdin.on("data", onData); + }); +} diff --git a/tests/auth/commands/setup.test.ts b/tests/auth/commands/setup.test.ts index 7e35dca..9333eb1 100644 --- a/tests/auth/commands/setup.test.ts +++ b/tests/auth/commands/setup.test.ts @@ -2,13 +2,10 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isolateConfigHome } from "../../platform/helpers/config-home.js"; -const mockQuestion = vi.hoisted(() => vi.fn()); +const mockPromptHidden = vi.hoisted(() => vi.fn()); -vi.mock("node:readline/promises", () => ({ - createInterface: vi.fn(() => ({ - question: mockQuestion, - close: vi.fn(), - })), +vi.mock("../../../src/platform/services/prompt-hidden.js", () => ({ + promptHidden: mockPromptHidden, })); async function runSetup( @@ -36,7 +33,7 @@ describe("setupCommand", () => { exitCode = undefined; stdout = []; stderr = []; - mockQuestion.mockReset(); + mockPromptHidden.mockReset(); vi.spyOn(process, "exit").mockImplementation((code) => { exitCode = code as number; @@ -112,7 +109,7 @@ describe("setupCommand", () => { }); it("exits with usage when interactive prompt returns empty input", async () => { - mockQuestion.mockResolvedValue(""); + mockPromptHidden.mockResolvedValue(""); await expect(runSetup([])).rejects.toThrow("process.exit:2"); @@ -121,7 +118,7 @@ describe("setupCommand", () => { }); it("exits with usage when interactive prompt returns whitespace", async () => { - mockQuestion.mockResolvedValue(" "); + mockPromptHidden.mockResolvedValue(" "); await expect(runSetup([])).rejects.toThrow("process.exit:2"); @@ -130,13 +127,13 @@ describe("setupCommand", () => { }); it("falls back to prompt when global --token is whitespace-only", async () => { - mockQuestion.mockResolvedValue(""); + mockPromptHidden.mockResolvedValue(""); await expect(runSetup([], ["--token", " "])).rejects.toThrow( "process.exit:2" ); - expect(mockQuestion).toHaveBeenCalledOnce(); + expect(mockPromptHidden).toHaveBeenCalledOnce(); expect(exitCode).toBe(2); }); @@ -161,11 +158,11 @@ describe("setupCommand", () => { }); it("prompts for token interactively when no flags are provided", async () => { - mockQuestion.mockResolvedValue("prompted-token"); + mockPromptHidden.mockResolvedValue("prompted-token"); await runSetup([]); - expect(mockQuestion).toHaveBeenCalledOnce(); + expect(mockPromptHidden).toHaveBeenCalledOnce(); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ authToken: "prompted-token", diff --git a/tests/auth/services/config.test.ts b/tests/auth/services/config.test.ts index bb9c8f0..f4dba89 100644 --- a/tests/auth/services/config.test.ts +++ b/tests/auth/services/config.test.ts @@ -37,6 +37,22 @@ describe("auth config", () => { }); }); + it("throws ConfigParseError for malformed config.json", async () => { + const { writeFile, mkdir } = await import("node:fs/promises"); + const { dirname } = await import("node:path"); + const { getConfigPath, readConfig } = await import( + "../../../src/auth/services/config.js" + ); + + const path = getConfigPath(); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, "{ not json", "utf8"); + + await expect(readConfig()).rejects.toMatchObject({ + name: "ConfigParseError", + }); + }); + it("clears config file on reset", async () => { const { writeConfig, clearConfig, readConfig } = await import( "../../../src/auth/services/config.js" diff --git a/tests/platform/services/prompt-hidden.test.ts b/tests/platform/services/prompt-hidden.test.ts new file mode 100644 index 0000000..d400f5e --- /dev/null +++ b/tests/platform/services/prompt-hidden.test.ts @@ -0,0 +1,24 @@ +import { stdin } from "node:process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promptHidden } from "../../../src/platform/services/prompt-hidden.js"; + +vi.mock("node:readline/promises", () => ({ + createInterface: vi.fn(() => ({ + question: vi.fn().mockResolvedValue(" piped-token "), + close: vi.fn(), + })), +})); + +describe("promptHidden", () => { + beforeEach(() => { + Object.defineProperty(stdin, "isTTY", { configurable: true, value: false }); + }); + + afterEach(() => { + Object.defineProperty(stdin, "isTTY", { configurable: true, value: true }); + }); + + it("falls back to readline when stdin is not a TTY", async () => { + await expect(promptHidden("Token: ")).resolves.toBe("piped-token"); + }); +}); diff --git a/tests/scrape/services/run-target-scrape.test.ts b/tests/scrape/services/run-target-scrape.test.ts index 0e64764..178a921 100644 --- a/tests/scrape/services/run-target-scrape.test.ts +++ b/tests/scrape/services/run-target-scrape.test.ts @@ -1,6 +1,7 @@ import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfigParseError } from "../../../src/auth/errors/config-parse-error.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { attachScrapeOutputOptions } from "../../../src/output/commands/attach-output-options.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; @@ -145,7 +146,7 @@ describe("createTargetAction", () => { it("maps resolveAuthToken failures through handleCliError", async () => { vi.mocked(resolveAuthToken).mockRejectedValue( - new SyntaxError("Unexpected token in config.json") + new ConfigParseError("/tmp/config.json") ); const program = new Command() From f82e3662d99d466db9aa1171cce0c83163b6dd73 Mon Sep 17 00:00:00 2001 From: Paulius Krutkis Date: Wed, 10 Jun 2026 07:58:36 +0300 Subject: [PATCH 2/3] Update version to 0.1.4 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index efa3fd4..d3c1f3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@decodo/cli", - "version": "0.1.3", + "version": "0.1.4", "description": "Official CLI for the Decodo APIs", "license": "MIT", "type": "module", From d96472c8d59479de193669196842ce06895b5ce6 Mon Sep 17 00:00:00 2001 From: paulius-krutkis-dcd Date: Thu, 11 Jun 2026 07:16:25 +0300 Subject: [PATCH 3/3] Friendlier first-run output and no-sudo install fallback (#29) * Enhance install.sh for improved package installation and error handling - Added `can_write_global` and `install_package` functions to manage global and user-level installations of the Decodo CLI. - Updated main installation logic to utilize the new functions, providing clearer feedback on installation success and path configuration. - Modified error messages to guide users on setting up their auth token more effectively. - Adjusted tests to reflect changes in error handling and onboarding steps for users. * Update auth-validation test to reflect changes in expected URL and integration header comment - Changed the expected URL in the test from "https://ip.decodo.com" to "https://does-not-exist.decodo.com". - Updated the comment for the integration header to remove the specific task reference, improving clarity. * Add no-sudo install fallback to Windows install.ps1 Mirror the install.sh behaviour: attempt a global npm install, fall back to a user-level install in %AppData%\npm-global when it fails, and show a friendly npx hint if even that fails. Suppress the global-bin PATH warning when the fallback ran and print user-prefix PATH guidance instead. * Bump vitest from 3.2.4 to 3.2.6 Folds in dependabot/npm_and_yarn/vitest-3.2.6 (lockfile-only; satisfies the existing ^3.2.0 range) so it ships with this branch. * Improve CLI error messaging for authentication issues - Consolidated error messages for AuthRequiredError to provide clearer guidance on obtaining and setting the auth token. - Removed redundant console.error calls to streamline output. --- docs/install.ps1 | 59 +++++++++++--- docs/install.sh | 62 ++++++++++++-- pnpm-lock.yaml | 80 +++++++++---------- src/auth/constants.ts | 3 +- src/platform/services/handle-cli-error.ts | 10 ++- src/scrape/services/auth-validation.ts | 2 +- .../services/handle-cli-error.test.ts | 7 +- tests/scrape/services/auth-validation.test.ts | 4 +- 8 files changed, 163 insertions(+), 64 deletions(-) diff --git a/docs/install.ps1 b/docs/install.ps1 index cf85a4b..7a99f3f 100644 --- a/docs/install.ps1 +++ b/docs/install.ps1 @@ -50,8 +50,40 @@ if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { Write-Err 'npm is not available. Install npm and try again.' } -Write-Info "Installing $PackageName globally..." -npm install -g $PackageName +$UserPrefix = $null + +# Run npm install without aborting on failure, so we can fall back. Returns the exit code. +function Invoke-NpmInstall { + param([string[]]$NpmArgs) + try { + & npm install -g @NpmArgs | Out-Host + return $LASTEXITCODE + } catch { + return 1 + } +} + +function Install-Package { + Write-Info "Installing $PackageName globally..." + if ((Invoke-NpmInstall @($PackageName)) -eq 0) { + return + } + + Write-Warn 'Global install failed. Falling back to a user-level install.' + $script:UserPrefix = Join-Path $env:APPDATA 'npm-global' + New-Item -ItemType Directory -Force -Path $script:UserPrefix | Out-Null + Write-Info "Installing $PackageName to $script:UserPrefix instead..." + if ((Invoke-NpmInstall @('--prefix', $script:UserPrefix, $PackageName)) -ne 0) { + Write-Err @" +Installation failed. +Try fixing your npm permissions, or run the CLI without installing: npx $PackageName --help +"@ + } + + $env:PATH = "$script:UserPrefix;$env:PATH" +} + +Install-Package $installedVersion = $null if (Get-Command $CommandName -ErrorAction SilentlyContinue) { @@ -65,17 +97,26 @@ if ($installedVersion) { Write-Host '' Write-Host 'Installed! You may need to restart your shell or add the npm global bin directory to your PATH.' -ForegroundColor Green - $npmPrefix = (npm prefix -g 2>$null).Trim() - if ($npmPrefix) { - $pathEntries = $env:PATH -split ';' | Where-Object { $_ -ne '' } - if ($pathEntries -notcontains $npmPrefix) { - Write-Warn "$npmPrefix is not in your PATH. Add it with:" - Write-Host " setx PATH `"$npmPrefix;%PATH%`"" - Write-Host '' + if (-not $UserPrefix) { + $npmPrefix = (npm prefix -g 2>$null).Trim() + if ($npmPrefix) { + $pathEntries = $env:PATH -split ';' | Where-Object { $_ -ne '' } + if ($pathEntries -notcontains $npmPrefix) { + Write-Warn "$npmPrefix is not in your PATH. Add it with:" + Write-Host " setx PATH `"$npmPrefix;%PATH%`"" + Write-Host '' + } } } } +if ($UserPrefix) { + Write-Host '' + Write-Host "The CLI was installed to $UserPrefix." + Write-Host 'Add it to your PATH permanently with:' + Write-Host " setx PATH `"$UserPrefix;%PATH%`"" +} + Write-Host '' Write-Host 'Next step: configure your auth token with decodo setup' Write-Host 'Get started:' diff --git a/docs/install.sh b/docs/install.sh index ec30c9b..1eda9d8 100755 --- a/docs/install.sh +++ b/docs/install.sh @@ -48,6 +48,47 @@ Update Node.js from https://nodejs.org/ and try again." echo "$version" } +can_write_global() { + prefix=$(npm prefix -g 2>/dev/null) || return 1 + [ -n "$prefix" ] || return 1 + + for dir in "${prefix}/lib/node_modules" "${prefix}/bin"; do + target="$dir" + while [ ! -d "$target" ]; do + target=$(dirname "$target") + done + [ -w "$target" ] || return 1 + done +} + +install_package() { + USER_PREFIX_BIN="" + + if can_write_global; then + info "Installing ${PACKAGE_NAME} globally..." + if npm install -g "${PACKAGE_NAME}"; then + return + fi + warn "Global install failed. Falling back to a user-level install." + else + warn "No write permission for the npm global directory ($(npm prefix -g 2>/dev/null))." + info "Installing ${PACKAGE_NAME} to ${HOME}/.npm-global instead (no sudo needed)..." + fi + + user_prefix="${HOME}/.npm-global" + mkdir -p "$user_prefix" + + if ! npm install -g --prefix "$user_prefix" "${PACKAGE_NAME}"; then + error "Installation failed. +Try fixing your npm permissions (https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally) +or run the CLI without installing: npx ${PACKAGE_NAME} --help" + fi + + USER_PREFIX_BIN="${user_prefix}/bin" + PATH="${USER_PREFIX_BIN}:${PATH}" + export PATH +} + main() { printf "\n${BOLD}Decodo CLI Installer${RESET}\n\n" @@ -59,22 +100,29 @@ main() { error "npm is not available. Install npm and try again." fi - info "Installing ${PACKAGE_NAME} globally..." - npm install -g "${PACKAGE_NAME}" + install_package if command -v "$COMMAND_NAME" >/dev/null 2>&1; then installed_version=$("$COMMAND_NAME" --version 2>/dev/null || echo "unknown") printf "\n${GREEN}${BOLD}Success!${RESET} ${PACKAGE_NAME} ${installed_version} is installed.\n" else printf "\n${GREEN}${BOLD}Installed!${RESET} You may need to restart your shell or add the npm global bin directory to your PATH.\n" - npm_prefix=$(npm config get prefix 2>/dev/null) || true - npm_bin="${npm_prefix:+$npm_prefix/bin}" - if [ -n "$npm_bin" ] && ! echo "$PATH" | tr ':' '\n' | grep -qx "$npm_bin"; then - warn "${npm_bin} is not in your PATH. Add it with:" - printf " export PATH=\"%s:\$PATH\"\n\n" "$npm_bin" + if [ -z "$USER_PREFIX_BIN" ]; then + npm_prefix=$(npm config get prefix 2>/dev/null) || true + npm_bin="${npm_prefix:+$npm_prefix/bin}" + if [ -n "$npm_bin" ] && ! echo "$PATH" | tr ':' '\n' | grep -qx "$npm_bin"; then + warn "${npm_bin} is not in your PATH. Add it with:" + printf " export PATH=\"%s:\$PATH\"\n\n" "$npm_bin" + fi fi fi + if [ -n "$USER_PREFIX_BIN" ]; then + printf "\nThe CLI was installed to ${BOLD}%s${RESET}.\n" "$USER_PREFIX_BIN" + printf "Add it to your PATH permanently by appending this line to your shell profile (e.g. ~/.zshrc or ~/.bashrc):\n" + printf " ${BOLD}export PATH=\"%s:\$PATH\"${RESET}\n" "$USER_PREFIX_BIN" + fi + printf "\nNext step: configure your auth token with ${BOLD}decodo setup${RESET}\n" printf "Get started:\n" printf " ${BOLD}decodo scrape${RESET} https://ip.decodo.com\n" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c40277e..348e7b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 7.7.0(oxlint@1.66.0) vitest: specifier: ^3.2.0 - version: 3.2.4(@types/node@25.9.1)(yaml@2.9.0) + version: 3.2.6(@types/node@25.9.1)(yaml@2.9.0) packages: @@ -545,11 +545,11 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 @@ -559,20 +559,20 @@ packages: vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -871,16 +871,16 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -1199,45 +1199,45 @@ snapshots: dependencies: undici-types: 7.24.6 - '@vitest/expect@3.2.4': + '@vitest/expect@3.2.6': dependencies: '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@25.9.1)(yaml@2.9.0))': + '@vitest/mocker@3.2.6(vite@7.3.3(@types/node@25.9.1)(yaml@2.9.0))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.3(@types/node@25.9.1)(yaml@2.9.0) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@3.2.6': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.2.4': + '@vitest/runner@3.2.6': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 3.2.6 pathe: 2.0.3 strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': + '@vitest/spy@3.2.6': dependencies: tinyspy: 4.0.4 - '@vitest/utils@3.2.4': + '@vitest/utils@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 loupe: 3.2.1 tinyrainbow: 2.0.0 @@ -1535,16 +1535,16 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 - vitest@3.2.4(@types/node@25.9.1)(yaml@2.9.0): + vitest@3.2.6(@types/node@25.9.1)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@25.9.1)(yaml@2.9.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.3(@types/node@25.9.1)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.3.3 debug: 4.4.3 expect-type: 1.3.0 diff --git a/src/auth/constants.ts b/src/auth/constants.ts index ebef062..1a286d6 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,4 +1,3 @@ export const PLAYGROUND_URL = "https://dashboard.decodo.com/playground"; -export const AUTH_MISSING_MESSAGE = - "No auth token found. Run `decodo setup` or set DECODO_AUTH_TOKEN."; +export const AUTH_MISSING_MESSAGE = "No auth token found."; diff --git a/src/platform/services/handle-cli-error.ts b/src/platform/services/handle-cli-error.ts index cead1cf..7de14f3 100644 --- a/src/platform/services/handle-cli-error.ts +++ b/src/platform/services/handle-cli-error.ts @@ -5,6 +5,7 @@ import { TimeoutError, ValidationError, } from "@decodo/sdk-ts"; +import { PLAYGROUND_URL } from "../../auth/constants.js"; import { AuthRequiredError } from "../../auth/errors/auth-required-error.js"; import { EXIT } from "../constants.js"; @@ -113,7 +114,14 @@ export function handleCliError( } } - if (err instanceof AuthRequiredError || err instanceof AuthenticationError) { + if (err instanceof AuthRequiredError) { + console.error( + "\nThe Decodo CLI is installed and working - it just needs an auth token:\n" + + ` 1. Get your Web Scraping API token at ${PLAYGROUND_URL}\n` + + " 2. Run `decodo setup` to save it (or set DECODO_AUTH_TOKEN)\n" + + " 3. Re-run your command" + ); + } else if (err instanceof AuthenticationError) { console.error("Hint: Run `decodo setup` to configure your auth token."); } diff --git a/src/scrape/services/auth-validation.ts b/src/scrape/services/auth-validation.ts index 66c845a..a450f54 100644 --- a/src/scrape/services/auth-validation.ts +++ b/src/scrape/services/auth-validation.ts @@ -6,6 +6,6 @@ export async function validateAuthToken(token: string): Promise { await client.webScrapingApi.scrape({ target: ScrapeTarget.Universal, - url: "https://ip.decodo.com", + url: "https://does-not-exist.decodo.com", }); } diff --git a/tests/platform/services/handle-cli-error.test.ts b/tests/platform/services/handle-cli-error.test.ts index 027c317..e356a99 100644 --- a/tests/platform/services/handle-cli-error.test.ts +++ b/tests/platform/services/handle-cli-error.test.ts @@ -33,21 +33,24 @@ describe("handleCliError", () => { vi.restoreAllMocks(); }); - it("maps auth-required errors to exit code 3 with setup hint", () => { + it("maps auth-required errors to exit code 3 with onboarding steps", () => { expect(() => handleCliError(new AuthRequiredError("token missing")) ).toThrow("process.exit:3"); expect(exitCode).toBe(3); expect(stderr.join("\n")).toContain("token missing"); + expect(stderr.join("\n")).toContain("installed and working"); expect(stderr.join("\n")).toContain("decodo setup"); + expect(stderr.join("\n")).toContain("DECODO_AUTH_TOKEN"); }); - it("maps SDK authentication errors to exit code 3", () => { + it("maps SDK authentication errors to exit code 3 with setup hint", () => { const err = new AuthenticationError("Unauthorized"); expect(() => handleCliError(err)).toThrow("process.exit:3"); expect(exitCode).toBe(3); + expect(stderr.join("\n")).toContain("decodo setup"); }); it("maps validation errors to exit code 4 and prints details", () => { diff --git a/tests/scrape/services/auth-validation.test.ts b/tests/scrape/services/auth-validation.test.ts index bee2e93..8c8ca5f 100644 --- a/tests/scrape/services/auth-validation.test.ts +++ b/tests/scrape/services/auth-validation.test.ts @@ -25,11 +25,11 @@ describe("validateAuthToken", () => { expect(url).toBe("https://scraper-api.decodo.com/v2/scrape"); expect(JSON.parse(init.body as string)).toEqual({ target: "universal", - url: "https://ip.decodo.com", + url: "https://does-not-exist.decodo.com", }); expect(init.headers).toMatchObject({ Authorization: "Basic test-token", - "x-integration": "sdk-ts", // TODO(SCR-3150): switch to cli when sdk task lands + "x-integration": "sdk-ts", // TODO: switch to cli when sdk task lands }); }); });