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 }); }); });