From b3b3578018355d487b5bebdd7081716121fb3c7c Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sun, 5 Oct 2025 22:00:13 +0900 Subject: [PATCH 1/8] chore: initialize npm tooling --- .env.example | 3 + .gitignore | 140 ++------------------ biome.json | 25 ++++ package-lock.json | 328 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 26 ++++ tsconfig.json | 22 ++++ 6 files changed, 415 insertions(+), 129 deletions(-) create mode 100644 .env.example create mode 100644 biome.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a64d413 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +SUPABASE_URL= +SUPABASE_PUBLISHABLE_KEY= +# LISTEE_CLI_KEYCHAIN_SERVICE=listee-cli diff --git a/.gitignore b/.gitignore index 9a5aced..b5ad2f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,139 +1,21 @@ +# Dependencies +node_modules/ +bun.lock + +# Build outputs +/dist/ +tsconfig.tsbuildinfo + # Logs -logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files +# Environment files .env .env.* !.env.example -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Sveltekit cache directory -.svelte-kit/ - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Firebase cache directory -.firebase/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v3 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Vite logs files -vite.config.js.timestamp-* -vite.config.ts.timestamp-* +# Editor artifacts +.DS_Store diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d5c1ec1 --- /dev/null +++ b/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b5b8aa8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,328 @@ +{ + "name": "listee-cli", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "listee-cli", + "version": "0.0.1", + "dependencies": { + "@napi-rs/keyring": "^1.2.0", + "commander": "^12.1.0", + "dotenv": "^16.4.5" + }, + "bin": { + "listee": "dist/index.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.4", + "@types/node": "^20.14.2", + "typescript": "^5.9.2" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.2.5", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.2.5", + "@biomejs/cli-darwin-x64": "2.2.5", + "@biomejs/cli-linux-arm64": "2.2.5", + "@biomejs/cli-linux-arm64-musl": "2.2.5", + "@biomejs/cli-linux-x64": "2.2.5", + "@biomejs/cli-linux-x64-musl": "2.2.5", + "@biomejs/cli-win32-arm64": "2.2.5", + "@biomejs/cli-win32-x64": "2.2.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.2.5", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", + "integrity": "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.2.0", + "@napi-rs/keyring-darwin-x64": "1.2.0", + "@napi-rs/keyring-freebsd-x64": "1.2.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", + "@napi-rs/keyring-linux-arm64-musl": "1.2.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-gnu": "1.2.0", + "@napi-rs/keyring-linux-x64-musl": "1.2.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", + "@napi-rs/keyring-win32-x64-msvc": "1.2.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.2.0.tgz", + "integrity": "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.2.0.tgz", + "integrity": "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.2.0.tgz", + "integrity": "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/node": { + "version": "20.19.19", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..837d590 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "listee-cli", + "version": "0.0.1", + "type": "module", + "bin": { + "listee": "./dist/index.js" + }, + "scripts": { + "build": "tsc -b", + "dev": "npm run watch", + "watch": "tsc --watch --preserveWatchOutput", + "start": "node dist/index.js", + "lint": "npx biome ci .", + "test": "npm run build" + }, + "dependencies": { + "@napi-rs/keyring": "^1.2.0", + "commander": "^12.1.0", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.4", + "@types/node": "^20.14.2", + "typescript": "^5.9.2" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1235857 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} From c7e603f7397eb8540de3dd9cd5b8141100a39c72 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sun, 5 Oct 2025 22:01:18 +0900 Subject: [PATCH 2/8] feat: add supabase auth commands --- src/commands/auth.ts | 223 +++++++++++++++++++++++++ src/index.ts | 29 ++++ src/services/authService.ts | 318 ++++++++++++++++++++++++++++++++++++ src/types/auth.ts | 35 ++++ 4 files changed, 605 insertions(+) create mode 100644 src/commands/auth.ts create mode 100644 src/index.ts create mode 100644 src/services/authService.ts create mode 100644 src/types/auth.ts diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..374b67d --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,223 @@ +import type { Buffer } from "node:buffer"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline"; +import type { Command } from "commander"; +import { + ensureSupabaseConfig, + login, + logout, + signup, + status, +} from "../services/authService.js"; +import type { + AuthStatus, + EmailOption, + RawModeCapableInput, +} from "../types/auth.js"; + +const ensureNonEmpty = (value: string, label: string): string => { + if (value.trim().length === 0) { + throw new Error(`${label} must not be empty.`); + } + + return value.trim(); +}; + +const ensureEmail = (value: unknown): string => { + if (typeof value !== "string") { + throw new Error("Email is required."); + } + + return ensureNonEmpty(value, "Email"); +}; + +const handleError = (error: unknown): void => { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error("Unknown error occurred."); + } + process.exitCode = 1; +}; + +const isRawModeCapable = ( + stream: typeof input, +): stream is RawModeCapableInput => { + return typeof stream.setRawMode === "function"; +}; + +const promptHiddenInput = (promptText: string): Promise => { + return new Promise((resolve, reject) => { + const rl = createInterface({ input, output, terminal: true }); + + if (!input.isTTY || !output.isTTY) { + rl.question(promptText, (answer) => { + rl.close(); + resolve(ensureNonEmpty(answer, "Password")); + }); + return; + } + + const collected: string[] = []; + + const cleanup = (): void => { + input.off("data", handleData); + rl.close(); + if (isRawModeCapable(input)) { + input.setRawMode(false); + } + input.pause(); + }; + + const cancel = (): void => { + cleanup(); + output.write("\n"); + reject(new Error("Input cancelled by user.")); + }; + + const handleData = (chunk: Buffer): void => { + const text = chunk.toString("utf8"); + for (const char of Array.from(text)) { + if (char === "\u0003" || char === "\u0004") { + cancel(); + return; + } + if (char === "\r" || char === "\n") { + cleanup(); + output.write("\n"); + resolve(ensureNonEmpty(collected.join(""), "Password")); + return; + } + if (char === "\u007f") { + if (collected.length > 0) { + collected.pop(); + output.clearLine(0); + output.cursorTo(0); + output.write(`${promptText}${"*".repeat(collected.length)}`); + } + continue; + } + + collected.push(char); + output.clearLine(0); + output.cursorTo(0); + output.write(`${promptText}${"*".repeat(collected.length)}`); + } + }; + + if (isRawModeCapable(input)) { + input.setRawMode(true); + } + + input.resume(); + input.on("data", handleData); + rl.on("SIGINT", cancel); + + output.write(promptText); + }); +}; + +const execute = (task: (...args: T) => Promise) => { + return async (...args: T): Promise => { + try { + await task(...args); + } catch (error) { + handleError(error); + } + }; +}; + +const printStatus = (result: AuthStatus): void => { + if (result.state === "logged_out") { + console.log("Not logged in. Run `listee auth login` to authenticate."); + return; + } + + if (result.accounts.length === 0) { + console.log("No accounts found in keychain."); + return; + } + + console.log("Logged-in accounts:"); + for (const account of result.accounts) { + console.log(` • ${account}`); + } +}; + +const loginAction = async (options: EmailOption): Promise => { + ensureSupabaseConfig(); + const email = ensureEmail(options.email); + const password = await promptHiddenInput("Password: "); + const _token = await login(email, password); + console.log("✅ Logged in."); +}; + +const signupAction = async (options: EmailOption): Promise => { + ensureSupabaseConfig(); + const email = ensureEmail(options.email); + const password = await promptHiddenInput("Password: "); + await signup(email, password); + console.log("📩 Confirmation email sent."); +}; + +const logoutAction = async (): Promise => { + const removed = await logout(); + if (removed === 0) { + console.log("No stored session tokens were found."); + } else { + console.log(`Logged out of ${removed} account(s).`); + } +}; + +const statusAction = async (): Promise => { + const currentStatus = await status(); + printStatus(currentStatus); +}; + +export const registerAuthCommand = (program: Command): void => { + const auth = program + .command("auth") + .description("Manage Supabase authentication for Listee."); + + auth + .command("signup") + .description( + "Sign up for a new Listee account via Supabase email/password.", + ) + .requiredOption("--email ", "Email address to register") + .action( + execute(async (options: EmailOption) => { + await signupAction(options); + }), + ); + + auth + .command("login") + .description( + "Authenticate with Supabase using email/password and store refresh token in keychain.", + ) + .requiredOption("--email ", "Email address to log in") + .action( + execute(async (options: EmailOption) => { + await loginAction(options); + }), + ); + + auth + .command("status") + .description("Show stored authentication status from the keychain.") + .action( + execute(async () => { + await statusAction(); + }), + ); + + auth + .command("logout") + .description("Remove stored refresh tokens from the keychain.") + .action( + execute(async () => { + await logoutAction(); + }), + ); +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b080f1f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,29 @@ +import "dotenv/config"; +import { Command } from "commander"; +import { registerAuthCommand } from "./commands/auth.js"; + +const program = new Command(); + +program + .name("listee") + .description( + "Official CLI for Listee: manage authentication, categories, and tasks.", + ) + .version("0.0.1"); + +registerAuthCommand(program); + +const main = async (): Promise => { + try { + await program.parseAsync(process.argv); + } catch (error) { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error("Unknown error occurred."); + } + process.exitCode = 1; + } +}; + +void main(); diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..405d1c0 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,318 @@ +import { AsyncEntry, findCredentials } from "@napi-rs/keyring"; +import type { + AccessTokenResult, + AuthStatus, + StoredCredential, + SupabaseErrorPayload, + SupabaseTokenResponse, +} from "../types/auth.js"; + +export type { AccessTokenResult, AuthStatus } from "../types/auth.js"; + +const DEFAULT_SERVICE_NAME = "listee-cli"; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + +const isString = (value: unknown): value is string => { + return typeof value === "string"; +}; + +const isNumber = (value: unknown): value is number => { + return typeof value === "number" && Number.isFinite(value); +}; + +const isSupabaseErrorPayload = ( + value: unknown, +): value is SupabaseErrorPayload => { + if (!isRecord(value)) { + return false; + } + + const possibleFields = [ + "error", + "error_description", + "msg", + "message", + "status", + ]; + return possibleFields.some((field) => field in value); +}; + +const isSupabaseTokenResponse = ( + value: unknown, +): value is SupabaseTokenResponse => { + if (!isRecord(value)) { + return false; + } + + const accessToken = value.access_token; + const refreshToken = value.refresh_token; + const tokenType = value.token_type; + const expiresIn = value.expires_in; + + return ( + isString(accessToken) && + isString(refreshToken) && + isString(tokenType) && + isNumber(expiresIn) + ); +}; + +const toErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + return "Unknown error occurred."; +}; + +const listStoredCredentials = (service: string): StoredCredential[] => { + try { + return findCredentials(service).map((credential) => ({ + account: credential.account, + password: credential.password, + })); + } catch (error) { + throw new Error( + `Failed to access credentials in the system keyring: ${toErrorMessage(error)}`, + ); + } +}; + +const getSupabaseUrl = (): URL => { + const rawUrl = process.env.SUPABASE_URL; + if (rawUrl === undefined || rawUrl.trim().length === 0) { + throw new Error( + "SUPABASE_URL is not set. Please configure the environment variable before continuing.", + ); + } + + return new URL(rawUrl.trim()); +}; + +const getSupabasePublishableKey = (): string => { + const publishableKey = process.env.SUPABASE_PUBLISHABLE_KEY; + if (publishableKey !== undefined && publishableKey.trim().length > 0) { + return publishableKey.trim(); + } + + const legacyAnonKey = process.env.SUPABASE_ANON_KEY; + if (legacyAnonKey !== undefined && legacyAnonKey.trim().length > 0) { + return legacyAnonKey.trim(); + } + + throw new Error( + "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing.", + ); +}; + +export const ensureSupabaseConfig = (): void => { + void getSupabaseUrl(); + void getSupabasePublishableKey(); +}; + +const getKeychainServiceName = (): string => { + const override = process.env.LISTEE_CLI_KEYCHAIN_SERVICE; + if (override !== undefined && override.trim().length > 0) { + return override.trim(); + } + + return DEFAULT_SERVICE_NAME; +}; + +const readJson = async (response: Response): Promise => { + const raw = await response.text(); + if (raw.trim().length === 0) { + return null; + } + + try { + return JSON.parse(raw); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to parse Supabase response: ${error.message}`); + } + throw new Error( + "Failed to parse Supabase response due to an unknown error.", + ); + } +}; + +const formatSupabaseError = (payload: unknown, status: number): string => { + if (isSupabaseErrorPayload(payload)) { + const { error, error_description: description, msg, message } = payload; + const details = [error, description, msg, message] + .filter( + (part) => + part !== undefined && isString(part) && part.trim().length > 0, + ) + .join(": "); + + if (details.length > 0) { + return details; + } + } + + return `Supabase request failed with status ${status}`; +}; + +const buildSupabaseHeaders = (): Record => { + const publishableKey = getSupabasePublishableKey(); + return { + "Content-Type": "application/json", + apikey: publishableKey, + Authorization: `Bearer ${publishableKey}`, + }; +}; + +const storeRefreshToken = async ( + account: string, + token: string, +): Promise => { + const service = getKeychainServiceName(); + const entry = new AsyncEntry(service, account); + await entry.setPassword(token); +}; + +const findStoredCredential = async ( + preferredAccount?: string, +): Promise => { + const service = getKeychainServiceName(); + + if (preferredAccount !== undefined) { + const entry = new AsyncEntry(service, preferredAccount); + const password = await entry.getPassword(); + if (password === undefined || password === null) { + return null; + } + return { account: preferredAccount, password }; + } + + const credentials = listStoredCredentials(service); + if (credentials.length === 0) { + return null; + } + + return credentials[0]; +}; + +const deleteAllStoredCredentials = async (): Promise => { + const service = getKeychainServiceName(); + const credentials = listStoredCredentials(service); + + let removed = 0; + for (const credential of credentials) { + const entry = new AsyncEntry(service, credential.account); + const deleted = await entry.deleteCredential(); + if (deleted) { + removed += 1; + } + } + + return removed; +}; + +const requestSupabase = async ( + path: string, + body: Record, +): Promise => { + const url = new URL(path, getSupabaseUrl()); + return fetch(url, { + method: "POST", + headers: buildSupabaseHeaders(), + body: JSON.stringify(body), + }); +}; + +export const signup = async ( + email: string, + password: string, +): Promise => { + const response = await requestSupabase("auth/v1/signup", { email, password }); + + if (!response.ok) { + const payload = await readJson(response); + throw new Error(formatSupabaseError(payload, response.status)); + } +}; + +export const login = async ( + email: string, + password: string, +): Promise => { + const response = await requestSupabase("auth/v1/token?grant_type=password", { + email, + password, + }); + + const payload = await readJson(response); + if (!response.ok) { + throw new Error(formatSupabaseError(payload, response.status)); + } + + if (!isSupabaseTokenResponse(payload)) { + throw new Error("Unexpected response from Supabase during login."); + } + + await storeRefreshToken(email, payload.refresh_token); + + return { + accessToken: payload.access_token, + expiresIn: payload.expires_in, + tokenType: payload.token_type, + }; +}; + +export const getAccessToken = async ( + email?: string, +): Promise => { + const credential = await findStoredCredential(email); + if (credential === null) { + throw new Error("No stored refresh token found. Please log in first."); + } + + const response = await requestSupabase( + "auth/v1/token?grant_type=refresh_token", + { + refresh_token: credential.password, + }, + ); + + const payload = await readJson(response); + if (!response.ok) { + throw new Error(formatSupabaseError(payload, response.status)); + } + + if (!isSupabaseTokenResponse(payload)) { + throw new Error( + "Unexpected response from Supabase while refreshing the session.", + ); + } + + await storeRefreshToken(credential.account, payload.refresh_token); + + return { + accessToken: payload.access_token, + expiresIn: payload.expires_in, + tokenType: payload.token_type, + }; +}; + +export const logout = async (): Promise => { + return deleteAllStoredCredentials(); +}; + +export const status = async (): Promise => { + const service = getKeychainServiceName(); + const credentials = listStoredCredentials(service); + + if (credentials.length === 0) { + return { state: "logged_out" }; + } + + return { + state: "logged_in", + accounts: credentials.map((credential) => credential.account), + }; +}; diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..62ed474 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,35 @@ +export type SupabaseTokenResponse = { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; +}; + +export type SupabaseErrorPayload = { + error?: string; + error_description?: string; + msg?: string; + message?: string; + status?: number; +}; + +export type StoredCredential = { + account: string; + password: string; +}; + +export type AccessTokenResult = { + accessToken: string; + expiresIn: number; + tokenType: string; +}; + +export type AuthStatus = + | { state: "logged_out" } + | { state: "logged_in"; accounts: readonly string[] }; + +export type EmailOption = { + email?: string; +}; + +export type RawModeCapableInput = NodeJS.ReadStream & { fd: 0 }; From dc94c35dd448be4176a598d3bd2a70389259561a Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sun, 5 Oct 2025 22:28:38 +0900 Subject: [PATCH 3/8] build: switch to bun tooling --- .gitignore | 1 - biome.json | 3 + bun.lock | 82 ++++++++++++ package-lock.json | 328 ---------------------------------------------- package.json | 11 +- 5 files changed, 91 insertions(+), 334 deletions(-) create mode 100644 bun.lock delete mode 100644 package-lock.json diff --git a/.gitignore b/.gitignore index b5ad2f4..c5fd347 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Dependencies node_modules/ -bun.lock # Build outputs /dist/ diff --git a/biome.json b/biome.json index d5c1ec1..e52728f 100644 --- a/biome.json +++ b/biome.json @@ -21,5 +21,8 @@ "formatter": { "quoteStyle": "double" } + }, + "files": { + "includes": ["src/**"] } } diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..6f8c4a4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,82 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "listee-cli", + "dependencies": { + "@napi-rs/keyring": "^1.2.0", + "commander": "^12.1.0", + "dotenv": "^16.4.5", + }, + "devDependencies": { + "@biomejs/biome": "^2.2.4", + "@types/bun": "^1.2.23", + "@types/node": "^20.14.2", + "typescript": "^5.9.2", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.2.5", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.5", "@biomejs/cli-darwin-x64": "2.2.5", "@biomejs/cli-linux-arm64": "2.2.5", "@biomejs/cli-linux-arm64-musl": "2.2.5", "@biomejs/cli-linux-x64": "2.2.5", "@biomejs/cli-linux-x64-musl": "2.2.5", "@biomejs/cli-win32-arm64": "2.2.5", "@biomejs/cli-win32-x64": "2.2.5" }, "bin": { "biome": "bin/biome" } }, "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-FLIEl73fv0R7dI10EnEiZLw+IMz3mWLnF95ASDI0kbx6DDLJjWxE5JxxBfmG+udz1hIDd3fr5wsuP7nwuTRdAg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Ov2wgAFwqDvQiESnu7b9ufD1faRa+40uwrohgBopeY84El2TnBDoMNXx6iuQdreoFGjwW8vH6k68G21EpNERw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="], + + "@napi-rs/keyring": ["@napi-rs/keyring@1.2.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.2.0", "@napi-rs/keyring-darwin-x64": "1.2.0", "@napi-rs/keyring-freebsd-x64": "1.2.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", "@napi-rs/keyring-linux-arm64-musl": "1.2.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-musl": "1.2.0", "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", "@napi-rs/keyring-win32-x64-msvc": "1.2.0" } }, "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.2.0", "", { "os": "linux", "cpu": "none" }, "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.2.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA=="], + + "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + + "@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="], + + "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], + + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index b5b8aa8..0000000 --- a/package-lock.json +++ /dev/null @@ -1,328 +0,0 @@ -{ - "name": "listee-cli", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "listee-cli", - "version": "0.0.1", - "dependencies": { - "@napi-rs/keyring": "^1.2.0", - "commander": "^12.1.0", - "dotenv": "^16.4.5" - }, - "bin": { - "listee": "dist/index.js" - }, - "devDependencies": { - "@biomejs/biome": "^2.2.4", - "@types/node": "^20.14.2", - "typescript": "^5.9.2" - } - }, - "node_modules/@biomejs/biome": { - "version": "2.2.5", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.2.5", - "@biomejs/cli-darwin-x64": "2.2.5", - "@biomejs/cli-linux-arm64": "2.2.5", - "@biomejs/cli-linux-arm64-musl": "2.2.5", - "@biomejs/cli-linux-x64": "2.2.5", - "@biomejs/cli-linux-x64-musl": "2.2.5", - "@biomejs/cli-win32-arm64": "2.2.5", - "@biomejs/cli-win32-x64": "2.2.5" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.2.5", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@napi-rs/keyring": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", - "integrity": "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/keyring-darwin-arm64": "1.2.0", - "@napi-rs/keyring-darwin-x64": "1.2.0", - "@napi-rs/keyring-freebsd-x64": "1.2.0", - "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", - "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", - "@napi-rs/keyring-linux-arm64-musl": "1.2.0", - "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", - "@napi-rs/keyring-linux-x64-gnu": "1.2.0", - "@napi-rs/keyring-linux-x64-musl": "1.2.0", - "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", - "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", - "@napi-rs/keyring-win32-x64-msvc": "1.2.0" - } - }, - "node_modules/@napi-rs/keyring-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.2.0.tgz", - "integrity": "sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-freebsd-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.2.0.tgz", - "integrity": "sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.2.0.tgz", - "integrity": "sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.2.0.tgz", - "integrity": "sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-arm64-musl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.2.0.tgz", - "integrity": "sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.2.0.tgz", - "integrity": "sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.2.0.tgz", - "integrity": "sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-linux-x64-musl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.2.0.tgz", - "integrity": "sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-arm64-msvc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.2.0.tgz", - "integrity": "sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-ia32-msvc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.2.0.tgz", - "integrity": "sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/keyring-win32-x64-msvc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.2.0.tgz", - "integrity": "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@types/node": { - "version": "20.19.19", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/commander": { - "version": "12.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index 837d590..39f622b 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,12 @@ "listee": "./dist/index.js" }, "scripts": { - "build": "tsc -b", - "dev": "npm run watch", - "watch": "tsc --watch --preserveWatchOutput", + "build": "bun x tsc -b", + "dev": "bun run watch", + "watch": "bun x tsc --watch --preserveWatchOutput", "start": "node dist/index.js", - "lint": "npx biome ci .", - "test": "npm run build" + "lint": "bun x biome ci .", + "test": "bun test" }, "dependencies": { "@napi-rs/keyring": "^1.2.0", @@ -20,6 +20,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.2.4", + "@types/bun": "^1.2.23", "@types/node": "^20.14.2", "typescript": "^5.9.2" } From dbd9a3ec848a2174eaf42cf4ddbd487584124334 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sun, 5 Oct 2025 22:40:04 +0900 Subject: [PATCH 4/8] test: cover supabase config validation --- src/services/authService.test.ts | 57 ++++++++++++++++++++++++++++++++ tsconfig.json | 8 ++++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/services/authService.test.ts diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts new file mode 100644 index 0000000..9bcd501 --- /dev/null +++ b/src/services/authService.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { type AccessTokenResult, ensureSupabaseConfig } from "./authService.js"; + +const ORIGINAL_ENV = { ...process.env }; + +const resetEnv = (): void => { + process.env = { ...ORIGINAL_ENV }; +}; + +beforeEach(resetEnv); +afterEach(resetEnv); + +describe("ensureSupabaseConfig", () => { + it("throws when SUPABASE_URL is missing", () => { + delete process.env.SUPABASE_URL; + process.env.SUPABASE_PUBLISHABLE_KEY = "pk_test"; + + expect(() => { + ensureSupabaseConfig(); + }).toThrow("SUPABASE_URL is not set"); + }); + + it("throws when publishable key and legacy anon key are missing", () => { + process.env.SUPABASE_URL = "https://example.supabase.co"; + delete process.env.SUPABASE_PUBLISHABLE_KEY; + delete process.env.SUPABASE_ANON_KEY; + + expect(() => { + ensureSupabaseConfig(); + }).toThrow("SUPABASE_PUBLISHABLE_KEY is not set"); + }); + + it("does not throw when publishable key is set", () => { + process.env.SUPABASE_URL = "https://example.supabase.co"; + process.env.SUPABASE_PUBLISHABLE_KEY = "pk_test"; + + expect(() => ensureSupabaseConfig()).not.toThrow(); + }); + + it("allows fallback to legacy anon key", () => { + process.env.SUPABASE_URL = "https://example.supabase.co"; + delete process.env.SUPABASE_PUBLISHABLE_KEY; + process.env.SUPABASE_ANON_KEY = "anon_key"; + + expect(() => ensureSupabaseConfig()).not.toThrow(); + }); +}); + +// Dummy test to ensure AccessTokenResult type stays exported +it("allows constructing AccessTokenResult shape", () => { + const sample: AccessTokenResult = { + accessToken: "token", + expiresIn: 3600, + tokenType: "bearer", + }; + expect(sample.tokenType).toBe("bearer"); +}); diff --git a/tsconfig.json b/tsconfig.json index 1235857..3cda614 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,11 @@ "declarationMap": true, "sourceMap": true }, - "include": ["src"] + "include": ["src"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } From 747eb34dd0662dd24500b3eaa6d68c1326722779 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sun, 5 Oct 2025 22:45:58 +0900 Subject: [PATCH 5/8] refactor: use kebab-case filenames --- README.md | 2 +- src/commands/auth.ts | 2 +- src/services/{authService.test.ts => auth-service.test.ts} | 2 +- src/services/{authService.ts => auth-service.ts} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/services/{authService.test.ts => auth-service.test.ts} (98%) rename src/services/{authService.ts => auth-service.ts} (100%) diff --git a/README.md b/README.md index a5b8b56..0aeacd6 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ listee auth logout src/ index.ts # CLI entrypoint (Commander wiring) commands/auth.ts # Auth subcommands - services/authService.ts + services/auth-service.ts AGENTS.md # Agent-specific automation guidelines ``` diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 374b67d..004cffa 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -8,7 +8,7 @@ import { logout, signup, status, -} from "../services/authService.js"; +} from "../services/auth-service.js"; import type { AuthStatus, EmailOption, diff --git a/src/services/authService.test.ts b/src/services/auth-service.test.ts similarity index 98% rename from src/services/authService.test.ts rename to src/services/auth-service.test.ts index 9bcd501..2e3e7ed 100644 --- a/src/services/authService.test.ts +++ b/src/services/auth-service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { type AccessTokenResult, ensureSupabaseConfig } from "./authService.js"; +import { type AccessTokenResult, ensureSupabaseConfig } from "./auth-service.js"; const ORIGINAL_ENV = { ...process.env }; diff --git a/src/services/authService.ts b/src/services/auth-service.ts similarity index 100% rename from src/services/authService.ts rename to src/services/auth-service.ts From b70a3cba05308a5176d667274fb594a45576aa67 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Oct 2025 12:28:47 +0900 Subject: [PATCH 6/8] feat(auth): complete signup via loopback --- README.md | 2 + src/commands/auth.ts | 173 +++++++++++++++++++++++++++++- src/services/auth-service.test.ts | 35 +++++- src/services/auth-service.ts | 111 ++++++++++++++++++- src/types/auth.ts | 5 + 5 files changed, 321 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0aeacd6..0a20f02 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ listee auth status listee auth logout ``` +`listee auth signup` starts a temporary local callback server. Leave the command running, open the confirmation email, and the CLI will finish automatically once the browser redirects back to the loopback URL. + ## Scripts | Command | Description | | --- | --- | diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 004cffa..3cba648 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,8 +1,10 @@ import type { Buffer } from "node:buffer"; +import { createServer } from "node:http"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline"; import type { Command } from "commander"; import { + completeSignupFromFragment, ensureSupabaseConfig, login, logout, @@ -13,6 +15,7 @@ import type { AuthStatus, EmailOption, RawModeCapableInput, + SignupRedirect, } from "../types/auth.js"; const ensureNonEmpty = (value: string, label: string): string => { @@ -40,6 +43,152 @@ const handleError = (error: unknown): void => { process.exitCode = 1; }; +const LOOPBACK_HOST = "127.0.0.1"; +const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000; + +type LoopbackServer = { + redirectUrl: string; + waitForConfirmation: () => Promise; + shutdown: () => Promise; +}; + +const successPage = (message: string): string => { + return ` + + + + Listee CLI Signup + + + +

