diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 0b9b5b2..46b1b15 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -4,12 +4,8 @@ - bun run bench - bun run bench:constraints -- bun run bench:patterns -- bun run bench:structures - bun run bench:node - bun run bench:node:constraints -- bun run bench:node:patterns -- bun run bench:node:structures - bun run bench:browser - bun run bench:all diff --git a/bench/browser.mjs b/bench/browser.mjs index 169903d..b4a9feb 100644 --- a/bench/browser.mjs +++ b/bench/browser.mjs @@ -1,6 +1,4 @@ import { runPasswordBench, formatPasswordBench } from "./bench.mjs"; -import { runPatternBench, formatPatternBench } from "./patterns.mjs"; -import { runStructuresBench, formatStructuresBench } from "./structures.mjs"; import { runConstraintsBench, formatConstraintsBench } from "./constraints.mjs"; const params = new URLSearchParams(globalThis.location?.search ?? ""); @@ -10,10 +8,6 @@ const log = !params.has("quiet"); const passwordOverrides = fast ? { warmup: 1, samples: 2, iters: 800, length: 12 } : {}; -const patternOverrides = fast ? { warmup: 1, samples: 2, iters: 400 } : {}; -const structureOverrides = fast - ? { warmup: 1, samples: 2, iters: 5_000, length: 16 } - : {}; const constraintsOverrides = fast ? { warmup: 1, @@ -29,21 +23,15 @@ const constraintsOverrides = fast try { const password = await runPasswordBench(passwordOverrides); - const patterns = runPatternBench(patternOverrides); - const structures = runStructuresBench(structureOverrides); const constraints = await runConstraintsBench(constraintsOverrides); - const results = { password, patterns, structures, constraints }; + const results = { password, constraints }; globalThis.__benchResults = results; if (log) { const output = [ ...formatPasswordBench(password), "", - ...formatPatternBench(patterns), - "", - ...formatStructuresBench(structures), - "", ...formatConstraintsBench(constraints), ]; for (const line of output) { diff --git a/bench/patterns.mjs b/bench/patterns.mjs deleted file mode 100644 index 0cf0a9b..0000000 --- a/bench/patterns.mjs +++ /dev/null @@ -1,109 +0,0 @@ -import { - envNumber, - now, - median, - formatCase, - runtimeLabel, - isCliEntry, -} from "./utils.mjs"; - -const DEFAULTS = { - WARMUP: 2, - SAMPLES: 5, - ITERS: 2_000, -}; - -const PATTERNS = [ - { name: "digits", pattern: /\d/ }, - { name: "word", pattern: /\w/ }, - { name: "hex", pattern: /[a-f0-9]/i }, - { name: "symbols", pattern: /[!@#$%^&*]/ }, -]; - -function buildValidChars(pattern) { - const validChars = []; - for (let i = 33; i <= 126; i += 1) { - const char = String.fromCharCode(i); - if (char.match(pattern)) { - validChars.push(char); - } - } - return validChars; -} - -export function runPatternBench(overrides = {}) { - const config = { - warmup: envNumber("WARMUP", DEFAULTS.WARMUP), - samples: envNumber("SAMPLES", DEFAULTS.SAMPLES), - iters: envNumber("ITERS", DEFAULTS.ITERS), - ...overrides, - }; - - const runCase = (name, ops, fn) => { - for (let i = 0; i < config.warmup; i += 1) { - fn(); - } - const sampleTimes = []; - for (let i = 0; i < config.samples; i += 1) { - const start = now(); - fn(); - sampleTimes.push(now() - start); - } - const med = median(sampleTimes); - return { - name, - medianMs: med, - opsPerSec: (ops / med) * 1000, - }; - }; - - const cases = PATTERNS.map(({ name, pattern }) => - runCase(`build valid chars (${name})`, config.iters * 94, () => { - for (let i = 0; i < config.iters; i += 1) { - buildValidChars(pattern); - } - }), - ); - - return { - title: "Pattern microbench", - runtime: runtimeLabel(), - config, - suites: [ - { - name: "regex pattern build", - cases, - }, - ], - }; -} - -export function formatPatternBench(result) { - const lines = []; - lines.push(result.title); - lines.push(result.runtime); - lines.push( - `samples=${result.config.samples} warmup=${result.config.warmup} iters=${result.config.iters}`, - ); - lines.push(""); - for (const suite of result.suites) { - lines.push(`Suite: ${suite.name}`); - for (const row of suite.cases) { - lines.push(formatCase(row)); - } - lines.push(""); - } - return lines; -} - -if ( - isCliEntry( - typeof process !== "undefined" ? process.argv : null, - "bench/patterns.mjs", - ) -) { - const result = runPatternBench(); - for (const line of formatPatternBench(result)) { - console.log(line); - } -} diff --git a/bench/structures.mjs b/bench/structures.mjs deleted file mode 100644 index de6bf4f..0000000 --- a/bench/structures.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import { - envNumber, - now, - median, - formatCase, - runtimeLabel, - isCliEntry, -} from "./utils.mjs"; - -const DEFAULTS = { - WARMUP: 2, - SAMPLES: 5, - ITERS: 20_000, - LENGTH: 24, -}; - -const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; - -function concatCase(iters, length) { - for (let i = 0; i < iters; i += 1) { - let result = ""; - for (let j = 0; j < length; j += 1) { - result += CHARS[(i + j) % CHARS.length]; - } - void result; - } -} - -function arrayCase(iters, length) { - for (let i = 0; i < iters; i += 1) { - const chars = []; - for (let j = 0; j < length; j += 1) { - chars.push(CHARS[(i + j) % CHARS.length]); - } - const result = chars.join(""); - void result; - } -} - -export function runStructuresBench(overrides = {}) { - const config = { - warmup: envNumber("WARMUP", DEFAULTS.WARMUP), - samples: envNumber("SAMPLES", DEFAULTS.SAMPLES), - iters: envNumber("ITERS", DEFAULTS.ITERS), - length: envNumber("LENGTH", DEFAULTS.LENGTH), - ...overrides, - }; - - const cases = []; - const { warmup, samples, iters, length } = config; - - const runCase = (name, ops, fn) => { - for (let i = 0; i < warmup; i += 1) { - fn(); - } - const sampleTimes = []; - for (let i = 0; i < samples; i += 1) { - const start = now(); - fn(); - sampleTimes.push(now() - start); - } - const med = median(sampleTimes); - cases.push({ - name, - medianMs: med, - opsPerSec: (ops / med) * 1000, - }); - }; - - const ops = iters * length; - runCase("string concat", ops, () => concatCase(iters, length)); - runCase("array join", ops, () => arrayCase(iters, length)); - - return { - title: "Structure microbench", - runtime: runtimeLabel(), - config, - cases, - }; -} - -export function formatStructuresBench(result) { - const lines = []; - lines.push(result.title); - lines.push(result.runtime); - lines.push( - `samples=${result.config.samples} warmup=${result.config.warmup} iters=${result.config.iters} length=${result.config.length}`, - ); - lines.push(""); - for (const row of result.cases) { - lines.push(formatCase(row)); - } - return lines; -} - -if ( - isCliEntry( - typeof process !== "undefined" ? process.argv : null, - "bench/structures.mjs", - ) -) { - const result = runStructuresBench(); - for (const line of formatStructuresBench(result)) { - console.log(line); - } -} diff --git a/docs/plans/drafts/modernize-as-we-did-for-eventify.md b/docs/plans/drafts/modernize-as-we-did-for-eventify.md deleted file mode 100644 index e81e9eb..0000000 --- a/docs/plans/drafts/modernize-as-we-did-for-eventify.md +++ /dev/null @@ -1,361 +0,0 @@ -# Password Generator v3 Modernization Plan (modeled after Eventify v3) - -Last updated: 2026-02-07 - -**Overview** -This plan mirrors the Eventify v2 -> v3 modernization: TypeScript-first, ESM-only, strict configs, Bun-based build/test, Playwright browser checks, and benchmark discipline. The primary functional change is moving to async password generation backed by WebCrypto. - -**Goals** - -- Rewrite the core in TypeScript with strict typing. -- Make the public API async and WebCrypto-backed. -- Ship ESM-only output with explicit exports and declaration files. -- Replace legacy Grunt/Make-based workflows with Bun. -- Add unit, type, and browser tests with CI parity. -- Document breaking changes and a clear migration path. - -**Non-Goals** - -- Keep legacy UMD/global builds or Ender/Bower integrations. -- Support Node versions below 20. -- Preserve synchronous generation as the primary API. - -**Key Decisions** - -- The default API becomes async: `generatePassword(...)` returns a Promise. -- Randomness uses WebCrypto; Node uses `node:crypto` with async `randomBytes`. -- Distribution is ESM-only with `exports` and `sideEffects: false`. -- Tooling matches Eventify: Bun build/test, Prettier formatting, Playwright browser tests. - -**Migration Map (v2 -> v3)** -| Area | v2 | v3 plan | -| --- | --- | --- | -| Module format | CommonJS + UMD global | ESM-only (`type: module`) | -| Runtime | Node 0.6+ | Node 20+ | -| API | `generatePassword()` sync | `await generatePassword()` async | -| Randomness | sync `crypto.getRandomValues`/`randomBytes` | async WebCrypto + async `randomBytes` | -| Build | Grunt/Make | Bun build + `tsc` for types | -| Tests | Mocha + Make | Bun test + Playwright | -| CI | Travis/Testling | GitHub Actions | - -**Target API** -TypeScript signature: - -```ts -export type GenerateOptions = { - length?: number; - memorable?: boolean; - pattern?: RegExp; - prefix?: string; -}; - -export async function generatePassword( - length?: number, - memorable?: boolean, - pattern?: RegExp, - prefix?: string, -): Promise; - -export async function generatePasswordWithOptions( - options?: GenerateOptions, -): Promise; -``` - -Usage example: - -```ts -import { generatePassword } from "password-generator"; - -const pass = await generatePassword(12, true); -``` - -CLI example: - -```ts -#!/usr/bin/env node -import { generatePasswordWithOptions } from "../dist/index.js"; - -const pass = await generatePasswordWithOptions({ - length: 16, - memorable: false, -}); -process.stdout.write(`${pass}\n`); -``` - -**Async WebCrypto Randomness** -Core helper sketch: - -```ts -import { randomBytes } from "node:crypto"; - -export async function getRandomBytes(length: number): Promise { - if (globalThis.crypto?.getRandomValues) { - const buffer = new Uint8Array(length); - globalThis.crypto.getRandomValues(buffer); - return buffer; - } - - if (typeof randomBytes === "function") { - return new Uint8Array(await randomBytes(length)); - } - - throw new Error("No secure random number generator available."); -} -``` - -**Project Structure (target)** - -```text -src/ - index.ts - random.ts -bench/ - bench.mjs - browser.mjs - utils.mjs - patterns.mjs - structures.mjs -.github/workflows/ - ci.yml - browser.yml -``` - -**TypeScript Strictness** -`tsconfig.json` baseline (match Eventify): - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "strict": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "useUnknownInCatchVariables": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "noEmit": true, - "rootDir": "src", - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"] -} -``` - -**Build and Package Outputs (Bun)** -`package.json` sketch (mirror Eventify structure and scripts): - -```json -{ - "type": "module", - "sideEffects": false, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "browser": "./dist/index.js", - "bun": "./dist/index.js", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "engines": { "node": ">=20" }, - "scripts": { - "check": "bun x tsc -p tsconfig.json --noEmit", - "build": "bun build src/index.ts --outdir dist --entry-naming \"[name].js\" --format esm --target=browser --sourcemap=linked", - "build:types": "bun x tsc -p tsconfig.types.json", - "build:all": "bun run build && bun run build:types", - "bench": "bun run build && bun run bench/bench.mjs", - "bench:patterns": "bun run bench/patterns.mjs", - "bench:structures": "bun run bench/structures.mjs", - "bench:node": "bun run build && node bench/bench.mjs", - "bench:node:patterns": "node bench/patterns.mjs", - "bench:node:structures": "node bench/structures.mjs", - "bench:browser": "bun run build && bunx playwright test -c bench/playwright.config.mjs", - "bench:all": "bun run bench && bun run bench:patterns && bun run bench:structures && bun run bench:node && bun run bench:node:patterns && bun run bench:node:structures && bun run bench:browser", - "format": "bunx prettier --write .", - "format:check": "bunx prettier --check .", - "typecheck:tests": "bun x tsc -p tests/tsconfig.types.json", - "test": "bun test", - "test:browser": "bun run build && bunx playwright test", - "test:all": "bun test --coverage && bun run typecheck:tests && bun run build && bunx playwright test", - "publish": "bun run build:all && npm publish" - } -} -``` - -**Formatting and Git Hooks** -Pre-commit hook (same pattern as Eventify): - -```bash -#!/usr/bin/env bash -set -euo pipefail - -mapfile -t files < <(git diff --cached --name-only --diff-filter=ACM) - -if [ ${#files[@]} -eq 0 ]; then - exit 0 -fi - -format_files=() -for file in "${files[@]}"; do - case "$file" in - *.js|*.jsx|*.ts|*.tsx|*.mjs|*.cjs|*.json|*.jsonc|*.md|*.css|*.html|*.graphql|*.gql|*.yml|*.yaml) - format_files+=("$file") - ;; - esac -done - -if [ ${#format_files[@]} -eq 0 ]; then - exit 0 -fi - -bunx prettier --write --ignore-unknown -- "${format_files[@]}" - -git add -- "${format_files[@]}" -``` - -**Testing Plan** - -- Unit tests moved to Bun, updated for async API. -- Type tests in `tests/types.test-d.ts` run via `tsc` in `tests/tsconfig.types.json`. -- Playwright browser tests exercise the ESM bundle. - -**CI Workflows (GitHub Actions)** -`ci.yml` (unit + type + Playwright like Eventify): - -```yaml -name: CI - -on: - push: - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 - with: - bun-version: "1.3.1" - - name: Install dependencies - run: bun install --no-progress --registry https://registry.npmjs.org/ - - name: Unit tests (Bun) - run: bun test --coverage - - name: Type tests (tsc) - run: bun run typecheck:tests - - name: Install Playwright browsers - run: bunx playwright install --with-deps chromium - - name: Browser tests (Playwright) - run: bun run test:browser -``` - -`browser.yml` (browser-only workflow parity): - -```yaml -name: Browser Tests - -on: - push: - pull_request: - -jobs: - browser: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v1 - with: - bun-version: "1.3.1" - - name: Install dependencies - run: bun install --no-progress --registry https://registry.npmjs.org/ - - name: Install Playwright browsers - run: bunx playwright install --with-deps chromium - - name: Browser tests (Playwright) - run: bun run test:browser -``` - -**Benchmarking (Bun Tasks + BENCHMARKS.md)** -Add a benchmark harness and document results like Eventify. Example harness: - -```js -// bench/bench.mjs -import { generatePassword } from "../dist/index.js"; - -const iterations = 20000; -const start = performance.now(); -for (let i = 0; i < iterations; i += 1) { - await generatePassword(12, true); -} -const elapsed = performance.now() - start; -const ops = Math.round((iterations / elapsed) * 1000); -console.log( - `generatePassword (memorable) - ${elapsed.toFixed(2)} ms, ${ops} ops/s`, -); -``` - -Template for `BENCHMARKS.md`: - -```md -# Benchmarks - -## Commands - -- bun run bench -- bun run bench:patterns -- bun run bench:structures -- bun run bench:node -- bun run bench:browser -- bun run bench:all - -## Environment - -- Date: 2026-02-07 -- Bun: 1.3.1 -- Node: 20.x -- Browser: Playwright Chromium -``` - -**Docs and Migration Notes** - -- Update README with ESM + async examples, CLI usage, and Node 20+ requirement. -- Add migration section mapping sync usage to async usage with example. -- Add `CHANGELOG.md` with a 3.0.0 breaking section. - -Migration snippet example: - -```md -### Migration (v2 -> v3) - -- `generatePassword()` is now async. Update your code to `await generatePassword()`. -- CommonJS `require()` is no longer supported. Use ESM imports instead. -- Node 20+ is required. -``` - -**Release Checklist** - -- bun run build:all -- bun run test:all -- Verify CLI behavior with async generation -- Confirm dist outputs for Node and browser -- Update README + CHANGELOG - -**Risks and Mitigations** - -- Async API breakage for sync consumers. Mitigation: clear migration docs and examples. -- Crypto availability differences across environments. Mitigation: explicit error message. -- ESM migration friction. Mitigation: explicit exports map and docs. - -**Done Criteria** - -- All tests pass in CI, including Playwright. -- `dist/` contains ESM bundle and `.d.ts` files. -- README and CHANGELOG accurately reflect the v3 API and migration notes. diff --git a/docs/plans/drafts/modernize-as-we-did-for-eventify.ts b/docs/plans/drafts/modernize-as-we-did-for-eventify.ts deleted file mode 100644 index 0b81a57..0000000 --- a/docs/plans/drafts/modernize-as-we-did-for-eventify.ts +++ /dev/null @@ -1,155 +0,0 @@ -export const modernizeAsWeDidForEventifyPlan = { - title: "Password Generator modernization plan (modeled after Eventify v3)", - lastUpdated: "2026-02-07", - context: - "Modernize the v2-era password-generator to a v3-style release with ESM, TypeScript, strict tooling, and async WebCrypto-based randomness.", - goals: [ - "Move core implementation to TypeScript with strict type checking.", - "Switch public API to async WebCrypto-backed generation.", - "Ship ESM-only distribution with explicit exports and type declarations.", - "Replace legacy build tooling with Bun-based build and test flow.", - "Raise quality bar with unit, type, and browser test coverage.", - "Refresh docs and changelog with clear migration guidance.", - ], - nonGoals: [ - "Keep the legacy UMD/global build or old Ender/Bower integrations.", - "Support Node versions below 20.", - "Keep synchronous password generation as the primary API.", - ], - decisions: [ - "Async API: generatePassword returns a Promise and relies on WebCrypto randomness.", - "Distribution: ESM-only with an exports map and sideEffects false.", - "Tooling: Bun for build/test, Prettier for formatting, GitHub Actions for CI.", - ], - steps: [ - { - id: "01-audit", - title: "Baseline audit and migration map", - tasks: [ - "Inventory current API surface (CLI, CommonJS export, browser global).", - "List all user-facing options (length, memorable, pattern, prefix).", - "Record behavior edge cases and error messages to preserve or update.", - "Define breaking changes and replacements for the migration guide.", - ], - exitCriteria: [ - "Written migration notes outline old-to-new API mappings.", - "Decision recorded on async API and dropped legacy targets.", - ], - }, - { - id: "02-core", - title: "Rewrite core in TypeScript", - tasks: [ - "Create src/index.ts with a typed async generatePassword API.", - "Implement async random byte sourcing using WebCrypto.", - "Preserve current memorable and pattern behaviors.", - "Eliminate recursion for random selection to avoid call stack risks.", - ], - exitCriteria: [ - "TypeScript core compiles under strict settings.", - "Async password generation matches existing output expectations.", - ], - }, - { - id: "03-random", - title: "Async WebCrypto random strategy", - tasks: [ - "Browser path: use globalThis.crypto.getRandomValues and wrap in Promise.", - "Node path: use globalThis.crypto.webcrypto when available or node:crypto randomBytes (promises).", - "Surface a clear error when no secure RNG is available.", - ], - exitCriteria: [ - "Single async random source utility used by all code paths.", - "Deterministic error messages for missing crypto.", - ], - }, - { - id: "04-build", - title: "Modern build and package outputs", - tasks: [ - "Add tsconfig.json and tsconfig.types.json with strict options.", - "Add Bun build pipeline to emit dist/index.js and dist/index.d.ts.", - "Update package.json with type module, exports map, files list, and engines >= 20.", - "Update CLI entry to ESM and async main.", - ], - exitCriteria: [ - "bun run build:all produces dist outputs and types.", - "Package metadata cleanly resolves ESM and types.", - ], - }, - { - id: "05-tests", - title: "Testing and coverage lift", - tasks: [ - "Port unit tests to Bun and update for async API.", - "Add type tests to validate public TypeScript types.", - "Add Playwright browser tests that call the ESM bundle.", - "Add a test:all script to run unit, type, and browser checks.", - ], - exitCriteria: [ - "CI can run tests in Node and browser contexts.", - "Async API is fully exercised by tests.", - ], - }, - { - id: "06-tooling", - title: "Tooling cleanup and formatting", - tasks: [ - "Remove Grunt, Makefile, Travis, and JSHint configs.", - "Add Prettier config and a pre-commit format hook.", - "Update .gitignore for modern tooling artifacts.", - ], - exitCriteria: [ - "Repository has a single formatting path and no legacy build files.", - ], - }, - { - id: "07-docs", - title: "Docs, changelog, and migration guide", - tasks: [ - "Rewrite README with ESM + async examples and CLI usage.", - "Add a v3 changelog entry with breaking changes.", - "Add a migration section mapping old sync usage to async usage.", - ], - exitCriteria: ["Docs explain async usage and environment requirements."], - }, - { - id: "08-ci", - title: "CI workflows", - tasks: [ - "Add GitHub Actions workflow for unit + type tests.", - "Add browser workflow for Playwright tests.", - "Add a local CI helper script similar to act usage.", - ], - exitCriteria: ["CI passes on PRs with Node 20+ and Bun."], - }, - { - id: "09-release", - title: "Release readiness", - tasks: [ - "Validate dist outputs for Node and browser usage.", - "Confirm CLI behavior with async generation.", - "Update release process to build before publish.", - ], - exitCriteria: [ - "Release checklist validated and publish script in place.", - ], - }, - ], - bestPractices: [ - "Prefer ESM with explicit exports and sideEffects false for tree shaking.", - "Keep a strict TypeScript configuration and type tests for public APIs.", - "Use async WebCrypto-backed randomness and avoid Math.random.", - "Run unit, type, and browser tests in CI.", - "Document breaking changes and provide migration notes.", - "Keep formatting automated via pre-commit hooks.", - "Ship dist outputs and declarations as part of release artifacts.", - ], - risks: [ - "Async API is a breaking change for sync callers and CLI usage.", - "Browser and Node crypto availability can vary in older environments.", - "Bundler and ESM changes may require updates in consumer apps.", - ], -}; - -export default modernizeAsWeDidForEventifyPlan; diff --git a/package.json b/package.json index 7e647c9..992c64b 100644 --- a/package.json +++ b/package.json @@ -55,14 +55,10 @@ "build:all": "bun run build && bun run build:cli && bun run build:types", "bench": "bun run build && bun run bench/bench.mjs", "bench:constraints": "bun run bench/constraints.mjs", - "bench:patterns": "bun run bench/patterns.mjs", - "bench:structures": "bun run bench/structures.mjs", "bench:node": "bun run build && node bench/bench.mjs", "bench:node:constraints": "node bench/constraints.mjs", - "bench:node:patterns": "node bench/patterns.mjs", - "bench:node:structures": "node bench/structures.mjs", "bench:browser": "bun run build && bunx playwright test -c bench/playwright.config.mjs", - "bench:all": "bun run bench && bun run bench:constraints && bun run bench:patterns && bun run bench:structures && bun run bench:node && bun run bench:node:constraints && bun run bench:node:patterns && bun run bench:node:structures && bun run bench:browser", + "bench:all": "bun run bench && bun run bench:constraints && bun run bench:node && bun run bench:node:constraints && bun run bench:browser", "format": "bunx prettier --write .", "format:check": "bunx prettier --check .", "typecheck:tests": "bun x tsc -p tests/tsconfig.types.json", diff --git a/src/cli.ts b/src/cli.ts index d58be89..19901d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,169 +1,91 @@ -type ArgValue = string | boolean | string[] | undefined; +import { parseArgs } from "node:util"; -type ParsedArgs = { - _: string[]; - [key: string]: ArgValue; -}; +const DEFAULT_LENGTH = 16; +const DEFAULT_MEMORABLE_LENGTH = 20; +const DEFAULT_WORDS = 3; -const parseArgs = (args: string[]): ParsedArgs => { - const parsed: ParsedArgs = { _: [] }; - const set = (key: string, value: ArgValue = true) => { - parsed[key] = value; - }; +const showHelp = () => { + console.log("Generates a secure password\r\n"); + console.log("Options:"); + console.log( + ` -l, --length : Password length [default: ${DEFAULT_LENGTH}, or ${DEFAULT_MEMORABLE_LENGTH} with --memorable]`, + ); + console.log(" -m, --memorable: Generates a memorable password"); + console.log( + " -c, --non-memorable: Generates a non memorable password [default]", + ); + console.log( + " -p, --pattern : Pattern to match for the generated password", + ); + console.log( + " -i, --ignore-security-recommendations: Ignore security recommendations", + ); + console.log( + ` -s, -sN, --words : Generate N memorable words (3-7 letters) separated by spaces [default: ${DEFAULT_WORDS}]`, + ); + console.log(" -h, --help: Displays this help"); +}; - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (arg === undefined) { - continue; - } - if (arg === "--") { - parsed._.push(...args.slice(i + 1)); - break; - } - if (arg.startsWith("--")) { - const [rawKey, rawValue] = arg.slice(2).split("="); - if (!rawKey) { - continue; - } - if (rawValue !== undefined) { - set(rawKey, rawValue); - continue; - } - const next = args[i + 1]; - if (next && !next.startsWith("-")) { - set(rawKey, next); +// Expand -s and -sN into --words before parseArgs +const expandWordsArg = (argv: string[]): string[] => { + const result: string[] = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]!; + const match = arg.match(/^-s(\d+)$/); + if (match) { + result.push("--words", match[1]!); + } else if (arg === "-s") { + const next = argv[i + 1]; + if (next !== undefined && /^\d+$/.test(next)) { + result.push("--words", next); i += 1; } else { - set(rawKey, true); - } - continue; - } - if (arg.startsWith("-")) { - const [rawKey, rawValue] = arg.slice(1).split("="); - if (!rawKey) { - continue; + result.push("--words", String(DEFAULT_WORDS)); } - if (rawValue !== undefined) { - set(rawKey, rawValue); - continue; - } - const next = args[i + 1]; - if (next && !next.startsWith("-")) { - set(rawKey, next); - i += 1; - } else { - set(rawKey, true); - } - continue; - } - parsed._.push(arg); - } - - return parsed; -}; - -const pickValue = (args: ParsedArgs, keys: string[]): ArgValue => { - for (const key of keys) { - if (args[key] !== undefined) { - return args[key]; + } else { + result.push(arg); } } - return undefined; -}; - -const asBoolean = (value: ArgValue): boolean => { - if (value === undefined) return false; - if (value === true) return true; - if (Array.isArray(value)) return value.length > 0; - if (typeof value === "string") { - const normalized = value.toLowerCase(); - return normalized !== "false" && normalized !== "0"; - } - return Boolean(value); -}; - -const asString = (value: ArgValue): string | undefined => { - if (value === undefined) return undefined; - if (Array.isArray(value)) return value[0]; - return typeof value === "string" ? value : String(value); -}; - -const puts = console.log; - -const DEFAULT_LENGTH = 16; -const DEFAULT_MEMORABLE_LENGTH = 20; -const DEFAULT_WORDS = 3; - -const options = [ - { - flags: "-l, --length ", - description: `Password length [default: ${DEFAULT_LENGTH}, or ${DEFAULT_MEMORABLE_LENGTH} with --memorable]`, - }, - { flags: "-m, --memorable", description: "Generates a memorable password" }, - { - flags: "-c, --non-memorable", - description: "Generates a non memorable password [default]", - }, - { - flags: "-p, --pattern ", - description: "Pattern to match for the generated password", - }, - { - flags: "-i, --ignore-security-recommendations", - description: "Ignore security recommendations", - }, - { - flags: "-s, -sN, --words ", - description: - "Generate N memorable words (3-7 letters) separated by spaces [default: 3]", - }, - { flags: "-h, --help", description: "Displays this help" }, -]; - -const showHelp = () => { - puts("Generates a secure password\r\n"); - puts("Options:"); - for (const option of options) { - puts(` ${option.flags}: ${option.description}`); - } + return result; }; export const runCli = async (argv = process.argv.slice(2)) => { - const parsed = parseArgs(argv); - const help = asBoolean(pickValue(parsed, ["h", "help"])); - if (help) { + const { values } = parseArgs({ + args: expandWordsArg(argv), + options: { + length: { type: "string", short: "l" }, + memorable: { type: "boolean", short: "m" }, + "non-memorable": { type: "boolean", short: "c" }, + pattern: { type: "string", short: "p" }, + "ignore-security-recommendations": { type: "boolean", short: "i" }, + words: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + strict: false, + allowPositionals: true, + }); + + if (values.help) { showHelp(); return; } const { generatePasswordWithOptions } = await import("./index.js"); - const patternRaw = asString(pickValue(parsed, ["p", "pattern"])); - const suffixedWordsKey = Object.keys(parsed).find((key) => - /^s\d+$/.test(key), - ); - const wordsRaw = pickValue(parsed, ["s", "words", "phrase", "passphrase"]); - let words: number | undefined; - if (suffixedWordsKey) { - words = Number(suffixedWordsKey.slice(1)); - } else if (wordsRaw !== undefined) { - words = wordsRaw === true ? DEFAULT_WORDS : Number(wordsRaw); - } - const hasMemorable = pickValue(parsed, ["m", "memorable"]) !== undefined; - const hasNonMemorable = - pickValue(parsed, ["c", "non-memorable", "nonmemorable"]) !== undefined; - let memorable = hasMemorable ? true : false; - if (hasNonMemorable) { - memorable = false; - } + let memorable = values.memorable === true; + if (values["non-memorable"]) memorable = false; - const pattern = patternRaw ? new RegExp(patternRaw) : undefined; - if (pattern) { - memorable = false; - } + const patternRaw = values.pattern; + const pattern = + typeof patternRaw === "string" ? new RegExp(patternRaw) : undefined; + if (pattern) memorable = false; + + const words = + values.words !== undefined ? Number(values.words) : undefined; - const lengthRaw = asString(pickValue(parsed, ["l", "length"])); - const lengthValue = lengthRaw !== undefined ? Number(lengthRaw) : undefined; + const lengthRaw = values.length; + const lengthValue = + typeof lengthRaw === "string" ? Number(lengthRaw) : undefined; const length = lengthValue !== undefined ? lengthValue @@ -172,13 +94,9 @@ export const runCli = async (argv = process.argv.slice(2)) => { : memorable ? DEFAULT_MEMORABLE_LENGTH : DEFAULT_LENGTH; - const ignoreSecurityRecommendations = asBoolean( - pickValue(parsed, [ - "i", - "ignore-security-recommendations", - "ignoreSecurityRecommendations", - ]), - ); + + const ignoreSecurityRecommendations = + values["ignore-security-recommendations"] === true; const options: { length?: number; @@ -191,17 +109,10 @@ export const runCli = async (argv = process.argv.slice(2)) => { ignoreSecurityRecommendations, }; - if (pattern) { - options.pattern = pattern; - } - if (typeof length === "number" && Number.isFinite(length)) { + if (pattern) options.pattern = pattern; + if (typeof length === "number" && Number.isFinite(length)) options.length = length; - } - if (words !== undefined) { - options.words = words; - } - - const pass = await generatePasswordWithOptions(options); + if (words !== undefined) options.words = words; - puts(pass); + console.log(await generatePasswordWithOptions(options)); }; diff --git a/src/index.ts b/src/index.ts index 5c05afc..b1b0f18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,6 @@ export type GenerateOptions = { const VOWELS = "aeiou"; const CONSONANTS = "bcdfghjklmnpqrstvwxyz"; -const VOWEL = new RegExp(`[${VOWELS}]$`, "i"); const CONSONANT = new RegExp(`[${CONSONANTS}]$`, "i"); const DEFAULT_PATTERN = /\w/; const DEFAULT_LENGTH = 12; @@ -25,95 +24,47 @@ const MIN_WORD_LENGTH = 3; const MAX_WORD_LENGTH = 7; const textEncoder = new TextEncoder(); -const normalizeOptions = (options: GenerateOptions | undefined) => { - const lengthRaw = options?.length; - const memorableRaw = options?.memorable; - const patternRaw = options?.pattern; - const prefixRaw = options?.prefix; - const ignoreSecurityRecommendationsRaw = - options?.ignoreSecurityRecommendations; - const entropyRaw = options?.entropy; - const wordsRaw = options?.words; - - const length = lengthRaw ?? DEFAULT_LENGTH; - const memorable = memorableRaw ?? false; - const pattern = patternRaw ?? DEFAULT_PATTERN; - const prefix = prefixRaw ?? ""; - - return { - length, - memorable, - pattern, - prefix: String(prefix), - ignoreSecurityRecommendations: ignoreSecurityRecommendationsRaw ?? false, - entropy: entropyRaw, - words: wordsRaw, - }; -}; - -const ensureSafeInteger = (value: number, name: string) => { - if (!Number.isSafeInteger(value)) { - throw new RangeError(`${name} must be a safe integer`); - } -}; - -const ensureRegExp = (value: unknown) => { - if (!(value instanceof RegExp)) { - throw new TypeError("pattern must be a RegExp"); - } -}; - -const normalizeEntropy = (entropy: Uint8Array | string | undefined) => { - if (entropy === undefined) { - return undefined; - } - if (typeof entropy === "string") { - return textEncoder.encode(entropy); +// Minimum memorable characters needed to reach MIN_ENTROPY_BITS. +const MIN_MEMORABLE_LENGTH = (() => { + let bits = 0; + let len = 0; + let vowel = false; + while (bits < MIN_ENTROPY_BITS) { + bits += Math.log2(vowel ? VOWELS.length : CONSONANTS.length); + vowel = !vowel; + len += 1; } - if (entropy instanceof Uint8Array) { - return entropy; - } - throw new TypeError("entropy must be a Uint8Array or string"); -}; - -const matchesPattern = (pattern: RegExp, char: string) => { - pattern.lastIndex = 0; - return pattern.test(char); -}; + return len; +})(); const buildValidChars = (pattern: RegExp) => { - const validChars: string[] = []; + const chars: string[] = []; for (let i = 33; i <= 126; i += 1) { const char = String.fromCharCode(i); - if (matchesPattern(pattern, char)) { - validChars.push(char); + if (pattern.test(char)) { + chars.push(char); } } - if (validChars.length === 0) { + if (chars.length === 0) { throw new Error( `Could not find characters that match the password pattern ${pattern}. Patterns must match individual characters, not the password as a whole.`, ); } - return validChars; + return chars; }; const estimatePatternEntropy = ( alphabetSize: number, length: number, - prefix: string, + prefixLength: number, ) => { - const effectiveLength = Math.max(0, length - prefix.length); const bitsPerChar = alphabetSize > 1 ? Math.log2(alphabetSize) : 0; - const entropyBits = bitsPerChar * effectiveLength; - const recommendedLength = - bitsPerChar > 0 - ? prefix.length + Math.ceil(MIN_ENTROPY_BITS / bitsPerChar) - : null; - return { - effectiveLength, - entropyBits, - recommendedLength, + entropyBits: bitsPerChar * Math.max(0, length - prefixLength), + recommendedLength: + bitsPerChar > 0 + ? prefixLength + Math.ceil(MIN_ENTROPY_BITS / bitsPerChar) + : null, }; }; @@ -121,50 +72,36 @@ const estimateMemorableEntropy = (length: number, prefix: string) => { const effectiveLength = Math.max(0, length - prefix.length); let entropyBits = 0; let expectsVowel = CONSONANT.test(prefix); - for (let i = 0; i < effectiveLength; i += 1) { - const alphabetSize = expectsVowel ? VOWELS.length : CONSONANTS.length; - entropyBits += Math.log2(alphabetSize); + entropyBits += Math.log2(expectsVowel ? VOWELS.length : CONSONANTS.length); expectsVowel = !expectsVowel; } let recommendedLength = prefix.length; - let recommendationBits = 0; + let bits = 0; expectsVowel = CONSONANT.test(prefix); - while (recommendationBits < MIN_ENTROPY_BITS) { - const alphabetSize = expectsVowel ? VOWELS.length : CONSONANTS.length; - recommendationBits += Math.log2(alphabetSize); + while (bits < MIN_ENTROPY_BITS) { + bits += Math.log2(expectsVowel ? VOWELS.length : CONSONANTS.length); expectsVowel = !expectsVowel; recommendedLength += 1; } - return { - effectiveLength, - entropyBits, - recommendedLength, - }; + return { entropyBits, recommendedLength }; }; -const MEMORABLE_RECOMMENDED_LENGTH = estimateMemorableEntropy( - 0, - "", -).recommendedLength; - -const buildMemorableWord = async ( +const buildMemorable = async ( length: number, + startsWithVowel: boolean, nextInt: (min: number, max: number) => Promise, ) => { - let expectsVowel = false; - let word = ""; - + let expectsVowel = startsWithVowel; + let result = ""; for (let i = 0; i < length; i += 1) { const alphabet = expectsVowel ? VOWELS : CONSONANTS; - const index = await nextInt(0, alphabet.length); - word += alphabet[index] ?? ""; + result += alphabet[await nextInt(0, alphabet.length)]; expectsVowel = !expectsVowel; } - - return word; + return result; }; const buildWordLengths = async ( @@ -174,35 +111,25 @@ const buildWordLengths = async ( ) => { const lengths: number[] = []; let total = 0; - for (let i = 0; i < count; i += 1) { - const length = await nextInt(MIN_WORD_LENGTH, MAX_WORD_LENGTH + 1); - lengths.push(length); - total += length; + const len = await nextInt(MIN_WORD_LENGTH, MAX_WORD_LENGTH + 1); + lengths.push(len); + total += len; } if (targetLength !== undefined && total < targetLength) { - const adjustable = Array.from({ length: count }, (_, idx) => idx); + const adjustable: number[] = []; + for (let i = 0; i < count; i += 1) { + if (lengths[i]! < MAX_WORD_LENGTH) adjustable.push(i); + } let remaining = targetLength - total; - while (remaining > 0 && adjustable.length > 0) { - const pickIndex = await nextInt(0, adjustable.length); - const wordIndex = adjustable[pickIndex]; - if (wordIndex === undefined) { - break; - } - const currentLength = lengths[wordIndex]; - if (currentLength === undefined) { - break; - } - if (currentLength < MAX_WORD_LENGTH) { - lengths[wordIndex] = currentLength + 1; - remaining -= 1; - if (lengths[wordIndex] === MAX_WORD_LENGTH) { - adjustable.splice(pickIndex, 1); - } - } else { - adjustable.splice(pickIndex, 1); + const pick = await nextInt(0, adjustable.length); + const wordIdx = adjustable[pick]!; + lengths[wordIdx] = lengths[wordIdx]! + 1; + remaining -= 1; + if (lengths[wordIdx]! >= MAX_WORD_LENGTH) { + adjustable.splice(pick, 1); } } } @@ -210,57 +137,45 @@ const buildWordLengths = async ( return lengths; }; -const securityRecommendation = (reason: string, recommendation: string) => { - throw new Error( - `Security recommendation: ${reason}. ${recommendation} To override, pass { ignoreSecurityRecommendations: true }.`, - ); -}; - export const generatePassword = async ( length?: number, memorable?: boolean, pattern?: RegExp, prefix?: string, ): Promise => { - const options: GenerateOptions = {}; - if (length !== undefined) { - options.length = length; - } - if (memorable !== undefined) { - options.memorable = memorable; - } - if (pattern !== undefined) { - options.pattern = pattern; - } - if (prefix !== undefined) { - options.prefix = prefix; - } - - return generatePasswordWithOptions( - Object.keys(options).length ? options : undefined, - ); + const opts: GenerateOptions = {}; + if (length !== undefined) opts.length = length; + if (memorable !== undefined) opts.memorable = memorable; + if (pattern !== undefined) opts.pattern = pattern; + if (prefix !== undefined) opts.prefix = prefix; + return generatePasswordWithOptions(opts); }; export const generatePasswordWithOptions = async ( options?: GenerateOptions, ): Promise => { - const { - length, - memorable, - pattern, - prefix, - ignoreSecurityRecommendations, - entropy, - words, - } = normalizeOptions(options); - - ensureSafeInteger(length, "length"); + const length = options?.length ?? DEFAULT_LENGTH; + const memorable = options?.memorable ?? false; + const pattern = options?.pattern ?? DEFAULT_PATTERN; + const prefix = String(options?.prefix ?? ""); + const ignoreSecurityRecommendations = + options?.ignoreSecurityRecommendations ?? false; + const entropy = options?.entropy; + const words = options?.words; + + if (!Number.isSafeInteger(length)) { + throw new RangeError("length must be a safe integer"); + } if (length < 0) { throw new RangeError("length must be a non-negative integer"); } - ensureRegExp(pattern); + if (!(pattern instanceof RegExp)) { + throw new TypeError("pattern must be a RegExp"); + } if (words !== undefined) { - ensureSafeInteger(words, "words"); + if (!Number.isSafeInteger(words)) { + throw new RangeError("words must be a safe integer"); + } if (words <= 0) { throw new RangeError("words must be a positive integer"); } @@ -269,92 +184,86 @@ export const generatePasswordWithOptions = async ( throw new Error("prefix is not supported when words are enabled"); } - const entropyBytes = normalizeEntropy(entropy); + let entropyBytes: Uint8Array | undefined; + if (entropy !== undefined) { + if (typeof entropy === "string") { + entropyBytes = textEncoder.encode(entropy); + } else if (entropy instanceof Uint8Array) { + entropyBytes = entropy; + } else { + throw new TypeError("entropy must be a Uint8Array or string"); + } + } + const randomBytes = entropyBytes ? await createDeterministicRandomBytes(entropyBytes) : getRandomBytes; const nextInt = (min: number, max: number) => randomInt(min, max, randomBytes); + // Passphrase mode if (words !== undefined) { if ( !ignoreSecurityRecommendations && - words * MAX_WORD_LENGTH < MEMORABLE_RECOMMENDED_LENGTH + words * MAX_WORD_LENGTH < MIN_MEMORABLE_LENGTH ) { const recommendedWords = Math.ceil( - MEMORABLE_RECOMMENDED_LENGTH / MAX_WORD_LENGTH, + MIN_MEMORABLE_LENGTH / MAX_WORD_LENGTH, ); - securityRecommendation( - `word count ${words} cannot reach ${MIN_ENTROPY_BITS} bits with ${MIN_WORD_LENGTH}-${MAX_WORD_LENGTH} letter words`, - `Use words >= ${recommendedWords}.`, + throw new Error( + `Security recommendation: word count ${words} cannot reach ${MIN_ENTROPY_BITS} bits with ${MIN_WORD_LENGTH}-${MAX_WORD_LENGTH} letter words. Use words >= ${recommendedWords}. To override, pass { ignoreSecurityRecommendations: true }.`, ); } const targetLength = ignoreSecurityRecommendations ? undefined - : MEMORABLE_RECOMMENDED_LENGTH; + : MIN_MEMORABLE_LENGTH; const lengths = await buildWordLengths(words, nextInt, targetLength); const wordsList: string[] = []; - for (const wordLength of lengths) { - wordsList.push(await buildMemorableWord(wordLength, nextInt)); + wordsList.push(await buildMemorable(wordLength, false, nextInt)); } - return wordsList.join(" "); } - let currentPattern = pattern; - let result = prefix; - let validChars: string[] | null = null; - - if (!memorable) { - validChars = buildValidChars(pattern); - } - - if (!ignoreSecurityRecommendations) { - if (memorable) { + // Memorable mode: direct alphabet indexing + if (memorable) { + if (!ignoreSecurityRecommendations) { const estimate = estimateMemorableEntropy(length, prefix); if (estimate.entropyBits < MIN_ENTROPY_BITS) { - securityRecommendation( - `estimated entropy ${estimate.entropyBits.toFixed(1)} bits is below ${MIN_ENTROPY_BITS} bits`, - `Use length >= ${estimate.recommendedLength} or set memorable: false.`, - ); - } - } else if (validChars) { - const estimate = estimatePatternEntropy( - validChars.length, - length, - prefix, - ); - if (estimate.entropyBits < MIN_ENTROPY_BITS) { - const recommendation = - estimate.recommendedLength === null - ? "Use a broader pattern to increase the character set." - : `Use length >= ${estimate.recommendedLength} or broaden the pattern.`; - securityRecommendation( - `estimated entropy ${estimate.entropyBits.toFixed(1)} bits is below ${MIN_ENTROPY_BITS} bits`, - recommendation, + throw new Error( + `Security recommendation: estimated entropy ${estimate.entropyBits.toFixed(1)} bits is below ${MIN_ENTROPY_BITS} bits. Use length >= ${estimate.recommendedLength} or set memorable: false. To override, pass { ignoreSecurityRecommendations: true }.`, ); } } + const charCount = Math.max(0, length - prefix.length); + return ( + prefix + (await buildMemorable(charCount, CONSONANT.test(prefix), nextInt)) + ); } - while (result.length < length) { - let char = ""; - - if (memorable) { - currentPattern = result.match(CONSONANT) ? VOWEL : CONSONANT; - const code = await nextInt(33, 126); - char = String.fromCharCode(code).toLowerCase(); - } else if (validChars) { - const index = await nextInt(0, validChars.length); - char = validChars[index] ?? ""; - } - - if (char.match(currentPattern)) { - result += char; + // Pattern mode + const validChars = buildValidChars(pattern); + if (!ignoreSecurityRecommendations) { + const estimate = estimatePatternEntropy( + validChars.length, + length, + prefix.length, + ); + if (estimate.entropyBits < MIN_ENTROPY_BITS) { + const recommendation = + estimate.recommendedLength === null + ? "Use a broader pattern to increase the character set." + : `Use length >= ${estimate.recommendedLength} or broaden the pattern.`; + throw new Error( + `Security recommendation: estimated entropy ${estimate.entropyBits.toFixed(1)} bits is below ${MIN_ENTROPY_BITS} bits. ${recommendation} To override, pass { ignoreSecurityRecommendations: true }.`, + ); } } + let result = prefix; + while (result.length < length) { + result += validChars[await nextInt(0, validChars.length)]; + } return result; }; diff --git a/src/random.ts b/src/random.ts index 4570339..371a5d9 100644 --- a/src/random.ts +++ b/src/random.ts @@ -42,7 +42,6 @@ export const createDeterministicRandomBytes = async ( let counter = 0n; const counterBytes = new Uint8Array(8); const counterView = new DataView(counterBytes.buffer); - counterView.setBigUint64(0, counter, false); const deterministicRandomBytes: RandomBytes = async (length: number) => { if (!Number.isFinite(length) || length < 0) { throw new RangeError("length must be a non-negative finite number");