Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ npx @decodo/cli scrape https://ip.decodo.com --token "$DECODO_AUTH_TOKEN"

## Authentication

Get a basic auth token from the Decodo [Playground](https://dashboard.decodo.com/playground).
Get an auth token from the Decodo [Playground](https://dashboard.decodo.com/playground).

```bash
# Interactive — saves token to config
Expand Down Expand Up @@ -252,7 +252,7 @@ Use the CLI when your agent needs to scrape from a shell, terminal, CI/CD pipeli

| Variable | Description |
| --- | --- |
| `DECODO_AUTH_TOKEN` | Basic auth token (overrides saved config, below `--token`) |
| `DECODO_AUTH_TOKEN` | Auth token (overrides saved config, below `--token`) |
| `DECODO_CONFIG_HOME` | Override config directory (default: `$XDG_CONFIG_HOME/decodo`, else `~/.config/decodo`) |

## Exit codes
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ then add a branch in `resolveCliExitCode` (and a hint in `handleCliError` if use
reports its `source` (`flag` | `env` | `config` | `none`). Persistent config lives in a
JSON file resolved through `platform/services/paths.ts` (via `env-paths`) and managed by
`auth/services/config.ts` (`readConfig`/`writeConfig`/`clearConfig`). The config file is
written with `0o600` permissions and only persists a validated `authToken`. The `setup`,
written with `0o600` permissions and only persists a validated credential. The `setup`,
`reset`, and `whoami` commands are the user-facing surface over these helpers; `mask.ts`
keeps tokens from being printed in full.

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@decodo/cli",
"version": "1.0.2",
"version": "1.0.3",
"description": "Official CLI for the Decodo APIs",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -37,7 +37,7 @@
},
"packageManager": "pnpm@10.33.3",
"dependencies": {
"@decodo/sdk-ts": "^2.1.2",
"@decodo/sdk-ts": "^2.3.0",
"commander": "^14.0.0"
},
"devDependencies": {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 56 additions & 10 deletions src/auth/commands/setup.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
import { AuthenticationError } from "@decodo/sdk-ts";
import { Command } from "commander";
import { getRootOpts } from "../../cli/services/global-opts.js";
import { CliUsageError } from "../../platform/errors/cli-usage-error.js";
import { 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 { validateCredential } from "../../scrape/services/auth-validation.js";
import { AUTH_TYPE, PLAYGROUND_URL } from "../constants.js";
import { getConfigPath, writeConfig } from "../services/config.js";
import { detectCredentialType } from "../services/detect-credential-type.js";
import type { DecodoConfig } from "../types/config.js";
import type { AuthCredential, AuthType } from "../types/credential.js";

const TOKEN_PROMPT = `Paste your Web Scraping API basic auth token (${PLAYGROUND_URL}): `;
const TOKEN_PROMPT = `Paste your Web Scraping API auth token (${PLAYGROUND_URL}): `;

interface SetupOptions {
token?: string;
}

function oppositeAuthType(type: AuthType): AuthType {
return type === AUTH_TYPE.TOKEN ? AUTH_TYPE.API_KEY : AUTH_TYPE.TOKEN;
}

function toConfig(credential: AuthCredential): DecodoConfig {
if (credential.type === AUTH_TYPE.API_KEY) {
return { apiKey: credential.value };
}

return { authToken: credential.value };
}

async function verifyCredential(value: string): Promise<AuthCredential> {
const detected: AuthCredential = {
type: detectCredentialType(value),
value,
};

try {
await validateCredential(detected);
return detected;
} catch (err) {
if (!(err instanceof AuthenticationError)) {
throw err;
}

const fallback: AuthCredential = {
type: oppositeAuthType(detected.type),
value,
};

try {
await validateCredential(fallback);
} catch {
throw err;
}

return fallback;
}
}

export const setupCommand = new Command("setup")
.description("Configure the Decodo CLI with your auth token")
.option(
"--token <value>",
"Web Scraping API basic auth token (non-interactive)"
)
.action(async (options: { token?: string }, command) => {
.option("--token <value>", "Web Scraping API auth token (non-interactive)")
.action(async (options: SetupOptions, command) => {
const rootOpts = getRootOpts(command);
const token = (
options.token?.trim() ||
Expand All @@ -28,8 +74,8 @@ export const setupCommand = new Command("setup")
}

try {
await validateAuthToken(token);
await writeConfig({ authToken: token });
const credential = await verifyCredential(token);
await writeConfig(toConfig(credential));
console.log(`Setup complete. Configuration saved to ${getConfigPath()}`);
} catch (err) {
handleCliError(err, { fallbackMessage: "Setup failed." });
Expand Down
15 changes: 12 additions & 3 deletions src/auth/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import { Command } from "commander";
import { getRootOpts } from "../../cli/services/global-opts.js";
import { handleCliError } from "../../platform/services/handle-cli-error.js";
import { AUTH_TYPE } from "../constants.js";
import { AuthRequiredError } from "../errors/auth-required-error.js";
import { mask } from "../services/mask.js";
import { resolveAuthToken } from "../services/resolve-token.js";
import type { AuthType } from "../types/credential.js";

const CREDENTIAL_LABEL: Record<AuthType, string> = {
[AUTH_TYPE.API_KEY]: "api key",
[AUTH_TYPE.TOKEN]: "token",
};

export const whoamiCommand = new Command("whoami")
.description("Show the active auth source and masked token")
.action(async (_options, command) => {
const rootOpts = getRootOpts(command);
const { token, source } = await resolveAuthToken({
const { credential, source } = await resolveAuthToken({
token: rootOpts.token,
});

if (!token) {
if (!credential) {
handleCliError(new AuthRequiredError());
}

console.log(`source: ${source}`);
console.log(`token: ${mask(token, 4, -4)}`);
console.log(
`${CREDENTIAL_LABEL[credential.type]}: ${mask(credential.value, 4, -4)}`
);
});
5 changes: 5 additions & 0 deletions src/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export const PLAYGROUND_URL = "https://dashboard.decodo.com/playground";

export const AUTH_MISSING_MESSAGE = "No auth token found.";

export const AUTH_TYPE = {
TOKEN: "token",
API_KEY: "apiKey",
} as const;
38 changes: 33 additions & 5 deletions src/auth/services/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ export function getConfigPath(): string {
return join(getConfigDir(), CONFIG_FILE);
}

function readCredentialField(
parsed: Partial<DecodoConfig>,
key: keyof DecodoConfig
): string | undefined {
const value = parsed[key];

if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}

return;
}

function parseConfig(
raw: string,
configPath: string
Expand All @@ -22,13 +35,28 @@ function parseConfig(
throw new ConfigParseError(configPath);
}

if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) {
return {
authToken: parsed.authToken,
};
if (!parsed || typeof parsed !== "object") {
return;
}

return;
const apiKey = readCredentialField(parsed, "apiKey");
const authToken = readCredentialField(parsed, "authToken");

if (!(apiKey || authToken)) {
return;
}

const config: DecodoConfig = {};

if (apiKey) {
config.apiKey = apiKey;
}

if (authToken) {
config.authToken = authToken;
}

return config;
}

export async function readConfig(): Promise<DecodoConfig | undefined> {
Expand Down
14 changes: 14 additions & 0 deletions src/auth/services/detect-credential-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AUTH_TYPE } from "../constants.js";
import type { AuthType } from "../types/credential.js";

const PRINTABLE_ASCII = /^[\x20-\x7e]+$/;

export function detectCredentialType(value: string): AuthType {
const decoded = Buffer.from(value, "base64").toString("utf8");

if (PRINTABLE_ASCII.test(decoded) && decoded.includes(":")) {
return AUTH_TYPE.TOKEN;
}

return AUTH_TYPE.API_KEY;
}
33 changes: 26 additions & 7 deletions src/auth/services/resolve-token.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,53 @@
import { AUTH_TYPE } from "../constants.js";
import type { AuthCredential } from "../types/credential.js";
import { readConfig } from "./config.js";
import { detectCredentialType } from "./detect-credential-type.js";

export type AuthSource = "flag" | "env" | "config" | "none";

export interface ResolvedAuth {
credential: AuthCredential | undefined;
source: AuthSource;
token: string | undefined;
}

export interface ResolveAuthOptions {
token?: string;
}

function detect(value: string): AuthCredential {
return { type: detectCredentialType(value), value };
}

export async function resolveAuthToken(
options: ResolveAuthOptions = {}
): Promise<ResolvedAuth> {
if (options.token) {
return { token: options.token, source: "flag" };
const flagToken = options.token?.trim();

if (flagToken) {
return { credential: detect(flagToken), source: "flag" };
}

const envToken = process.env.DECODO_AUTH_TOKEN;
const envToken = process.env.DECODO_AUTH_TOKEN?.trim();

if (envToken) {
return { token: envToken, source: "env" };
return { credential: detect(envToken), source: "env" };
}

const config = await readConfig();

if (config?.authToken) {
return { token: config.authToken, source: "config" };
return {
credential: { type: AUTH_TYPE.TOKEN, value: config.authToken },
source: "config",
};
}

if (config?.apiKey) {
return {
credential: { type: AUTH_TYPE.API_KEY, value: config.apiKey },
source: "config",
};
}

return { token: undefined, source: "none" };
return { credential: undefined, source: "none" };
}
3 changes: 2 additions & 1 deletion src/auth/types/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export interface DecodoConfig {
authToken: string;
apiKey?: string;
authToken?: string;
}
8 changes: 8 additions & 0 deletions src/auth/types/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { AUTH_TYPE } from "../constants.js";

export type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE];

export interface AuthCredential {
type: AuthType;
value: string;
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const program = new Command()
.option("-v, --verbose", "Print debug logs to stderr")
.option(
"--token <token>",
"Basic auth token (overrides DECODO_AUTH_TOKEN and saved config)"
"Auth token (overrides DECODO_AUTH_TOKEN and saved config)"
);

async function main(): Promise<void> {
Expand Down
7 changes: 5 additions & 2 deletions src/scrape/services/auth-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import {
Target as ScrapeTarget,
TimeoutError,
} from "@decodo/sdk-ts";
import type { AuthCredential } from "../../auth/types/credential.js";
import { createDecodoClient } from "./client.js";

const AUTH_PROBE_URL = "https://does-not-exist.decodo.com";

export async function validateAuthToken(token: string): Promise<void> {
const client = createDecodoClient(token);
export async function validateCredential(
credential: AuthCredential
): Promise<void> {
const client = createDecodoClient(credential);

try {
await client.webScrapingApi.scrape({
Expand Down
Loading
Loading