${message}

+

This window is part of the Listee CLI signup flow.

+

You may close this window and return to your terminal.

+ +`; +}; + +const callbackPage = ` + + + + Completing Signup + + + +

Completing signup...

+

This window is part of the Listee CLI signup flow and will update automatically.

+ + +`; + +const startLoopbackServer = async (): Promise => { + let resolveResult: ((value: SignupRedirect) => void) | undefined; + let rejectResult: ((reason?: unknown) => void) | undefined; + let settled = false; + + const server = createServer((req, res) => { + const finish = (status: number, body: string, contentType = "text/html"): void => { + res.writeHead(status, { "Content-Type": contentType }); + res.end(body); + }; + + if (req.method === "GET" && req.url?.startsWith("/callback")) { + finish(200, callbackPage); + return; + } + + if (req.method === "POST" && req.url === "/token") { + let data = ""; + req.on("data", (chunk) => { + data += chunk.toString(); + }); + req.on("end", async () => { + if (settled) { + finish(200, successPage("Signup already completed.")); + return; + } + try { + const parsed = JSON.parse(data) as { hash?: string }; + const hash = parsed.hash; + if (typeof hash !== "string" || hash.length === 0) { + throw new Error("Missing hash in request body."); + } + const result = await completeSignupFromFragment(hash); + settled = true; + finish(200, successPage("Signup confirmed.")); + resolveResult?.(result); + } catch (error) { + finish(400, successPage(`Failed to complete signup: ${error instanceof Error ? error.message : String(error)}`)); + rejectResult?.(error); + } + }); + return; + } + + finish(404, "Not Found", "text/plain"); + }); + + const waitForConfirmation = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + server.on("error", (error) => { + if (!settled) { + settled = true; + rejectResult?.(error); + } + }); + + await new Promise((resolve) => { + server.listen(0, LOOPBACK_HOST, () => resolve()); + }); + + const address = server.address(); + if (address === null || typeof address !== "object" || address.port === undefined) { + server.close(); + throw new Error("Failed to determine loopback server port."); + } + + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + rejectResult?.(new Error("Signup confirmation timed out.")); + } + void server.close(); + }, LOOPBACK_TIMEOUT_MS); + + const shutdown = async (): Promise => { + clearTimeout(timeout); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }; + + return { + redirectUrl: `http://${LOOPBACK_HOST}:${address.port}/callback`, + waitForConfirmation: () => waitForConfirmation.finally(() => clearTimeout(timeout)), + shutdown, + }; +}; + const isRawModeCapable = ( stream: typeof input, ): stream is RawModeCapableInput => { @@ -156,8 +305,28 @@ const signupAction = async (options: EmailOption): Promise => { ensureSupabaseConfig(); const email = ensureEmail(options.email); const password = await promptHiddenInput("Password: "); - await signup(email, password); - console.log("📩 Confirmation email sent."); + const loopback = await startLoopbackServer(); + + const handleAbort = (): void => { + void loopback.shutdown().finally(() => { + console.log("\nSignup confirmation cancelled."); + process.exit(1); + }); + }; + + process.once("SIGINT", handleAbort); + process.once("SIGTERM", handleAbort); + + try { + await signup(email, password, loopback.redirectUrl); + console.log("📩 Confirmation email sent. Keep this terminal open while you click the link."); + const result = await loopback.waitForConfirmation(); + console.log(`✅ Signup confirmed for ${result.account}.`); + } finally { + process.removeListener("SIGINT", handleAbort); + process.removeListener("SIGTERM", handleAbort); + await loopback.shutdown(); + } }; const logoutAction = async (): Promise => { diff --git a/src/services/auth-service.test.ts b/src/services/auth-service.test.ts index 2e3e7ed..6b19f64 100644 --- a/src/services/auth-service.test.ts +++ b/src/services/auth-service.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { type AccessTokenResult, ensureSupabaseConfig } from "./auth-service.js"; +import { Buffer } from "node:buffer"; +import { + type AccessTokenResult, + ensureSupabaseConfig, + parseSignupFragment, +} from "./auth-service.js"; const ORIGINAL_ENV = { ...process.env }; @@ -55,3 +60,31 @@ it("allows constructing AccessTokenResult shape", () => { }; expect(sample.tokenType).toBe("bearer"); }); + +describe("parseSignupFragment", () => { + const encodeSegment = (value: string): string => { + return Buffer.from(value, "utf8").toString("base64url"); + }; + + const header = encodeSegment(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = encodeSegment(JSON.stringify({ email: "user@example.com" })); + const signature = encodeSegment("signature"); + const accessToken = `${header}.${payload}.${signature}`; + + it("parses tokens from a confirmation fragment", () => { + const fragment = `#access_token=${accessToken}&refresh_token=refresh123&expires_in=3600&token_type=bearer&type=signup`; + const result = parseSignupFragment(fragment); + + expect(result.account).toBe("user@example.com"); + expect(result.refreshToken).toBe("refresh123"); + expect(result.expiresIn).toBe(3600); + }); + + it("throws when fragment is missing required parameters", () => { + const fragment = "#token_type=bearer"; + + expect(() => { + parseSignupFragment(fragment); + }).toThrow("Confirmation URL is missing access_token."); + }); +}); diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 405d1c0..f29f697 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -1,13 +1,15 @@ +import { Buffer } from "node:buffer"; import { AsyncEntry, findCredentials } from "@napi-rs/keyring"; import type { AccessTokenResult, AuthStatus, + SignupRedirect, StoredCredential, SupabaseErrorPayload, SupabaseTokenResponse, } from "../types/auth.js"; -export type { AccessTokenResult, AuthStatus } from "../types/auth.js"; +export type { AccessTokenResult, AuthStatus, SignupRedirect } from "../types/auth.js"; const DEFAULT_SERVICE_NAME = "listee-cli"; @@ -166,6 +168,99 @@ const buildSupabaseHeaders = (): Record => { }; }; +const getFragmentParams = (fragment: string): URLSearchParams => { + if (fragment.length === 0) { + throw new Error("Confirmation URL does not include hash parameters."); + } + + if (fragment.startsWith("#")) { + return new URLSearchParams(fragment.slice(1)); + } + + return new URLSearchParams(fragment); +}; + +const decodeJwtPayload = (token: string): unknown => { + const segments = token.split("."); + if (segments.length < 2) { + throw new Error("Malformed access token received."); + } + + try { + const payloadSegment = segments[1]; + const decoded = Buffer.from(payloadSegment, "base64url").toString("utf8"); + return JSON.parse(decoded); + } catch (error) { + throw new Error( + `Unable to decode access token payload: ${toErrorMessage(error)}`, + ); + } +}; + +const extractEmailFromAccessToken = (token: string): string => { + const payload = decodeJwtPayload(token); + if (!isRecord(payload)) { + throw new Error("Access token payload structure is invalid."); + } + + const email = payload.email; + if (!isString(email) || email.trim().length === 0) { + throw new Error("Access token payload did not include an email."); + } + + return email.trim(); +}; + +const parseIntegerParam = (value: string | null, name: string): number => { + if (value === null) { + throw new Error(`Missing ${name} in confirmation URL.`); + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid ${name} value in confirmation URL.`); + } + + return parsed; +}; + +const parseSignupFromParams = (params: URLSearchParams): SignupRedirect => { + const accessToken = params.get("access_token"); + const refreshToken = params.get("refresh_token"); + const tokenType = params.get("token_type"); + const expiresInRaw = params.get("expires_in"); + const flowType = params.get("type"); + + if (!isString(accessToken) || accessToken.length === 0) { + throw new Error("Confirmation URL is missing access_token."); + } + if (!isString(refreshToken) || refreshToken.length === 0) { + throw new Error("Confirmation URL is missing refresh_token."); + } + if (!isString(tokenType) || tokenType.length === 0) { + throw new Error("Confirmation URL is missing token_type."); + } + if (flowType !== "signup") { + throw new Error("Confirmation URL is not for a signup flow."); + } + + const account = extractEmailFromAccessToken(accessToken); + const expiresIn = parseIntegerParam(expiresInRaw, "expires_in"); + + return { + account, + accessToken, + refreshToken, + tokenType, + expiresIn, + }; +}; + +export const parseSignupFragment = (fragment: string): SignupRedirect => { + const params = getFragmentParams(fragment); + return parseSignupFromParams(params); +}; + const storeRefreshToken = async ( account: string, token: string, @@ -228,8 +323,12 @@ const requestSupabase = async ( export const signup = async ( email: string, password: string, + redirectUrl?: string, ): Promise => { - const response = await requestSupabase("auth/v1/signup", { email, password }); + const path = redirectUrl === undefined + ? "auth/v1/signup" + : `auth/v1/signup?redirect_to=${encodeURIComponent(redirectUrl)}`; + const response = await requestSupabase(path, { email, password }); if (!response.ok) { const payload = await readJson(response); @@ -316,3 +415,11 @@ export const status = async (): Promise => { accounts: credentials.map((credential) => credential.account), }; }; + +export const completeSignupFromFragment = async ( + fragment: string, +): Promise => { + const result = parseSignupFragment(fragment); + await storeRefreshToken(result.account, result.refreshToken); + return result; +}; diff --git a/src/types/auth.ts b/src/types/auth.ts index 62ed474..ac58be7 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -33,3 +33,8 @@ export type EmailOption = { }; export type RawModeCapableInput = NodeJS.ReadStream & { fd: 0 }; + +export type SignupRedirect = AccessTokenResult & { + account: string; + refreshToken: string; +}; From c70db1e95389ed2cab136fc079413321f5a7e0e0 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Oct 2025 13:59:26 +0900 Subject: [PATCH 7/8] fix(auth): preserve whitespace in password prompt --- src/commands/auth.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 3cba648..50fd093 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -202,7 +202,11 @@ const promptHiddenInput = (promptText: string): Promise => { if (!input.isTTY || !output.isTTY) { rl.question(promptText, (answer) => { rl.close(); - resolve(ensureNonEmpty(answer, "Password")); + if (answer.length === 0) { + reject(new Error("Password must not be empty.")); + return; + } + resolve(answer); }); return; } @@ -234,7 +238,12 @@ const promptHiddenInput = (promptText: string): Promise => { if (char === "\r" || char === "\n") { cleanup(); output.write("\n"); - resolve(ensureNonEmpty(collected.join(""), "Password")); + const password = collected.join(""); + if (password.length === 0) { + reject(new Error("Password must not be empty.")); + return; + } + resolve(password); return; } if (char === "\u007f") { From b72c369069c1edcecdb4500105d35e52bf20a097 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Oct 2025 16:04:44 +0900 Subject: [PATCH 8/8] chore(auth): address review feedback --- src/commands/auth.ts | 70 ++++++++++++++++++++++-------------- src/services/auth-service.ts | 10 +++--- src/types/auth.ts | 2 +- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 50fd093..1b3fbc2 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -52,24 +52,6 @@ type LoopbackServer = { shutdown: () => Promise; }; -const successPage = (message: string): string => { - return ` - - - - Listee CLI Signup - - - -

