diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37d4b72..f3821ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,13 +29,13 @@ jobs: - name: Install dependencies run: npm ci - - name: Build - run: npm run build - - name: Bump version uses: phips28/gh-action-bump-version@v11.0.7 with: tag-prefix: "" + - name: Build + run: npm run build + - name: Publish to NPM run: npm publish --access public --provenance diff --git a/README.md b/README.md index cd0dec8..93c2d6d 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,34 @@ # localview -Easily access a localhost website from your mobile device. +Open your local dev server on your phone with a QR scan. -## Usage +`localview` finds your machine's LAN IP, builds a URL pointing at your dev server's port, and prints a QR code in the terminal. Scan it from any device on the same network. No tunnels, no signup, no daemon. -1. Start your localhost server. -2. Create a QR code with localview. +![Demo](./static/demo.gif) -```bash -npx localview --port 8080 -``` +## Quick start -3. To visit the exposed URL, scan the QR code with your mobile device. +1. Start your dev server. +2. In a separate terminal, run `localview` with the same port: -![Demo](./static/demo.gif) + ```bash + npx localview --port 8080 + ``` + +3. Scan the QR code with your phone's camera. The page opens in the default browser. + +## Use cases + +- **Mobile UI checks**: touch targets, breakpoints, virtual keyboards, and the OS chrome that desktop devtools approximations can't fully replicate. +- **Real-device APIs**: `getUserMedia`, geolocation, device orientation, `vibrate`, and other sensor or permission flows that only work on a real phone. +- **PWA install flow**: Add-to-home-screen prompts, splash screens, standalone-mode display. ## Options | Flag | Description | | -------------- | ------------------------------------------------------------- | -| `--port`, `-P` | Port exposed by the local server. **Required.** | -| `--path` | Path appended to the URL (e.g. `/admin`). Optional. | +| `--port`, `-P` | Port your dev server is bound to. **Required.** | +| `--path` | Path appended to the URL (e.g. `/admin`). | | `--host` | Override the auto-detected LAN IP (Docker, multi-NIC, demos). | ### Examples @@ -33,13 +41,13 @@ npx localview --port 3000 --path /admin npx localview --port 8080 --host 192.168.1.42 ``` -If multiple non-internal IPv4 interfaces are detected (e.g. wifi + VPN + Docker bridges), localview prints a numbered list and asks which one to use; press Enter to accept the smart default. +If your machine has multiple LAN interfaces (wifi + VPN + Docker bridges, for example), `localview` shows an interactive picker with arrow-key navigation. The smart-sorted default (wifi and ethernet first) is selected by pressing Enter. -## Notes +## Requirements -- Requires Node.js `^20.19.0`, `^22.12.0`, or `>=23`. -- Both the server and the mobile device need to be connected to the same network. -- You may need to set your server's host to `0.0.0.0`. +- Node.js `^20.19.0`, `^22.12.0`, or `>=23`. +- Your dev server and your phone connected to the same network. +- Your dev server must accept connections from its LAN address. Many frameworks bind to `127.0.0.1` only by default; bind to `0.0.0.0` instead (the exact flag depends on your tool: `--host 0.0.0.0`, `--bind 0.0.0.0`, `HOST=0.0.0.0`, etc.) to expose it on the LAN. ## License diff --git a/package-lock.json b/package-lock.json index b11e33c..802ddb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@clack/prompts": "^1.3.0", "qrcode-terminal": "^0.12.0", "yargs": "^18.0.0" }, @@ -28,6 +29,34 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/@clack/core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.0.tgz", + "integrity": "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.3.0.tgz", + "integrity": "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.3.0", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1102,6 +1131,30 @@ "node": ">=6" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1461,6 +1514,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/package.json b/package.json index 0451d6f..4e38d7f 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "start": "tsup src/cli.ts --format cjs --watch" }, "dependencies": { + "@clack/prompts": "^1.3.0", "qrcode-terminal": "^0.12.0", "yargs": "^18.0.0" }, diff --git a/src/cli.ts b/src/cli.ts index b6f4f9a..b8d0939 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import os from "os"; -import readline from "readline"; +import { isCancel, select } from "@clack/prompts"; import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import qrcode from "qrcode-terminal"; @@ -74,34 +74,21 @@ async function pickAddress(): Promise { return first.address; } - console.log("Multiple network interfaces detected:"); - candidates.forEach((c, i) => { - const label = c.kind === "other" ? "" : ` (${c.kind})`; - console.log(` ${i + 1}. ${c.name}${label} — ${c.address}`); + const selected = await select({ + message: "Select network interface", + initialValue: candidates[0].address, + options: candidates.map((c) => { + const label = c.kind === "other" ? c.name : `${c.name} (${c.kind})`; + return { value: c.address, label, hint: c.address }; + }), }); - const answer = await prompt( - `Select interface [1-${candidates.length}] (default: 1): `, - ); - const trimmed = answer.trim(); - const choice = trimmed === "" ? 1 : Number(trimmed); - if (!Number.isInteger(choice) || choice < 1 || choice > candidates.length) { - console.log("Invalid selection."); - process.exit(1); + + if (isCancel(selected)) { + console.log("Cancelled."); + process.exit(0); } - return candidates[choice - 1].address; -} -function prompt(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer); - }); - }); + return selected as string; } function normalizePath(value: string): string { diff --git a/static/demo.gif b/static/demo.gif index 306127c..d50837a 100644 Binary files a/static/demo.gif and b/static/demo.gif differ