diff --git a/.env.example b/.env.example index a64d413..8ce4eb6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ -SUPABASE_URL= -SUPABASE_PUBLISHABLE_KEY= +LISTEE_API_URL= # LISTEE_CLI_KEYCHAIN_SERVICE=listee-cli diff --git a/AGENTS.md b/AGENTS.md index 51b2fc0..a106140 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - Agents must follow the Listee org conventions observed in sibling repos (`listee-libs`, `listee-ci`) and keep changes minimal and well-justified. ## Repository Layout Awareness -- Source of truth: `src/index.ts` wires Commander, `src/commands/` hosts CLI handlers, and `src/services/` stores Supabase-facing logic. Tests will expand under `tests/`. +- Source of truth: `src/index.ts` wires Commander, `src/commands/` hosts CLI handlers, and `src/services/` stores Listee API-facing logic. Tests will expand under `tests/`. - Keep generated output in `dist/` (never commit). Respect any existing files—do not alter unrelated modules. - When referencing other org repos, treat them as read-only unless explicitly instructed. @@ -23,8 +23,8 @@ - Indentation is two spaces, LF line endings, `kebab-case` filenames for modules, `camelCase` for identifiers. Maintain ASCII unless the file already uses Unicode. - Keep comments purposeful; avoid restating the obvious. Add brief context only for non-trivial flows (e.g., token refresh sequencing). -## Supabase & Secrets Handling -- Read `SUPABASE_URL` and `SUPABASE_ANON_KEY` from environment variables or `.env`; never hardcode credentials. +## Listee API & Secrets Handling +- Read `LISTEE_API_URL` from environment variables or `.env`; never hardcode endpoints or secrets. - Keytar service name defaults to `listee-cli`. If a feature demands overrides, surface them via env vars or CLI flags. ## Safety & Review Protocol diff --git a/README.md b/README.md index 0a20f02..49175de 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # listee-cli -Official command-line interface for Listee — manage authentication, categories, and tasks directly from your terminal. The MVP focuses on Supabase email/password flows (`signup`, `login`, `logout`, `status`). +Official command-line interface for Listee — manage authentication, categories, and tasks directly from your terminal via the Listee API (`signup`, `login`, `logout`, `status`). ## Requirements - Bun 1.2.22 (`bun --version`) - Node.js 20+ (runtime for the compiled CLI) -- Supabase project credentials (`SUPABASE_URL`, `SUPABASE_ANON_KEY`) +- Listee API base URL (`LISTEE_API_URL`) ## Installation ```bash @@ -15,8 +15,7 @@ bun install ## Configuration Create a `.env` file or export environment variables before running commands: ```bash -export SUPABASE_URL="https://your-project.supabase.co" -export SUPABASE_ANON_KEY="your-anon-key" +export LISTEE_API_URL="https://api.your-listee-instance.dev" # optional: override the Keytar service name export LISTEE_CLI_KEYCHAIN_SERVICE="listee-cli" ``` @@ -32,6 +31,16 @@ listee auth signup --email you@example.com listee auth login --email you@example.com listee auth status listee auth logout +listee categories list [--email you@example.com] +listee categories show [--email you@example.com] +listee categories create --name "Inbox" [--email you@example.com] +listee categories update --name "New name" [--email you@example.com] +listee categories delete [--email you@example.com] +listee tasks list --category [--email you@example.com] +listee tasks create --category --name "Task title" [--description "..."] [--checked] [--email you@example.com] +listee tasks show [--email you@example.com] +listee tasks update [--name "New title"] [--description "..."] [--clear-description] [--checked|--unchecked] [--email you@example.com] +listee tasks delete [--email you@example.com] ``` `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. @@ -49,7 +58,12 @@ listee auth logout src/ index.ts # CLI entrypoint (Commander wiring) commands/auth.ts # Auth subcommands + commands/categories.ts + commands/tasks.ts services/auth-service.ts + services/api-client.ts + services/category-api.ts + services/task-api.ts AGENTS.md # Agent-specific automation guidelines ``` diff --git a/bun.lock b/bun.lock index c190242..019d035 100644 --- a/bun.lock +++ b/bun.lock @@ -4,11 +4,13 @@ "": { "name": "listee-cli", "dependencies": { - "@listee/auth": "^0.2.3", - "@listee/types": "^0.2.3", + "@listee/auth": "^0.4.0", + "@listee/types": "^0.4.0", "@napi-rs/keyring": "^1.2.0", + "@t3-oss/env-core": "^0.13.8", "commander": "^12.1.0", "dotenv": "^16.4.5", + "zod": "^4.1.12", }, "devDependencies": { "@biomejs/biome": "^2.2.4", @@ -37,11 +39,11 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="], - "@listee/auth": ["@listee/auth@0.2.3", "", { "dependencies": { "@listee/db": "^0.2.3", "@listee/types": "^0.2.3", "jose": "^5.2.3" } }, "sha512-uKWmlxL1wji+/qILopv1iOc8G98fpBhmpTTAc7CgtaNK3bXdrbsKSQCNKUXxu9Q5bPsdWNcbLP1AyYznwEE9wQ=="], + "@listee/auth": ["@listee/auth@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "jose": "^5.2.3" } }, "sha512-Er3L7k1br6b/3ROLVrnI4xUzGeJMtpQdy2py9qd7d7S8SpBcZwUa1mejwfjACwrk2t/WyUNnhHF/Kgsk137q3Q=="], - "@listee/db": ["@listee/db@0.2.3", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-RNijZvSbavrMITk+mKUwPon2XwcbxVYiQSLWPfrv83g+B3JpG1ClYId4qk21cJkUVH6Hn6O97rXN1XOQSKdKNw=="], + "@listee/db": ["@listee/db@0.4.0", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-6WOSt6UZy+fX2TnsrLl812P9NdWPocCGrOzN6kVnfdR61rbh2OE8k6KPC3cXnGzbamIQBueeut8v66ho0lHsiw=="], - "@listee/types": ["@listee/types@0.2.3", "", { "dependencies": { "@listee/db": "^0.2.3" } }, "sha512-eGOVIn4nCTIWuIC8fu07vjbBf14UU8DQKJQCQOpJjFjW7YP4hCUdjc64DhtYjlsK56xi/TiJprVw7MrpJfx4FA=="], + "@listee/types": ["@listee/types@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-UmQjgCGu23ebgHoP3zYknEDirASRADguKJleEsrU/XwBSfxb7xSyAmIz7Erf1vSjYY+qj35LnoydOk4mSdYHNQ=="], "@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=="], @@ -69,6 +71,8 @@ "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA=="], + "@t3-oss/env-core": ["@t3-oss/env-core@0.13.8", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw=="], + "@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=="], @@ -92,5 +96,7 @@ "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=="], + + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], } } diff --git a/package.json b/package.json index 36ff89b..7ef33e9 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "test": "bun test" }, "dependencies": { - "@listee/auth": "^0.2.3", - "@listee/types": "^0.2.3", + "@listee/auth": "^0.4.0", + "@listee/types": "^0.4.0", "@napi-rs/keyring": "^1.2.0", + "@t3-oss/env-core": "^0.13.8", "commander": "^12.1.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "zod": "^4.1.12" }, "devDependencies": { "@biomejs/biome": "^2.2.4", diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f61d5c5..1e87596 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -5,7 +5,7 @@ import { createInterface } from "node:readline"; import type { Command } from "commander"; import { completeSignupFromFragment, - ensureSupabaseConfig, + ensureListeeApiConfig, login, logout, signup, @@ -34,6 +34,10 @@ const ensureEmail = (value: unknown): string => { return ensureNonEmpty(value, "Email"); }; +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + const handleError = (error: unknown): void => { if (error instanceof Error) { console.error(`Error: ${error.message}`); @@ -138,7 +142,10 @@ const startLoopbackServer = async (): Promise => { return; } try { - const parsed = JSON.parse(data) as { hash?: string }; + const parsed = JSON.parse(data); + if (!isRecord(parsed)) { + throw new Error("Invalid request body received."); + } const hash = parsed.hash; if (typeof hash !== "string" || hash.length === 0) { throw new Error("Missing hash in request body."); @@ -181,11 +188,12 @@ const startLoopbackServer = async (): Promise => { }); const address = server.address(); - if ( - address === null || - typeof address !== "object" || - address.port === undefined - ) { + if (address === null || typeof address === "string") { + server.close(); + throw new Error("Failed to determine loopback server port."); + } + const { port } = address; + if (port === undefined) { server.close(); throw new Error("Failed to determine loopback server port."); } @@ -210,7 +218,7 @@ const startLoopbackServer = async (): Promise => { }; return { - redirectUrl: `http://${LOOPBACK_HOST}:${address.port}/callback`, + redirectUrl: `http://${LOOPBACK_HOST}:${port}/callback`, waitForConfirmation: () => waitForConfirmation.finally(() => clearTimeout(timeout)), shutdown, @@ -331,7 +339,7 @@ const printStatus = (result: AuthStatus): void => { }; const loginAction = async (options: EmailOption): Promise => { - ensureSupabaseConfig(); + ensureListeeApiConfig(); const email = ensureEmail(options.email); const password = await promptHiddenInput("Password: "); await login(email, password); @@ -339,7 +347,7 @@ const loginAction = async (options: EmailOption): Promise => { }; const signupAction = async (options: EmailOption): Promise => { - ensureSupabaseConfig(); + ensureListeeApiConfig(); const email = ensureEmail(options.email); const password = await promptHiddenInput("Password: "); const loopback = await startLoopbackServer(); @@ -385,13 +393,11 @@ const statusAction = async (): Promise => { export const registerAuthCommand = (program: Command): void => { const auth = program .command("auth") - .description("Manage Supabase authentication for Listee."); + .description("Manage Listee API authentication for Listee."); auth .command("signup") - .description( - "Sign up for a new Listee account via Supabase email/password.", - ) + .description("Sign up for a new Listee account via the Listee API.") .requiredOption("--email ", "Email address to register") .action( execute(async (options: EmailOption) => { @@ -402,7 +408,7 @@ export const registerAuthCommand = (program: Command): void => { auth .command("login") .description( - "Authenticate with Supabase using email/password and store refresh token in keychain.", + "Authenticate with the Listee API using email/password and store refresh token in keychain.", ) .requiredOption("--email ", "Email address to log in") .action( diff --git a/src/commands/categories.ts b/src/commands/categories.ts new file mode 100644 index 0000000..2ef8d3c --- /dev/null +++ b/src/commands/categories.ts @@ -0,0 +1,209 @@ +import type { Command } from "commander"; +import { + createCategory, + deleteCategory, + getCategory, + listCategories, + updateCategory, +} from "../services/category-api.js"; + +const ensurePositiveInteger = (value: string): number => { + if (!/^\d+$/.test(value)) { + throw new Error("Limit must be a positive integer."); + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error("Limit must be a positive integer."); + } + return parsed; +}; + +const ensureNonEmptyString = (value: string, label: string): string => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`${label} must not be empty.`); + } + return trimmed; +}; + +const execute = (task: (...args: T) => Promise) => { + return async (...args: T): Promise => { + try { + await task(...args); + } catch (error) { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error("Unknown error occurred."); + } + process.exitCode = 1; + } + }; +}; + +const printCategories = ( + items: readonly { + readonly id: string; + readonly name: string; + readonly kind: string; + }[], +): void => { + if (items.length === 0) { + console.log("No categories found."); + return; + } + + console.log("Categories:"); + for (const item of items) { + console.log(` • ${item.name} (${item.id}) [${item.kind}]`); + } +}; + +const printCategoryDetails = (category: { + readonly name: string; + readonly id: string; + readonly kind: string; + readonly createdBy: string; + readonly updatedBy: string; + readonly createdAt: string; + readonly updatedAt: string; +}): void => { + console.log(`Name: ${category.name}`); + console.log(`ID: ${category.id}`); + console.log(`Kind: ${category.kind}`); + console.log(`Created By: ${category.createdBy}`); + console.log(`Updated By: ${category.updatedBy}`); + console.log(`Created At: ${category.createdAt}`); + console.log(`Updated At: ${category.updatedAt}`); +}; + +export const registerCategoryCommand = (program: Command): void => { + const categories = program + .command("categories") + .description("Inspect Listee categories via the API."); + + categories + .command("list") + .description("List categories for the authenticated user.") + .option("--email ", "Account email to use when fetching categories") + .option("--limit ", "Maximum number of categories to fetch") + .option("--cursor ", "Cursor returned by a previous list operation") + .action( + execute( + async (options: { + readonly email?: string; + readonly limit?: string; + readonly cursor?: string; + }) => { + const limit = + options.limit === undefined + ? undefined + : ensurePositiveInteger(options.limit); + const result = await listCategories({ + email: options.email, + limit, + cursor: options.cursor ?? null, + }); + printCategories(result.data); + if (result.meta.hasMore) { + const cursorValue = result.meta.nextCursor ?? ""; + console.log( + "More categories available. Use --cursor", + cursorValue, + "to continue.", + ); + } + }, + ), + ); + + categories + .command("show ") + .description("Show details for a specific category.") + .option( + "--email ", + "Account email to use when fetching the category", + ) + .action( + execute( + async (categoryId: string, options: { readonly email?: string }) => { + const id = ensureNonEmptyString(categoryId, "Category ID"); + const response = await getCategory({ + email: options.email, + categoryId: id, + }); + printCategoryDetails(response.data); + }, + ), + ); + + categories + .command("create") + .description("Create a new category for the authenticated user.") + .requiredOption("--name ", "Name of the category to create") + .option( + "--email ", + "Account email to use when creating the category", + ) + .action( + execute( + async (options: { readonly name: string; readonly email?: string }) => { + const name = ensureNonEmptyString(options.name, "Name"); + const category = await createCategory({ + email: options.email, + name, + }); + console.log("Category created."); + printCategoryDetails(category); + }, + ), + ); + + categories + .command("update ") + .description("Update an existing category for the authenticated user.") + .requiredOption("--name ", "New name for the category") + .option( + "--email ", + "Account email to use when updating the category", + ) + .action( + execute( + async ( + categoryId: string, + options: { + readonly name: string; + readonly email?: string; + }, + ) => { + const id = ensureNonEmptyString(categoryId, "Category ID"); + const name = ensureNonEmptyString(options.name, "Name"); + + const category = await updateCategory({ + categoryId: id, + name, + email: options.email, + }); + console.log("Category updated."); + printCategoryDetails(category); + }, + ), + ); + + categories + .command("delete ") + .description("Delete a category for the authenticated user.") + .option( + "--email ", + "Account email to use when deleting the category", + ) + .action( + execute( + async (categoryId: string, options: { readonly email?: string }) => { + const id = ensureNonEmptyString(categoryId, "Category ID"); + await deleteCategory({ categoryId: id, email: options.email }); + console.log("Category deleted."); + }, + ), + ); +}; diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts new file mode 100644 index 0000000..1b6f7c8 --- /dev/null +++ b/src/commands/tasks.ts @@ -0,0 +1,247 @@ +import type { Command } from "commander"; +import { + createTask, + deleteTask, + getTask, + listTasksByCategory, + updateTask, +} from "../services/task-api.js"; + +const execute = (task: (...args: T) => Promise) => { + return async (...args: T): Promise => { + try { + await task(...args); + } catch (error) { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error("Unknown error occurred."); + } + process.exitCode = 1; + } + }; +}; + +const ensureNonEmptyString = (value: string, label: string): string => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`${label} must not be empty.`); + } + return trimmed; +}; + +const printTasks = ( + items: readonly { + readonly id: string; + readonly name: string; + readonly description: string | null; + readonly isChecked: boolean; + }[], +): void => { + if (items.length === 0) { + console.log("No tasks found."); + return; + } + + console.log("Tasks:"); + for (const item of items) { + const status = item.isChecked ? "[x]" : "[ ]"; + const details = item.description === null ? "" : ` — ${item.description}`; + console.log(` • ${status} ${item.name} (${item.id})${details}`); + } +}; + +const printTaskDetails = (task: { + readonly id: string; + readonly name: string; + readonly description: string | null; + readonly isChecked: boolean; + readonly categoryId: string; + readonly createdBy: string; + readonly updatedBy: string; + readonly createdAt: string; + readonly updatedAt: string; +}): void => { + const status = task.isChecked ? "[x]" : "[ ]"; + console.log(`Name: ${task.name}`); + console.log(`ID: ${task.id}`); + console.log(`Description: ${task.description ?? ""}`); + console.log(`Status: ${status}`); + console.log(`Category: ${task.categoryId}`); + console.log(`Created By: ${task.createdBy}`); + console.log(`Updated By: ${task.updatedBy}`); + console.log(`Created At: ${task.createdAt}`); + console.log(`Updated At: ${task.updatedAt}`); +}; + +export const registerTaskCommand = (program: Command): void => { + const tasks = program + .command("tasks") + .description("Inspect Listee tasks via the API."); + + tasks + .command("list") + .description("List tasks within a specific category.") + .requiredOption( + "--category ", + "Category identifier to list tasks for", + ) + .option("--email ", "Account email to use when fetching tasks") + .action( + execute( + async (options: { + readonly category: string; + readonly email?: string; + }) => { + const categoryId = ensureNonEmptyString(options.category, "Category"); + const response = await listTasksByCategory({ + categoryId, + email: options.email, + }); + printTasks(response.data); + }, + ), + ); + + tasks + .command("show ") + .description("Show details for a specific task.") + .option("--email ", "Account email to use when fetching the task") + .action( + execute(async (taskId: string, options: { readonly email?: string }) => { + const id = ensureNonEmptyString(taskId, "Task ID"); + const response = await getTask({ taskId: id, email: options.email }); + printTaskDetails(response.data); + }), + ); + + tasks + .command("create") + .description("Create a new task within a category.") + .requiredOption( + "--category ", + "Category identifier to attach the new task to", + ) + .requiredOption("--name ", "Name of the task to create") + .option("--description ", "Optional description for the task") + .option("--checked", "Mark the task as checked upon creation") + .option("--email ", "Account email to use when creating the task") + .action( + execute( + async (options: { + readonly category: string; + readonly name: string; + readonly description?: string; + readonly checked?: boolean; + readonly email?: string; + }) => { + const categoryId = ensureNonEmptyString(options.category, "Category"); + const name = ensureNonEmptyString(options.name, "Name"); + const description = + options.description === undefined + ? undefined + : options.description.trim(); + const task = await createTask({ + categoryId, + name, + description, + isChecked: options.checked === true ? true : undefined, + email: options.email, + }); + console.log("Task created."); + printTaskDetails(task); + }, + ), + ); + + tasks + .command("update ") + .description("Update an existing task.") + .option("--name ", "New name for the task") + .option("--description ", "New description for the task") + .option("--clear-description", "Remove the task description") + .option("--checked", "Mark the task as checked") + .option("--unchecked", "Mark the task as unchecked") + .option("--email ", "Account email to use when updating the task") + .action( + execute( + async ( + taskId: string, + options: { + readonly name?: string; + readonly description?: string; + readonly clearDescription?: boolean; + readonly checked?: boolean; + readonly unchecked?: boolean; + readonly email?: string; + }, + ) => { + const id = ensureNonEmptyString(taskId, "Task ID"); + const name = + options.name === undefined + ? undefined + : ensureNonEmptyString(options.name, "Name"); + + if (options.checked === true && options.unchecked === true) { + throw new Error( + "--checked and --unchecked cannot be used together.", + ); + } + + if ( + options.clearDescription === true && + options.description !== undefined + ) { + throw new Error( + "--description cannot be combined with --clear-description.", + ); + } + + const description = + options.clearDescription === true + ? null + : options.description === undefined + ? undefined + : options.description.trim(); + const isChecked = + options.checked === true + ? true + : options.unchecked === true + ? false + : undefined; + + if ( + name === undefined && + description === undefined && + isChecked === undefined + ) { + throw new Error( + "Provide at least one update option (--name, --description, --clear-description, --checked, --unchecked).", + ); + } + + const task = await updateTask({ + taskId: id, + name, + description, + isChecked, + email: options.email, + }); + console.log("Task updated."); + printTaskDetails(task); + }, + ), + ); + + tasks + .command("delete ") + .description("Delete a task.") + .option("--email ", "Account email to use when deleting the task") + .action( + execute(async (taskId: string, options: { readonly email?: string }) => { + const id = ensureNonEmptyString(taskId, "Task ID"); + await deleteTask({ taskId: id, email: options.email }); + console.log("Task deleted."); + }), + ); +}; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..8f5e5e8 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,111 @@ +import "dotenv/config"; +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; + +type EnvIssue = { + readonly path: readonly (string | number)[]; + readonly message: string; +}; + +export class EnvValidationError extends Error { + readonly issues: EnvIssue[]; + + constructor(issues: EnvIssue[]) { + super("Invalid environment variables"); + this.issues = issues; + } +} + +const urlString = z.url(); +const nonEmptyString = z + .string() + .min(1) + .refine((value) => value === value.trim(), { + message: "Value must not include leading or trailing whitespace.", + }); +const optionalNonEmptyString = nonEmptyString.optional(); + +const buildEnv = () => { + return createEnv({ + server: { + LISTEE_API_URL: urlString, + LISTEE_CLI_KEYCHAIN_SERVICE: optionalNonEmptyString, + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, + onValidationError: (issues: ReadonlyArray): never => { + const normalizedIssues: EnvIssue[] = issues.map((issue) => { + if ( + typeof issue === "object" && + issue !== null && + "message" in issue && + typeof issue.message === "string" + ) { + const pathValue = + "path" in issue && + Array.isArray(issue.path) && + issue.path.every( + (segment) => + typeof segment === "string" || typeof segment === "number", + ) + ? [...issue.path] + : []; + return { + path: pathValue, + message: issue.message, + } satisfies EnvIssue; + } + return { + path: [], + message: "Unknown environment validation error", + } satisfies EnvIssue; + }); + throw new EnvValidationError(normalizedIssues); + }, + }); +}; + +type Env = ReturnType; + +let cachedEnv: Env | null = null; + +const loadEnv = (): Env => { + if (cachedEnv === null) { + cachedEnv = buildEnv(); + } + return cachedEnv; +}; + +export const getEnv = (): Env => { + return loadEnv(); +}; + +export const resetEnvCache = (): void => { + cachedEnv = null; +}; + +const describeIssue = (issue: EnvIssue): string => { + const path = issue.path.join("."); + + if (path === "LISTEE_API_URL") { + return "LISTEE_API_URL is not set. Please configure the environment variable before continuing."; + } + + return issue.message; +}; + +export const checkEnv = (): void => { + try { + void getEnv(); + } catch (error) { + if (error instanceof EnvValidationError) { + const firstIssue = error.issues[0]; + const message = + firstIssue !== undefined + ? describeIssue(firstIssue) + : "Invalid environment variables"; + throw new Error(message); + } + throw error; + } +}; diff --git a/src/index.ts b/src/index.ts index b080f1f..e404dbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,10 @@ -import "dotenv/config"; import { Command } from "commander"; import { registerAuthCommand } from "./commands/auth.js"; +import { registerCategoryCommand } from "./commands/categories.js"; +import { registerTaskCommand } from "./commands/tasks.js"; +import { checkEnv } from "./env.js"; + +checkEnv(); const program = new Command(); @@ -12,6 +16,8 @@ program .version("0.0.1"); registerAuthCommand(program); +registerCategoryCommand(program); +registerTaskCommand(program); const main = async (): Promise => { try { diff --git a/src/services/api-base.ts b/src/services/api-base.ts new file mode 100644 index 0000000..1b8301c --- /dev/null +++ b/src/services/api-base.ts @@ -0,0 +1,90 @@ +import { EnvValidationError, getEnv } from "../env.js"; + +type ParsedPayloadJson = { type: "json"; body: unknown }; +type ParsedPayloadText = { type: "text"; body: string }; +type ParsedPayloadEmpty = { type: "empty"; body: null }; + +export type ParsedPayload = + | ParsedPayloadJson + | ParsedPayloadText + | ParsedPayloadEmpty; + +export const getListeeApiBaseUrl = (): URL => { + try { + const env = getEnv(); + return new URL(env.LISTEE_API_URL); + } catch (error) { + if (error instanceof EnvValidationError) { + const listeeApiIssue = error.issues.find((issue) => { + return issue.path.join(".") === "LISTEE_API_URL"; + }); + if (listeeApiIssue !== undefined) { + const message = listeeApiIssue.message.includes( + "expected string, received undefined", + ) + ? "LISTEE_API_URL is not set. Please configure the environment variable before continuing." + : listeeApiIssue.message; + throw new Error(message); + } + } + throw error; + } +}; + +export const buildListeeApiUrl = (path: string): URL => { + if (!path.startsWith("/")) { + throw new Error("API path must start with '/'."); + } + const base = getListeeApiBaseUrl(); + const baseHref = base.href.endsWith("/") ? base.href : `${base.href}/`; + const normalizedPath = path.replace(/^\//u, ""); + return new URL(normalizedPath, baseHref); +}; + +export const readApiPayload = async ( + response: Response, +): Promise => { + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); + if (text.trim().length === 0) { + return { type: "empty", body: null } satisfies ParsedPayload; + } + + if (contentType.toLowerCase().includes("application/json")) { + try { + const parsed = JSON.parse(text); + return { type: "json", body: parsed } satisfies ParsedPayload; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new Error(`Failed to parse API response as JSON: ${message}`); + } + } + + return { type: "text", body: text } satisfies ParsedPayload; +}; + +export const extractApiErrorMessage = ( + payload: ParsedPayload, + fallback: string, +): string => { + if (payload.type === "json") { + const body = payload.body; + if (typeof body === "object" && body !== null) { + const error = (body as { error?: unknown }).error; + if (error !== undefined) { + return typeof error === "string" ? error : String(error); + } + } + return fallback; + } + + if (payload.type === "text") { + const snippet = + payload.body.length > 200 + ? `${payload.body.slice(0, 200)}…` + : payload.body; + return `${fallback}; raw response: ${snippet}`; + } + + return fallback; +}; diff --git a/src/services/api-client.ts b/src/services/api-client.ts new file mode 100644 index 0000000..f5187c8 --- /dev/null +++ b/src/services/api-client.ts @@ -0,0 +1,69 @@ +import { + buildListeeApiUrl, + extractApiErrorMessage, + readApiPayload, +} from "./api-base.js"; +import { getAuthenticatedAccessToken } from "./auth-service.js"; + +type AuthenticatedContext = { + readonly accessToken: string; + readonly userId: string; + readonly authorizationValue: string; +}; + +export const createAuthenticatedContext = async ( + email?: string, +): Promise => { + const tokenResult = await getAuthenticatedAccessToken(email); + const accessToken = tokenResult.accessToken; + const userId = tokenResult.userId; + + return { + accessToken, + userId, + authorizationValue: accessToken, + }; +}; + +const buildHeaders = (authorizationValue: string): HeadersInit => { + return { + Authorization: `Bearer ${authorizationValue}`, + Accept: "application/json", + }; +}; + +const buildUrl = (path: string): URL => { + return buildListeeApiUrl(path); +}; + +export const requestJson = async ( + path: string, + authorizationValue: string, + init?: RequestInit, +): Promise => { + const url = buildUrl(path); + const response = await fetch(url, { + ...init, + headers: { + ...buildHeaders(authorizationValue), + ...(init?.headers ?? {}), + }, + }); + + const payload = await readApiPayload(response); + if (!response.ok) { + const message = extractApiErrorMessage( + payload, + `status ${response.status}`, + ); + throw new Error(`API request failed: ${message}`); + } + + if (payload.type !== "json") { + throw new Error( + `API request failed: Expected JSON response but received ${payload.type}`, + ); + } + + return payload.body; +}; diff --git a/src/services/auth-service.test.ts b/src/services/auth-service.test.ts index e7c8e09..f0d9a89 100644 --- a/src/services/auth-service.test.ts +++ b/src/services/auth-service.test.ts @@ -1,53 +1,40 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { Buffer } from "node:buffer"; +import { resetEnvCache } from "../env.js"; import { type AccessTokenResult, - ensureSupabaseConfig, + ensureListeeApiConfig, parseSignupFragment, + toAuthenticatedAccessTokenResult, } from "./auth-service.js"; const ORIGINAL_ENV = { ...process.env }; +if (ORIGINAL_ENV.LISTEE_API_URL === undefined) { + ORIGINAL_ENV.LISTEE_API_URL = "https://api.example.dev"; +} + const resetEnv = (): void => { process.env = { ...ORIGINAL_ENV }; + resetEnvCache(); }; 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; +describe("ensureListeeApiConfig", () => { + it("throws when LISTEE_API_URL is missing", () => { + delete process.env.LISTEE_API_URL; 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(); + ensureListeeApiConfig(); + }).toThrow("LISTEE_API_URL is not set"); }); - 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"; + it("does not throw when LISTEE_API_URL is set", () => { + process.env.LISTEE_API_URL = "https://api.example.dev"; - expect(() => ensureSupabaseConfig()).not.toThrow(); + expect(() => ensureListeeApiConfig()).not.toThrow(); }); }); @@ -97,3 +84,64 @@ describe("parseSignupFragment", () => { }).toThrow("Confirmation URL is missing access_token."); }); }); + +describe("toAuthenticatedAccessTokenResult", () => { + const encodeSegment = (value: unknown): string => { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + }; + + const header = encodeSegment({ alg: "ES256", typ: "JWT" }); + + const buildToken = (payload: Record): string => { + const payloadSegment = encodeSegment(payload); + const signature = encodeSegment({ sig: "signature" }); + return `${header}.${payloadSegment}.${signature}`; + }; + + it("returns enriched access token details when payload is valid", () => { + const epoch = Math.floor(Date.now() / 1000); + const payload = { + sub: "user-123", + email: "user@example.com", + iss: "https://example.supabase.co/auth/v1", + aud: "authenticated", + role: "authenticated", + exp: epoch + 3600, + iat: epoch, + }; + const accessToken = buildToken(payload); + const input: AccessTokenResult = { + accessToken, + expiresIn: 3600, + tokenType: "bearer", + }; + + const result = toAuthenticatedAccessTokenResult(input); + + expect(result.userId).toBe("user-123"); + expect(result.token.email).toBe("user@example.com"); + expect(result.accessToken).toBe(accessToken); + }); + + it("throws when the JWT payload is missing required subject", () => { + const epoch = Math.floor(Date.now() / 1000); + const payload = { + email: "user@example.com", + iss: "https://example.supabase.co/auth/v1", + aud: "authenticated", + role: "authenticated", + exp: epoch + 3600, + iat: epoch, + }; + const accessToken = buildToken(payload); + const input: AccessTokenResult = { + accessToken, + expiresIn: 3600, + tokenType: "bearer", + }; + + expect(() => { + toAuthenticatedAccessTokenResult(input); + }).toThrow("Access token payload structure is invalid."); + }); +}); diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 5337115..c5aa120 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -3,16 +3,21 @@ import { type AccountProvisioner, createAccountProvisioner, } from "@listee/auth"; -import type { SupabaseToken } from "@listee/types"; import { AsyncEntry, findCredentials } from "@napi-rs/keyring"; +import { checkEnv, getEnv } from "../env.js"; import type { AccessTokenResult, AuthStatus, + AuthTokenClaims, + AuthTokenResponse, SignupRedirect, StoredCredential, - SupabaseErrorPayload, - SupabaseTokenResponse, } from "../types/auth.js"; +import { + buildListeeApiUrl, + extractApiErrorMessage, + readApiPayload, +} from "./api-base.js"; const DEFAULT_SERVICE_NAME = "listee-cli"; let cachedAccountProvisioner: AccountProvisioner | null = null; @@ -36,34 +41,15 @@ const isNumber = (value: unknown): value is number => { return typeof value === "number" && Number.isFinite(value); }; -const isSupabaseErrorPayload = ( - value: unknown, -): value is SupabaseErrorPayload => { +const isAuthTokenResponse = (value: unknown): value is AuthTokenResponse => { 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; + const accessToken = value.accessToken; + const refreshToken = value.refreshToken; + const tokenType = value.tokenType; + const expiresIn = value.expiresIn; return ( isString(accessToken) && @@ -93,90 +79,63 @@ const listStoredCredentials = (service: string): StoredCredential[] => { } }; -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(); +export const ensureListeeApiConfig = (): void => { + checkEnv(); + void getEnv().LISTEE_API_URL; }; 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 env = getEnv(); + return env.LISTEE_CLI_KEYCHAIN_SERVICE ?? DEFAULT_SERVICE_NAME; }; -const readJson = async (response: Response): Promise => { - const raw = await response.text(); - if (raw.trim().length === 0) { - return null; - } +type AuthRequestBody = Record; - try { - return JSON.parse(raw); - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to parse Supabase response: ${error.message}`); +const requestAuthJson = async ( + path: string, + body: AuthRequestBody, +): Promise => { + const url = buildListeeApiUrl(path); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + + const payload = await readApiPayload(response); + if (response.ok) { + if (payload.type === "json") { + return payload.body; + } + if (payload.type === "empty") { + return null; } throw new Error( - "Failed to parse Supabase response due to an unknown error.", + `Listee API auth request expected JSON or empty response but received ${payload.type}`, ); } -}; -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(": "); + const message = extractApiErrorMessage(payload, `status ${response.status}`); + throw new Error(`Listee API auth request failed: ${message}`); +}; - if (details.length > 0) { - return details; - } +const toAuthTokenResponse = (payload: unknown): AuthTokenResponse => { + if (isAuthTokenResponse(payload)) { + return payload; } - return `Supabase request failed with status ${status}`; -}; + if ( + isRecord(payload) && + "data" in payload && + isAuthTokenResponse((payload as { data: unknown }).data) + ) { + return (payload as { data: AuthTokenResponse }).data; + } -const buildSupabaseHeaders = (): Record => { - const publishableKey = getSupabasePublishableKey(); - return { - "Content-Type": "application/json", - apikey: publishableKey, - Authorization: `Bearer ${publishableKey}`, - }; + throw new Error("Listee API auth response did not include token details."); }; const getFragmentParams = (fragment: string): URLSearchParams => { @@ -208,7 +167,7 @@ const decodeJwtPayload = (token: string): unknown => { } }; -const isSupabaseTokenPayload = (payload: unknown): payload is SupabaseToken => { +const isAuthTokenPayload = (payload: unknown): payload is AuthTokenClaims => { if (!isRecord(payload)) { return false; } @@ -239,16 +198,16 @@ const isSupabaseTokenPayload = (payload: unknown): payload is SupabaseToken => { return true; }; -const decodeSupabaseToken = (token: string): SupabaseToken => { +const decodeAuthToken = (token: string): AuthTokenClaims => { const payload = decodeJwtPayload(token); - if (!isSupabaseTokenPayload(payload)) { + if (!isAuthTokenPayload(payload)) { throw new Error("Access token payload structure is invalid."); } return payload; }; -const extractSubjectFromTokenPayload = (payload: SupabaseToken): string => { +const extractSubjectFromTokenPayload = (payload: AuthTokenClaims): string => { const subjectValue = payload.sub; if (!isString(subjectValue) || subjectValue.trim().length === 0) { throw new Error("Access token payload did not include a user id."); @@ -258,7 +217,7 @@ const extractSubjectFromTokenPayload = (payload: SupabaseToken): string => { }; const extractEmailFromAccessToken = (token: string): string => { - const payload = decodeSupabaseToken(token); + const payload = decodeAuthToken(token); const email = payload.email; if (!isString(email) || email.trim().length === 0) { throw new Error("Access token payload did not include an email."); @@ -364,59 +323,37 @@ const deleteAllStoredCredentials = async (): Promise => { 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)); + const requestBody: AuthRequestBody = { + email, + password, + }; + if (redirectUrl !== undefined) { + requestBody.redirectUrl = redirectUrl; } + await requestAuthJson("/auth/signup", requestBody); }; export const login = async ( email: string, password: string, ): Promise => { - const response = await requestSupabase("auth/v1/token?grant_type=password", { + const payload = await requestAuthJson("/auth/login", { email, password, }); + const tokenResponse = toAuthTokenResponse(payload); - 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); + await storeRefreshToken(email, tokenResponse.refreshToken); return { - accessToken: payload.access_token, - expiresIn: payload.expires_in, - tokenType: payload.token_type, + accessToken: tokenResponse.accessToken, + expiresIn: tokenResponse.expiresIn, + tokenType: tokenResponse.tokenType, }; }; @@ -428,33 +365,51 @@ export const getAccessToken = async ( 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 requestAuthJson("/auth/token", { + refreshToken: credential.refreshToken, + }); + const tokenResponse = toAuthTokenResponse(payload); - const payload = await readJson(response); - if (!response.ok) { - throw new Error(formatSupabaseError(payload, response.status)); - } + await storeRefreshToken(credential.account, tokenResponse.refreshToken); - if (!isSupabaseTokenResponse(payload)) { - throw new Error( - "Unexpected response from Supabase while refreshing the session.", - ); + return { + accessToken: tokenResponse.accessToken, + expiresIn: tokenResponse.expiresIn, + tokenType: tokenResponse.tokenType, + }; +}; + +export type AuthenticatedAccessTokenResult = AccessTokenResult & { + userId: string; + token: AuthTokenClaims; +}; + +export const toAuthenticatedAccessTokenResult = ( + tokenResult: AccessTokenResult, +): AuthenticatedAccessTokenResult => { + const accessToken = tokenResult.accessToken.trim(); + if (accessToken.length === 0) { + throw new Error("Access token is empty."); } - await storeRefreshToken(credential.account, payload.refresh_token); + const token = decodeAuthToken(accessToken); + const userId = extractSubjectFromTokenPayload(token); return { - accessToken: payload.access_token, - expiresIn: payload.expires_in, - tokenType: payload.token_type, + ...tokenResult, + accessToken, + userId, + token, }; }; +export const getAuthenticatedAccessToken = async ( + email?: string, +): Promise => { + const tokenResult = await getAccessToken(email); + return toAuthenticatedAccessTokenResult(tokenResult); +}; + export const logout = async (): Promise => { return deleteAllStoredCredentials(); }; @@ -485,7 +440,7 @@ export const completeSignupFromFragment = async ( const provisionSignupAccount = async ( result: SignupRedirect, ): Promise => { - const tokenPayload = decodeSupabaseToken(result.accessToken); + const tokenPayload = decodeAuthToken(result.accessToken); const userId = extractSubjectFromTokenPayload(tokenPayload); const provisioner = getAccountProvisioner(); diff --git a/src/services/category-api.ts b/src/services/category-api.ts new file mode 100644 index 0000000..9e93523 --- /dev/null +++ b/src/services/category-api.ts @@ -0,0 +1,208 @@ +import type { Category as CategoryDb } from "@listee/types"; +import { createAuthenticatedContext, requestJson } from "./api-client.js"; + +export type Category = Omit & { + readonly createdAt: string; + readonly updatedAt: string; +}; + +type ListCategoriesResponse = { + readonly data: readonly Category[]; + readonly meta: { + readonly nextCursor: string | null; + readonly hasMore: boolean; + }; +}; + +type CategoryDetailResponse = { + readonly data: Category; +}; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + +const isCategory = (value: unknown): value is Category => { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === "string" && + typeof value.name === "string" && + typeof value.kind === "string" && + typeof value.createdBy === "string" && + typeof value.updatedBy === "string" && + typeof value.createdAt === "string" && + typeof value.updatedAt === "string" + ); +}; + +const toCategory = (value: unknown): Category => { + if (!isCategory(value)) { + throw new Error("API response did not include valid category data."); + } + return { + id: value.id, + name: value.name, + kind: value.kind, + createdBy: value.createdBy, + updatedBy: value.updatedBy, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + }; +}; + +const toCategoryArray = (values: unknown): readonly Category[] => { + if (!Array.isArray(values)) { + throw new Error("API response did not include a category list."); + } + return values.map((value) => toCategory(value)); +}; + +const toListResponse = (payload: unknown): ListCategoriesResponse => { + if (!isRecord(payload)) { + throw new Error("Invalid list categories response."); + } + + const data = toCategoryArray(payload.data); + const metaValue = payload.meta; + if (!isRecord(metaValue)) { + throw new Error("List categories response is missing pagination metadata."); + } + + const nextCursorValue = + metaValue.nextCursor === null || typeof metaValue.nextCursor === "string" + ? metaValue.nextCursor + : undefined; + if (metaValue.hasMore !== true && metaValue.hasMore !== false) { + throw new Error("List categories response has invalid hasMore flag."); + } + + if (nextCursorValue === undefined) { + throw new Error("List categories response has invalid cursor value."); + } + + return { + data, + meta: { + nextCursor: nextCursorValue, + hasMore: metaValue.hasMore, + }, + }; +}; + +const toCategoryDetail = (payload: unknown): CategoryDetailResponse => { + if (!isRecord(payload)) { + throw new Error("Invalid category detail response."); + } + return { + data: toCategory(payload.data), + }; +}; + +export type ListCategoriesParams = { + readonly email?: string; + readonly limit?: number; + readonly cursor?: string | null; +}; + +export type ListCategoriesResult = ListCategoriesResponse; + +export const listCategories = async ( + params: ListCategoriesParams = {}, +): Promise => { + const context = await createAuthenticatedContext(params.email); + const searchParams = new URLSearchParams(); + if (params.limit !== undefined) { + searchParams.set("limit", String(params.limit)); + } + if (params.cursor !== undefined && params.cursor !== null) { + searchParams.set("cursor", params.cursor); + } + const query = searchParams.toString(); + const pathBase = `/users/${encodeURIComponent(context.userId)}/categories`; + const path = query.length === 0 ? pathBase : `${pathBase}?${query}`; + const payload = await requestJson(path, context.authorizationValue); + return toListResponse(payload); +}; + +export type GetCategoryParams = { + readonly email?: string; + readonly categoryId: string; +}; + +export const getCategory = async ( + params: GetCategoryParams, +): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/categories/${encodeURIComponent(params.categoryId)}`; + const payload = await requestJson(path, context.authorizationValue); + return toCategoryDetail(payload); +}; + +export type CreateCategoryParams = { + readonly email?: string; + readonly name: string; + readonly kind?: string; +}; + +export const createCategory = async ( + params: CreateCategoryParams, +): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/users/${encodeURIComponent(context.userId)}/categories`; + const payload = await requestJson(path, context.authorizationValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: params.name, + kind: params.kind ?? "user", + }), + }); + + return toCategoryDetail(payload).data; +}; + +export type UpdateCategoryParams = { + readonly email?: string; + readonly categoryId: string; + readonly name?: string; +}; + +export const updateCategory = async ( + params: UpdateCategoryParams, +): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/categories/${encodeURIComponent(params.categoryId)}`; + if (params.name === undefined) { + throw new Error("No category fields were provided for update."); + } + + const payload = await requestJson(path, context.authorizationValue, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: params.name }), + }); + + return toCategoryDetail(payload).data; +}; + +export type DeleteCategoryParams = { + readonly email?: string; + readonly categoryId: string; +}; + +export const deleteCategory = async ( + params: DeleteCategoryParams, +): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/categories/${encodeURIComponent(params.categoryId)}`; + await requestJson(path, context.authorizationValue, { + method: "DELETE", + }); +}; diff --git a/src/services/task-api.ts b/src/services/task-api.ts new file mode 100644 index 0000000..b44b65f --- /dev/null +++ b/src/services/task-api.ts @@ -0,0 +1,181 @@ +import type { Task as TaskDb } from "@listee/types"; +import { createAuthenticatedContext, requestJson } from "./api-client.js"; + +export type Task = Omit & { + readonly createdAt: string; + readonly updatedAt: string; +}; + +type TaskListResponse = { + readonly data: readonly Task[]; +}; + +type TaskDetailResponse = { + readonly data: Task; +}; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + +const isTask = (value: unknown): value is Task => { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === "string" && + typeof value.name === "string" && + (typeof value.description === "string" || value.description === null) && + typeof value.isChecked === "boolean" && + typeof value.categoryId === "string" && + typeof value.createdBy === "string" && + typeof value.updatedBy === "string" && + typeof value.createdAt === "string" && + typeof value.updatedAt === "string" + ); +}; + +const toTask = (value: unknown): Task => { + if (!isTask(value)) { + throw new Error("API response did not include valid task data."); + } + return { + id: value.id, + name: value.name, + description: value.description, + isChecked: value.isChecked, + categoryId: value.categoryId, + createdBy: value.createdBy, + updatedBy: value.updatedBy, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + }; +}; + +const toTaskList = (payload: unknown): TaskListResponse => { + if (!isRecord(payload)) { + throw new Error("Invalid task list response."); + } + const data = payload.data; + if (!Array.isArray(data)) { + throw new Error("Task list response is missing task data."); + } + return { + data: data.map((item) => toTask(item)), + }; +}; + +const toTaskDetail = (payload: unknown): TaskDetailResponse => { + if (!isRecord(payload)) { + throw new Error("Invalid task detail response."); + } + return { + data: toTask(payload.data), + }; +}; + +export type ListTasksParams = { + readonly email?: string; + readonly categoryId: string; +}; + +export const listTasksByCategory = async ( + params: ListTasksParams, +): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/categories/${encodeURIComponent(params.categoryId)}/tasks`; + const payload = await requestJson(path, context.authorizationValue); + return toTaskList(payload); +}; + +export type GetTaskParams = { + readonly email?: string; + readonly taskId: string; +}; + +export const getTask = async ( + params: GetTaskParams, +): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/tasks/${encodeURIComponent(params.taskId)}`; + const payload = await requestJson(path, context.authorizationValue); + return toTaskDetail(payload); +}; + +export type CreateTaskParams = { + readonly email?: string; + readonly categoryId: string; + readonly name: string; + readonly description?: string | null; + readonly isChecked?: boolean; +}; + +export const createTask = async (params: CreateTaskParams): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/categories/${encodeURIComponent(params.categoryId)}/tasks`; + const description = + params.description === undefined ? null : params.description; + const body = { + name: params.name, + description, + ...(params.isChecked === undefined ? {} : { isChecked: params.isChecked }), + }; + + const payload = await requestJson(path, context.authorizationValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return toTaskDetail(payload).data; +}; + +export type UpdateTaskParams = { + readonly email?: string; + readonly taskId: string; + readonly name?: string; + readonly description?: string | null; + readonly isChecked?: boolean; +}; + +export const updateTask = async (params: UpdateTaskParams): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/tasks/${encodeURIComponent(params.taskId)}`; + const body = { + ...(params.name === undefined ? {} : { name: params.name }), + ...(params.description === undefined + ? {} + : { description: params.description }), + ...(params.isChecked === undefined ? {} : { isChecked: params.isChecked }), + }; + + if (Object.keys(body).length === 0) { + throw new Error("No task fields were provided for update."); + } + + const payload = await requestJson(path, context.authorizationValue, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return toTaskDetail(payload).data; +}; + +export type DeleteTaskParams = { + readonly email?: string; + readonly taskId: string; +}; + +export const deleteTask = async (params: DeleteTaskParams): Promise => { + const context = await createAuthenticatedContext(params.email); + const path = `/tasks/${encodeURIComponent(params.taskId)}`; + await requestJson(path, context.authorizationValue, { + method: "DELETE", + }); +}; diff --git a/src/types/auth.ts b/src/types/auth.ts index e3233c0..ffe3402 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,16 +1,10 @@ -export type SupabaseTokenResponse = { - access_token: string; - refresh_token: string; - token_type: string; - expires_in: number; -}; +export type { SupabaseToken as AuthTokenClaims } from "@listee/types"; -export type SupabaseErrorPayload = { - error?: string; - error_description?: string; - msg?: string; - message?: string; - status?: number; +export type AuthTokenResponse = { + accessToken: string; + refreshToken: string; + tokenType: string; + expiresIn: number; }; export type StoredCredential = {