Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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
```

Expand Down
5 changes: 1 addition & 4 deletions docs/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ $ErrorActionPreference = 'Stop'

$PackageName = '@decodo/cli'
$CommandName = 'decodo'
$CommandAlias = 'dcd'
$MinNodeMajor = 18

function Write-Info([string]$Message) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ''
9 changes: 3 additions & 6 deletions docs/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ set -e

PACKAGE_NAME="@decodo/cli"
COMMAND_NAME="decodo"
COMMAND_ALIAS="dcd"
MIN_NODE_MAJOR=18

if [ -t 1 ]; then
Expand Down Expand Up @@ -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"
Expand All @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 4 additions & 15 deletions src/auth/commands/setup.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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(
Expand All @@ -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."));
Expand Down
8 changes: 8 additions & 0 deletions src/auth/errors/config-parse-error.ts
Original file line number Diff line number Diff line change
@@ -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";
}
}
16 changes: 13 additions & 3 deletions src/auth/services/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<DecodoConfig>;
function parseConfig(
raw: string,
configPath: string
): DecodoConfig | undefined {
let parsed: Partial<DecodoConfig>;

try {
parsed = JSON.parse(raw) as Partial<DecodoConfig>;
} catch {
throw new ConfigParseError(configPath);
}

if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) {
return {
Expand All @@ -27,7 +37,7 @@ export async function readConfig(): Promise<DecodoConfig | undefined> {
try {
const raw = await readFile(configPath, "utf8");

return parseConfig(raw);
return parseConfig(raw, configPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return;
Expand Down
12 changes: 7 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
75 changes: 75 additions & 0 deletions src/platform/services/prompt-hidden.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
});
}
23 changes: 10 additions & 13 deletions tests/auth/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -36,7 +33,7 @@ describe("setupCommand", () => {
exitCode = undefined;
stdout = [];
stderr = [];
mockQuestion.mockReset();
mockPromptHidden.mockReset();

vi.spyOn(process, "exit").mockImplementation((code) => {
exitCode = code as number;
Expand Down Expand Up @@ -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");

Expand All @@ -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");

Expand All @@ -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);
});

Expand All @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions tests/auth/services/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions tests/platform/services/prompt-hidden.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading