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..c5fd347 100644 --- a/.gitignore +++ b/.gitignore @@ -1,139 +1,20 @@ +# Dependencies +node_modules/ + +# 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/README.md b/README.md index a5b8b56..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 | | --- | --- | @@ -47,7 +49,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/biome.json b/biome.json new file mode 100644 index 0000000..e52728f --- /dev/null +++ b/biome.json @@ -0,0 +1,28 @@ +{ + "$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" + } + }, + "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.json b/package.json new file mode 100644 index 0000000..39f622b --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "listee-cli", + "version": "0.0.1", + "type": "module", + "bin": { + "listee": "./dist/index.js" + }, + "scripts": { + "build": "bun x tsc -b", + "dev": "bun run watch", + "watch": "bun x tsc --watch --preserveWatchOutput", + "start": "node dist/index.js", + "lint": "bun x biome ci .", + "test": "bun test" + }, + "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" + } +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..1b3fbc2 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,417 @@ +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, + signup, + status, +} from "../services/auth-service.js"; +import type { + AuthStatus, + EmailOption, + RawModeCapableInput, + SignupRedirect, +} 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 LOOPBACK_HOST = "127.0.0.1"; +const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000; + +type LoopbackServer = { + redirectUrl: string; + waitForConfirmation: () => Promise; + shutdown: () => Promise; +}; + +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.

+ + +`; + +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); + }; + + 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; + } + + if (req.method === "POST" && req.url === "/token") { + let data = ""; + req.on("data", (chunk) => { + data += chunk.toString(); + }); + req.on("end", async () => { + if (settled) { + respondWithJson(200, { + title: "Signup already completed.", + message: "You may close this window and return to your terminal.", + }); + 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; + respondWithJson(200, { + title: "Signup confirmed.", + message: "You may close this window and return to your terminal.", + }); + resolveResult?.(result); + } catch (error) { + respondWithJson(400, { + title: "Failed to complete signup.", + message: 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 (async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + })(); + }, 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 => { + 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(); + if (answer.length === 0) { + reject(new Error("Password must not be empty.")); + return; + } + resolve(answer); + }); + 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"); + const password = collected.join(""); + if (password.length === 0) { + reject(new Error("Password must not be empty.")); + return; + } + resolve(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: "); + 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: "); + 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 => { + 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/auth-service.test.ts b/src/services/auth-service.test.ts new file mode 100644 index 0000000..6b19f64 --- /dev/null +++ b/src/services/auth-service.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { Buffer } from "node:buffer"; +import { + type AccessTokenResult, + ensureSupabaseConfig, + parseSignupFragment, +} from "./auth-service.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"); +}); + +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 new file mode 100644 index 0000000..d85602c --- /dev/null +++ b/src/services/auth-service.ts @@ -0,0 +1,425 @@ +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, SignupRedirect } 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, + refreshToken: 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 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, +): 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 refreshToken = await entry.getPassword(); + if (refreshToken === undefined || refreshToken === null) { + return null; + } + return { account: preferredAccount, refreshToken }; + } + + 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, + redirectUrl?: string, +): Promise => { + 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); + 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.refreshToken, + }, + ); + + 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), + }; +}; + +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 new file mode 100644 index 0000000..e3233c0 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,40 @@ +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; + refreshToken: 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 }; + +export type SignupRedirect = AccessTokenResult & { + account: string; + refreshToken: string; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3cda614 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "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"], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] +}