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/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", 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()