${message}

-

This window is part of the Listee CLI signup flow.

-

You may close this window and return to your terminal.

- -`; -}; - const callbackPage = ` @@ -80,19 +62,36 @@ const callbackPage = ` -

Completing signup...

+

Completing signup...

This window is part of the Listee CLI signup flow and will update automatically.

+

You may close this window and return to your terminal once it finishes.

@@ -109,6 +108,10 @@ const startLoopbackServer = async (): Promise => { res.end(body); }; + const respondWithJson = (status: number, payload: { title: string; message: string }): void => { + finish(status, JSON.stringify(payload), "application/json"); + }; + if (req.method === "GET" && req.url?.startsWith("/callback")) { finish(200, callbackPage); return; @@ -121,7 +124,10 @@ const startLoopbackServer = async (): Promise => { }); req.on("end", async () => { if (settled) { - finish(200, successPage("Signup already completed.")); + respondWithJson(200, { + title: "Signup already completed.", + message: "You may close this window and return to your terminal.", + }); return; } try { @@ -132,10 +138,16 @@ const startLoopbackServer = async (): Promise => { } const result = await completeSignupFromFragment(hash); settled = true; - finish(200, successPage("Signup confirmed.")); + respondWithJson(200, { + title: "Signup confirmed.", + message: "You may close this window and return to your terminal.", + }); resolveResult?.(result); } catch (error) { - finish(400, successPage(`Failed to complete signup: ${error instanceof Error ? error.message : String(error)}`)); + respondWithJson(400, { + title: "Failed to complete signup.", + message: error instanceof Error ? error.message : String(error), + }); rejectResult?.(error); } }); @@ -172,7 +184,11 @@ const startLoopbackServer = async (): Promise => { settled = true; rejectResult?.(new Error("Signup confirmation timed out.")); } - void server.close(); + void (async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + })(); }, LOOPBACK_TIMEOUT_MS); const shutdown = async (): Promise => { @@ -306,7 +322,7 @@ const loginAction = async (options: EmailOption): Promise => { ensureSupabaseConfig(); const email = ensureEmail(options.email); const password = await promptHiddenInput("Password: "); - const _token = await login(email, password); + await login(email, password); console.log("✅ Logged in."); }; diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index f29f697..d85602c 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -73,7 +73,7 @@ const listStoredCredentials = (service: string): StoredCredential[] => { try { return findCredentials(service).map((credential) => ({ account: credential.account, - password: credential.password, + refreshToken: credential.password, })); } catch (error) { throw new Error( @@ -277,11 +277,11 @@ const findStoredCredential = async ( if (preferredAccount !== undefined) { const entry = new AsyncEntry(service, preferredAccount); - const password = await entry.getPassword(); - if (password === undefined || password === null) { + const refreshToken = await entry.getPassword(); + if (refreshToken === undefined || refreshToken === null) { return null; } - return { account: preferredAccount, password }; + return { account: preferredAccount, refreshToken }; } const credentials = listStoredCredentials(service); @@ -374,7 +374,7 @@ export const getAccessToken = async ( const response = await requestSupabase( "auth/v1/token?grant_type=refresh_token", { - refresh_token: credential.password, + refresh_token: credential.refreshToken, }, ); diff --git a/src/types/auth.ts b/src/types/auth.ts index ac58be7..e3233c0 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -15,7 +15,7 @@ export type SupabaseErrorPayload = { export type StoredCredential = { account: string; - password: string; + refreshToken: string; }; export type AccessTokenResult = {