From 4f8e49024597bb93eeac151243c78ad363f19703 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 22 Oct 2025 15:16:34 +0900 Subject: [PATCH 01/11] feat(cli): add category and task commands --- .env.example | 2 + README.md | 12 +++ src/commands/auth.ts | 33 +++++-- src/commands/categories.ts | 113 +++++++++++++++++++++++ src/commands/tasks.ts | 88 ++++++++++++++++++ src/index.ts | 4 + src/services/api-client.ts | 169 +++++++++++++++++++++++++++++++++++ src/services/category-api.ts | 142 +++++++++++++++++++++++++++++ src/services/task-api.ts | 104 +++++++++++++++++++++ 9 files changed, 661 insertions(+), 6 deletions(-) create mode 100644 src/commands/categories.ts create mode 100644 src/commands/tasks.ts create mode 100644 src/services/api-client.ts create mode 100644 src/services/category-api.ts create mode 100644 src/services/task-api.ts diff --git a/.env.example b/.env.example index a64d413..5666e2b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ SUPABASE_URL= SUPABASE_PUBLISHABLE_KEY= +LISTEE_API_URL= # LISTEE_CLI_KEYCHAIN_SERVICE=listee-cli +# LISTEE_API_AUTH_BEARER_MODE=user-id diff --git a/README.md b/README.md index 0a20f02..7bbda19 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,11 @@ 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" +# optional: choose bearer header value ("user-id" for local API mocks, "access-token" for real JWT) +export LISTEE_API_AUTH_BEARER_MODE="user-id" ``` Never commit secrets; the repo defaults to reading from the process environment. @@ -32,6 +35,10 @@ 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 tasks list --category [--email you@example.com] +listee tasks show [--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 +56,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/src/commands/auth.ts b/src/commands/auth.ts index 1b3fbc2..c510755 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -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}`); @@ -103,12 +107,19 @@ const startLoopbackServer = async (): Promise => { let settled = false; const server = createServer((req, res) => { - const finish = (status: number, body: string, contentType = "text/html"): void => { + 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 => { + const respondWithJson = ( + status: number, + payload: { title: string; message: string }, + ): void => { finish(status, JSON.stringify(payload), "application/json"); }; @@ -131,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."); @@ -174,7 +188,11 @@ const startLoopbackServer = async (): Promise => { }); const address = server.address(); - if (address === null || typeof address !== "object" || address.port === undefined) { + if ( + address === null || + typeof address !== "object" || + address.port === undefined + ) { server.close(); throw new Error("Failed to determine loopback server port."); } @@ -200,7 +218,8 @@ const startLoopbackServer = async (): Promise => { return { redirectUrl: `http://${LOOPBACK_HOST}:${address.port}/callback`, - waitForConfirmation: () => waitForConfirmation.finally(() => clearTimeout(timeout)), + waitForConfirmation: () => + waitForConfirmation.finally(() => clearTimeout(timeout)), shutdown, }; }; @@ -344,7 +363,9 @@ const signupAction = async (options: EmailOption): Promise => { try { await signup(email, password, loopback.redirectUrl); - console.log("πŸ“© Confirmation email sent. Keep this terminal open while you click the link."); + 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 { diff --git a/src/commands/categories.ts b/src/commands/categories.ts new file mode 100644 index 0000000..b1adfc3 --- /dev/null +++ b/src/commands/categories.ts @@ -0,0 +1,113 @@ +import type { Command } from "commander"; +import { getCategory, listCategories } 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 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}]`); + } +}; + +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 response = await getCategory({ + email: options.email, + categoryId, + }); + const category = response.data; + 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}`); + }, + ), + ); +}; diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts new file mode 100644 index 0000000..2a637f8 --- /dev/null +++ b/src/commands/tasks.ts @@ -0,0 +1,88 @@ +import type { Command } from "commander"; +import { getTask, listTasksByCategory } 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 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}`); + } +}; + +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 response = await listTasksByCategory({ + categoryId: options.category, + 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 response = await getTask({ taskId, email: options.email }); + const task = response.data; + 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}`); + }), + ); +}; diff --git a/src/index.ts b/src/index.ts index b080f1f..6e68d75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ 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"; const program = new Command(); @@ -12,6 +14,8 @@ program .version("0.0.1"); registerAuthCommand(program); +registerCategoryCommand(program); +registerTaskCommand(program); const main = async (): Promise => { try { diff --git a/src/services/api-client.ts b/src/services/api-client.ts new file mode 100644 index 0000000..778e06b --- /dev/null +++ b/src/services/api-client.ts @@ -0,0 +1,169 @@ +import { Buffer } from "node:buffer"; +import { getAccessToken } from "./auth-service.js"; + +type JwtClaims = { + readonly sub?: string; + readonly email?: string; +}; + +type AuthenticatedContext = { + readonly accessToken: string; + readonly userId: string; + readonly authorizationValue: string; +}; + +const isNonEmptyString = (value: unknown): value is string => { + return typeof value === "string" && value.trim().length > 0; +}; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + +const getEnvValue = (key: string): string => { + const raw = process.env[key]; + if (raw === undefined || raw.trim().length === 0) { + throw new Error( + `${key} is not set. Please configure the environment variable before continuing.`, + ); + } + return raw.trim(); +}; + +const getApiBaseUrl = (): URL => { + const rawUrl = getEnvValue("LISTEE_API_URL"); + return new URL(rawUrl); +}; + +const decodeJwtClaims = (token: string): JwtClaims => { + const segments = token.split("."); + if (segments.length < 2) { + throw new Error("Access token is malformed."); + } + + const payloadSegment = segments[1]; + try { + const decoded = Buffer.from(payloadSegment, "base64url").toString("utf8"); + const parsed = JSON.parse(decoded); + if (!isRecord(parsed)) { + throw new Error("Access token payload is not an object."); + } + + const subValue = + typeof parsed.sub === "string" && parsed.sub.length > 0 + ? parsed.sub + : undefined; + const emailValue = + typeof parsed.email === "string" && parsed.email.length > 0 + ? parsed.email + : undefined; + + return { + ...(subValue === undefined ? {} : { sub: subValue }), + ...(emailValue === undefined ? {} : { email: emailValue }), + }; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new Error(`Failed to decode access token payload: ${message}`); + } +}; + +const readJson = async (response: Response): Promise => { + const text = await response.text(); + if (text.trim().length === 0) { + return null; + } + try { + return JSON.parse(text); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new Error(`Failed to parse API response: ${message}`); + } +}; + +const getAuthorizationMode = (): "access-token" | "user-id" => { + const raw = process.env.LISTEE_API_AUTH_BEARER_MODE; + if (raw === undefined || raw.trim().length === 0) { + return "user-id"; + } + + const value = raw.trim().toLowerCase(); + if (value === "access-token" || value === "user-id") { + return value; + } + + throw new Error( + "LISTEE_API_AUTH_BEARER_MODE must be either 'access-token' or 'user-id'.", + ); +}; + +export const createAuthenticatedContext = async ( + email?: string, +): Promise => { + const tokenResult = await getAccessToken(email); + const claims = decodeJwtClaims(tokenResult.accessToken); + if (!isNonEmptyString(claims.sub)) { + throw new Error("Access token does not include the user identifier."); + } + + const mode = getAuthorizationMode(); + const authorizationValue = + mode === "user-id" ? claims.sub : tokenResult.accessToken; + + return { + accessToken: tokenResult.accessToken, + userId: claims.sub, + authorizationValue, + }; +}; + +const buildHeaders = (authorizationValue: string): HeadersInit => { + return { + Authorization: `Bearer ${authorizationValue}`, + Accept: "application/json", + }; +}; + +const buildUrl = (path: string): URL => { + if (!path.startsWith("/")) { + throw new Error("API path must start with '/' ."); + } + const base = getApiBaseUrl(); + const baseHref = base.href.endsWith("/") ? base.href : `${base.href}/`; + const normalizedPath = path.replace(/^\//u, ""); + return new URL(normalizedPath, baseHref); +}; + +const extractErrorMessage = (payload: unknown, fallback: string): string => { + if (!isRecord(payload)) { + return fallback; + } + const error = payload.error; + if (error === undefined) { + return fallback; + } + return typeof error === "string" ? error : String(error); +}; + +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 readJson(response); + if (!response.ok) { + const message = extractErrorMessage(payload, `status ${response.status}`); + throw new Error(`API request failed: ${message}`); + } + + return payload; +}; diff --git a/src/services/category-api.ts b/src/services/category-api.ts new file mode 100644 index 0000000..75d17d0 --- /dev/null +++ b/src/services/category-api.ts @@ -0,0 +1,142 @@ +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); +}; diff --git a/src/services/task-api.ts b/src/services/task-api.ts new file mode 100644 index 0000000..9d20079 --- /dev/null +++ b/src/services/task-api.ts @@ -0,0 +1,104 @@ +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); +}; From b2e393b8da2ca22dbdb27b6434f3d3c8499850c8 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 22 Oct 2025 15:58:38 +0900 Subject: [PATCH 02/11] feat: add category and task create commands --- README.md | 2 + src/commands/categories.ts | 63 ++++++++++++++++++++++---- src/commands/tasks.ts | 88 +++++++++++++++++++++++++++++++----- src/services/category-api.ts | 25 ++++++++++ src/services/task-api.ts | 30 ++++++++++++ 5 files changed, 187 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7bbda19..c16735e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ 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 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] ``` diff --git a/src/commands/categories.ts b/src/commands/categories.ts index b1adfc3..1f50c5e 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -1,5 +1,9 @@ import type { Command } from "commander"; -import { getCategory, listCategories } from "../services/category-api.js"; +import { + createCategory, + getCategory, + listCategories, +} from "../services/category-api.js"; const ensurePositiveInteger = (value: string): number => { if (!/^\d+$/.test(value)) { @@ -12,6 +16,14 @@ const ensurePositiveInteger = (value: string): number => { 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 { @@ -45,6 +57,24 @@ const printCategories = ( } }; +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") @@ -99,14 +129,29 @@ export const registerCategoryCommand = (program: Command): void => { email: options.email, categoryId, }); - const category = response.data; - 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}`); + 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); }, ), ); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 2a637f8..3fa7aea 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,5 +1,9 @@ import type { Command } from "commander"; -import { getTask, listTasksByCategory } from "../services/task-api.js"; +import { + createTask, + getTask, + listTasksByCategory, +} from "../services/task-api.js"; const execute = (task: (...args: T) => Promise) => { return async (...args: T): Promise => { @@ -16,6 +20,14 @@ const execute = (task: (...args: T) => Promise) => { }; }; +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; @@ -37,6 +49,29 @@ const printTasks = ( } }; +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") @@ -72,17 +107,46 @@ export const registerTaskCommand = (program: Command): void => { .action( execute(async (taskId: string, options: { readonly email?: string }) => { const response = await getTask({ taskId, email: options.email }); - const task = response.data; - 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}`); + 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); + }, + ), + ); }; diff --git a/src/services/category-api.ts b/src/services/category-api.ts index 75d17d0..7965cbd 100644 --- a/src/services/category-api.ts +++ b/src/services/category-api.ts @@ -140,3 +140,28 @@ export const getCategory = async ( 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; +}; diff --git a/src/services/task-api.ts b/src/services/task-api.ts index 9d20079..32dd73b 100644 --- a/src/services/task-api.ts +++ b/src/services/task-api.ts @@ -102,3 +102,33 @@ export const getTask = async ( 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; +}; From 7afd53e46dbba2aa5e1a33182aab7a6963880bb5 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 22 Oct 2025 16:24:57 +0900 Subject: [PATCH 03/11] docs: clarify supabase publishable key usage --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c16735e..0702733 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ bun install 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 SUPABASE_PUBLISHABLE_KEY="your-publishable-key" +# legacy fallback (deprecated, only if publishable key is unavailable) +# 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" From 181df4bf313db571445a4fee027eea7882f0fc56 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 22 Oct 2025 16:28:11 +0900 Subject: [PATCH 04/11] fix: narrow loopback server address --- src/commands/auth.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index c510755..333203f 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -188,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."); } @@ -217,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, From 5b08b95d859012ca14c06d60a2a89dc0f977bd50 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sat, 1 Nov 2025 22:27:28 +0900 Subject: [PATCH 05/11] refactor(cli): align category and task commands with api --- README.md | 4 ++ bun.lock | 10 ++-- package.json | 4 +- src/commands/categories.ts | 50 +++++++++++++++++++ src/commands/tasks.ts | 93 ++++++++++++++++++++++++++++++++++++ src/services/category-api.ts | 41 ++++++++++++++++ src/services/task-api.ts | 47 ++++++++++++++++++ 7 files changed, 242 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0702733..2ad49b2 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,13 @@ 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. diff --git a/bun.lock b/bun.lock index c190242..8a932b5 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,8 @@ "": { "name": "listee-cli", "dependencies": { - "@listee/auth": "^0.2.3", - "@listee/types": "^0.2.3", + "@listee/auth": "^0.3.0", + "@listee/types": "^0.3.0", "@napi-rs/keyring": "^1.2.0", "commander": "^12.1.0", "dotenv": "^16.4.5", @@ -37,11 +37,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.3.0", "", { "dependencies": { "@listee/db": "^0.3.0", "@listee/types": "^0.3.0", "jose": "^5.2.3" } }, "sha512-esTAtrv+SR0gtHg/HPCyZvG2NG+bY//h3U6tZuy4SuVsN/sZ0060c47GwVdV7Ety4j3no3HZDnGTAPpN1xir7A=="], - "@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.3.0", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-lokU4wQCp0Wr73U4qSe7Y5Zn6H8EdIvnKSUcvT5nXXcJSdqtwvk0KGpWk7HZUIoV8I6StAiMD/wUjckIqTFnwg=="], - "@listee/types": ["@listee/types@0.2.3", "", { "dependencies": { "@listee/db": "^0.2.3" } }, "sha512-eGOVIn4nCTIWuIC8fu07vjbBf14UU8DQKJQCQOpJjFjW7YP4hCUdjc64DhtYjlsK56xi/TiJprVw7MrpJfx4FA=="], + "@listee/types": ["@listee/types@0.3.0", "", { "dependencies": { "@listee/db": "^0.3.0" } }, "sha512-yfHI2ShIARi/TqTUC2/Sn41LhJMv4Stc7IrX0CeyiGisHfS7E4mg7NhLRKuQi77F6zzqhXkpORH2dQ21V7TDCQ=="], "@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=="], diff --git a/package.json b/package.json index 36ff89b..ed84297 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "test": "bun test" }, "dependencies": { - "@listee/auth": "^0.2.3", - "@listee/types": "^0.2.3", + "@listee/auth": "^0.3.0", + "@listee/types": "^0.3.0", "@napi-rs/keyring": "^1.2.0", "commander": "^12.1.0", "dotenv": "^16.4.5" diff --git a/src/commands/categories.ts b/src/commands/categories.ts index 1f50c5e..b477719 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -1,8 +1,10 @@ import type { Command } from "commander"; import { createCategory, + deleteCategory, getCategory, listCategories, + updateCategory, } from "../services/category-api.js"; const ensurePositiveInteger = (value: string): number => { @@ -155,4 +157,52 @@ export const registerCategoryCommand = (program: Command): void => { }, ), ); + + 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 index 3fa7aea..c24a53c 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,8 +1,10 @@ import type { Command } from "commander"; import { createTask, + deleteTask, getTask, listTasksByCategory, + updateTask, } from "../services/task-api.js"; const execute = (task: (...args: T) => Promise) => { @@ -149,4 +151,95 @@ export const registerTaskCommand = (program: Command): void => { }, ), ); + + 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/services/category-api.ts b/src/services/category-api.ts index 7965cbd..9e93523 100644 --- a/src/services/category-api.ts +++ b/src/services/category-api.ts @@ -165,3 +165,44 @@ export const createCategory = async ( 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 index 32dd73b..b44b65f 100644 --- a/src/services/task-api.ts +++ b/src/services/task-api.ts @@ -132,3 +132,50 @@ export const createTask = async (params: CreateTaskParams): Promise => { 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", + }); +}; From 9b4a03bec033cac7acff584fce88bd6590388d31 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Mon, 3 Nov 2025 17:15:21 +0900 Subject: [PATCH 06/11] feat(auth): provide authenticated token helper --- .env.example | 2 +- README.md | 3 +- bun.lock | 10 +-- package.json | 4 +- src/services/api-client.ts | 135 +++++++++++------------------- src/services/auth-service.test.ts | 62 ++++++++++++++ src/services/auth-service.ts | 31 +++++++ 7 files changed, 153 insertions(+), 94 deletions(-) diff --git a/.env.example b/.env.example index 5666e2b..ea039c7 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,4 @@ SUPABASE_URL= SUPABASE_PUBLISHABLE_KEY= LISTEE_API_URL= # LISTEE_CLI_KEYCHAIN_SERVICE=listee-cli -# LISTEE_API_AUTH_BEARER_MODE=user-id +# Authorization headers always use Supabase access tokens (JWT) diff --git a/README.md b/README.md index 2ad49b2..40c3636 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ export SUPABASE_PUBLISHABLE_KEY="your-publishable-key" export LISTEE_API_URL="https://api.your-listee-instance.dev" # optional: override the Keytar service name export LISTEE_CLI_KEYCHAIN_SERVICE="listee-cli" -# optional: choose bearer header value ("user-id" for local API mocks, "access-token" for real JWT) -export LISTEE_API_AUTH_BEARER_MODE="user-id" +# authorization headers always use Supabase access tokens (JWT) ``` Never commit secrets; the repo defaults to reading from the process environment. diff --git a/bun.lock b/bun.lock index 8a932b5..de872bf 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,8 @@ "": { "name": "listee-cli", "dependencies": { - "@listee/auth": "^0.3.0", - "@listee/types": "^0.3.0", + "@listee/auth": "^0.4.0", + "@listee/types": "^0.4.0", "@napi-rs/keyring": "^1.2.0", "commander": "^12.1.0", "dotenv": "^16.4.5", @@ -37,11 +37,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.3.0", "", { "dependencies": { "@listee/db": "^0.3.0", "@listee/types": "^0.3.0", "jose": "^5.2.3" } }, "sha512-esTAtrv+SR0gtHg/HPCyZvG2NG+bY//h3U6tZuy4SuVsN/sZ0060c47GwVdV7Ety4j3no3HZDnGTAPpN1xir7A=="], + "@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.3.0", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-lokU4wQCp0Wr73U4qSe7Y5Zn6H8EdIvnKSUcvT5nXXcJSdqtwvk0KGpWk7HZUIoV8I6StAiMD/wUjckIqTFnwg=="], + "@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.3.0", "", { "dependencies": { "@listee/db": "^0.3.0" } }, "sha512-yfHI2ShIARi/TqTUC2/Sn41LhJMv4Stc7IrX0CeyiGisHfS7E4mg7NhLRKuQi77F6zzqhXkpORH2dQ21V7TDCQ=="], + "@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=="], diff --git a/package.json b/package.json index ed84297..4d1a09b 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "test": "bun test" }, "dependencies": { - "@listee/auth": "^0.3.0", - "@listee/types": "^0.3.0", + "@listee/auth": "^0.4.0", + "@listee/types": "^0.4.0", "@napi-rs/keyring": "^1.2.0", "commander": "^12.1.0", "dotenv": "^16.4.5" diff --git a/src/services/api-client.ts b/src/services/api-client.ts index 778e06b..b13ce49 100644 --- a/src/services/api-client.ts +++ b/src/services/api-client.ts @@ -1,10 +1,4 @@ -import { Buffer } from "node:buffer"; -import { getAccessToken } from "./auth-service.js"; - -type JwtClaims = { - readonly sub?: string; - readonly email?: string; -}; +import { getAuthenticatedAccessToken } from "./auth-service.js"; type AuthenticatedContext = { readonly accessToken: string; @@ -12,10 +6,6 @@ type AuthenticatedContext = { readonly authorizationValue: string; }; -const isNonEmptyString = (value: unknown): value is string => { - return typeof value === "string" && value.trim().length > 0; -}; - const isRecord = (value: unknown): value is Record => { return typeof value === "object" && value !== null; }; @@ -35,85 +25,41 @@ const getApiBaseUrl = (): URL => { return new URL(rawUrl); }; -const decodeJwtClaims = (token: string): JwtClaims => { - const segments = token.split("."); - if (segments.length < 2) { - throw new Error("Access token is malformed."); - } - - const payloadSegment = segments[1]; - try { - const decoded = Buffer.from(payloadSegment, "base64url").toString("utf8"); - const parsed = JSON.parse(decoded); - if (!isRecord(parsed)) { - throw new Error("Access token payload is not an object."); - } +type ParsedPayload = + | { type: "json"; body: unknown } + | { type: "text"; body: string } + | { type: "empty"; body: null }; - const subValue = - typeof parsed.sub === "string" && parsed.sub.length > 0 - ? parsed.sub - : undefined; - const emailValue = - typeof parsed.email === "string" && parsed.email.length > 0 - ? parsed.email - : undefined; - - return { - ...(subValue === undefined ? {} : { sub: subValue }), - ...(emailValue === undefined ? {} : { email: emailValue }), - }; - } catch (error) { - const message = error instanceof Error ? error.message : "unknown error"; - throw new Error(`Failed to decode access token payload: ${message}`); - } -}; - -const readJson = async (response: Response): Promise => { +const readPayload = async (response: Response): Promise => { + const contentType = response.headers.get("content-type") ?? ""; const text = await response.text(); if (text.trim().length === 0) { - return null; - } - try { - return JSON.parse(text); - } catch (error) { - const message = error instanceof Error ? error.message : "unknown error"; - throw new Error(`Failed to parse API response: ${message}`); - } -}; - -const getAuthorizationMode = (): "access-token" | "user-id" => { - const raw = process.env.LISTEE_API_AUTH_BEARER_MODE; - if (raw === undefined || raw.trim().length === 0) { - return "user-id"; + return { type: "empty", body: null }; } - const value = raw.trim().toLowerCase(); - if (value === "access-token" || value === "user-id") { - return value; + if (contentType.toLowerCase().includes("application/json")) { + try { + return { type: "json", body: JSON.parse(text) }; + } catch (error) { + const message = error instanceof Error ? error.message : "unknown error"; + throw new Error(`Failed to parse API response as JSON: ${message}`); + } } - throw new Error( - "LISTEE_API_AUTH_BEARER_MODE must be either 'access-token' or 'user-id'.", - ); + return { type: "text", body: text }; }; export const createAuthenticatedContext = async ( email?: string, ): Promise => { - const tokenResult = await getAccessToken(email); - const claims = decodeJwtClaims(tokenResult.accessToken); - if (!isNonEmptyString(claims.sub)) { - throw new Error("Access token does not include the user identifier."); - } - - const mode = getAuthorizationMode(); - const authorizationValue = - mode === "user-id" ? claims.sub : tokenResult.accessToken; + const tokenResult = await getAuthenticatedAccessToken(email); + const accessToken = tokenResult.accessToken; + const userId = tokenResult.userId; return { - accessToken: tokenResult.accessToken, - userId: claims.sub, - authorizationValue, + accessToken, + userId, + authorizationValue: accessToken, }; }; @@ -134,15 +80,30 @@ const buildUrl = (path: string): URL => { return new URL(normalizedPath, baseHref); }; -const extractErrorMessage = (payload: unknown, fallback: string): string => { - if (!isRecord(payload)) { +const extractErrorMessage = ( + payload: ParsedPayload, + fallback: string, +): string => { + if (payload.type === "json") { + const body = payload.body; + if (isRecord(body)) { + const error = body.error; + if (error !== undefined) { + return typeof error === "string" ? error : String(error); + } + } return fallback; } - const error = payload.error; - if (error === undefined) { - 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 typeof error === "string" ? error : String(error); + + return fallback; }; export const requestJson = async ( @@ -159,11 +120,17 @@ export const requestJson = async ( }, }); - const payload = await readJson(response); + const payload = await readPayload(response); if (!response.ok) { const message = extractErrorMessage(payload, `status ${response.status}`); throw new Error(`API request failed: ${message}`); } - return payload; + 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..d34f2ff 100644 --- a/src/services/auth-service.test.ts +++ b/src/services/auth-service.test.ts @@ -4,6 +4,7 @@ import { type AccessTokenResult, ensureSupabaseConfig, parseSignupFragment, + toAuthenticatedAccessTokenResult, } from "./auth-service.js"; const ORIGINAL_ENV = { ...process.env }; @@ -97,3 +98,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..872fef5 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -455,6 +455,37 @@ export const getAccessToken = async ( }; }; +export type AuthenticatedAccessTokenResult = AccessTokenResult & { + userId: string; + token: SupabaseToken; +}; + +export const toAuthenticatedAccessTokenResult = ( + tokenResult: AccessTokenResult, +): AuthenticatedAccessTokenResult => { + const accessToken = tokenResult.accessToken.trim(); + if (accessToken.length === 0) { + throw new Error("Access token is empty."); + } + + const token = decodeSupabaseToken(accessToken); + const userId = extractSubjectFromTokenPayload(token); + + return { + ...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(); }; From 28bb5b50352498bd4a463cde0493324f92d71b73 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 4 Nov 2025 13:23:54 +0900 Subject: [PATCH 07/11] chore(env): enforce Supabase config at startup --- .env.example | 1 - AGENTS.md | 2 +- README.md | 5 +- bun.lock | 6 ++ package.json | 4 +- src/env.ts | 121 ++++++++++++++++++++++++++++++ src/index.ts | 4 +- src/services/api-client.ts | 32 +++++--- src/services/auth-service.test.ts | 17 ++--- src/services/auth-service.ts | 77 +++++++++++++------ 10 files changed, 214 insertions(+), 55 deletions(-) create mode 100644 src/env.ts diff --git a/.env.example b/.env.example index ea039c7..f268172 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,3 @@ SUPABASE_URL= SUPABASE_PUBLISHABLE_KEY= LISTEE_API_URL= # LISTEE_CLI_KEYCHAIN_SERVICE=listee-cli -# Authorization headers always use Supabase access tokens (JWT) diff --git a/AGENTS.md b/AGENTS.md index 51b2fc0..0841f80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ - 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. +- Read `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` from environment variables or `.env`; never hardcode credentials. - 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 40c3636..c47920c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Official command-line interface for Listee β€” manage authentication, categories ## Requirements - Bun 1.2.22 (`bun --version`) - Node.js 20+ (runtime for the compiled CLI) -- Supabase project credentials (`SUPABASE_URL`, `SUPABASE_ANON_KEY`) +- Supabase project credentials (`SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`) ## Installation ```bash @@ -17,12 +17,9 @@ Create a `.env` file or export environment variables before running commands: ```bash export SUPABASE_URL="https://your-project.supabase.co" export SUPABASE_PUBLISHABLE_KEY="your-publishable-key" -# legacy fallback (deprecated, only if publishable key is unavailable) -# 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" -# authorization headers always use Supabase access tokens (JWT) ``` Never commit secrets; the repo defaults to reading from the process environment. diff --git a/bun.lock b/bun.lock index de872bf..019d035 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,10 @@ "@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", @@ -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 4d1a09b..7ef33e9 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "@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/env.ts b/src/env.ts new file mode 100644 index 0000000..64ffbe3 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,121 @@ +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: { + SUPABASE_URL: urlString, + SUPABASE_PUBLISHABLE_KEY: nonEmptyString, + 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 === "SUPABASE_URL") { + return "SUPABASE_URL is not set. Please configure the environment variable before continuing."; + } + + if (path === "SUPABASE_PUBLISHABLE_KEY") { + return "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing."; + } + + 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 6e68d75..e404dbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +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(); diff --git a/src/services/api-client.ts b/src/services/api-client.ts index b13ce49..0652e82 100644 --- a/src/services/api-client.ts +++ b/src/services/api-client.ts @@ -1,3 +1,4 @@ +import { EnvValidationError, getEnv } from "../env.js"; import { getAuthenticatedAccessToken } from "./auth-service.js"; type AuthenticatedContext = { @@ -10,19 +11,26 @@ const isRecord = (value: unknown): value is Record => { return typeof value === "object" && value !== null; }; -const getEnvValue = (key: string): string => { - const raw = process.env[key]; - if (raw === undefined || raw.trim().length === 0) { - throw new Error( - `${key} is not set. Please configure the environment variable before continuing.`, - ); - } - return raw.trim(); -}; - const getApiBaseUrl = (): URL => { - const rawUrl = getEnvValue("LISTEE_API_URL"); - return new URL(rawUrl); + 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; + } }; type ParsedPayload = diff --git a/src/services/auth-service.test.ts b/src/services/auth-service.test.ts index d34f2ff..685ea7d 100644 --- a/src/services/auth-service.test.ts +++ b/src/services/auth-service.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { Buffer } from "node:buffer"; +import { resetEnvCache } from "../env.js"; import { type AccessTokenResult, ensureSupabaseConfig, @@ -9,8 +10,13 @@ import { 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); @@ -26,10 +32,9 @@ describe("ensureSupabaseConfig", () => { }).toThrow("SUPABASE_URL is not set"); }); - it("throws when publishable key and legacy anon key are missing", () => { + it("throws when publishable key is missing", () => { process.env.SUPABASE_URL = "https://example.supabase.co"; delete process.env.SUPABASE_PUBLISHABLE_KEY; - delete process.env.SUPABASE_ANON_KEY; expect(() => { ensureSupabaseConfig(); @@ -42,14 +47,6 @@ describe("ensureSupabaseConfig", () => { 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 diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 872fef5..2f5e1d0 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -5,6 +5,7 @@ import { } from "@listee/auth"; import type { SupabaseToken } from "@listee/types"; import { AsyncEntry, findCredentials } from "@napi-rs/keyring"; +import { checkEnv, EnvValidationError, getEnv } from "../env.js"; import type { AccessTokenResult, AuthStatus, @@ -94,44 +95,70 @@ 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.", - ); + try { + const env = getEnv(); + const rawUrl = env.SUPABASE_URL; + if (rawUrl === undefined) { + throw new Error( + "SUPABASE_URL is not set. Please configure the environment variable before continuing.", + ); + } + return new URL(rawUrl); + } catch (error) { + if (error instanceof EnvValidationError) { + const supabaseIssue = error.issues.find((issue) => { + return issue.path.join(".") === "SUPABASE_URL"; + }); + if (supabaseIssue !== undefined) { + const message = supabaseIssue.message.includes( + "expected string, received undefined", + ) + ? "SUPABASE_URL is not set. Please configure the environment variable before continuing." + : supabaseIssue.message; + throw new Error(message); + } + } + throw error; } - - 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(); + try { + const env = getEnv(); + const publishableKey = env.SUPABASE_PUBLISHABLE_KEY; + if (publishableKey === undefined) { + throw new Error( + "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing.", + ); + } + return publishableKey; + } catch (error) { + if (error instanceof EnvValidationError) { + const publishableIssue = error.issues.find((issue) => { + return issue.path.join(".") === "SUPABASE_PUBLISHABLE_KEY"; + }); + if (publishableIssue !== undefined) { + const message = publishableIssue.message.includes( + "expected string, received undefined", + ) + ? "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing." + : publishableIssue.message; + throw new Error(message); + } + } + throw error; } - - throw new Error( - "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing.", - ); }; export const ensureSupabaseConfig = (): void => { + checkEnv(); 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 env = getEnv(); + return env.LISTEE_CLI_KEYCHAIN_SERVICE ?? DEFAULT_SERVICE_NAME; }; const readJson = async (response: Response): Promise => { From c3efaa07cece8fadb1c134f371ec9f136fbee692 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 4 Nov 2025 15:56:12 +0900 Subject: [PATCH 08/11] feat(auth): route CLI auth via Listee API --- .env.example | 2 - AGENTS.md | 6 +- README.md | 6 +- src/commands/auth.ts | 14 +- src/env.ts | 10 -- src/services/api-base.ts | 90 ++++++++++++ src/services/api-client.ts | 97 ++----------- src/services/auth-service.test.ts | 29 ++-- src/services/auth-service.ts | 234 +++++++++--------------------- src/types/auth.ts | 8 - 10 files changed, 188 insertions(+), 308 deletions(-) create mode 100644 src/services/api-base.ts diff --git a/.env.example b/.env.example index f268172..8ce4eb6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +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 0841f80..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_PUBLISHABLE_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 c47920c..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_PUBLISHABLE_KEY`) +- Listee API base URL (`LISTEE_API_URL`) ## Installation ```bash @@ -15,8 +15,6 @@ 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_PUBLISHABLE_KEY="your-publishable-key" export LISTEE_API_URL="https://api.your-listee-instance.dev" # optional: override the Keytar service name export LISTEE_CLI_KEYCHAIN_SERVICE="listee-cli" diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 333203f..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, @@ -339,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); @@ -347,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(); @@ -393,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) => { @@ -410,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/env.ts b/src/env.ts index 64ffbe3..8f5e5e8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -28,8 +28,6 @@ const optionalNonEmptyString = nonEmptyString.optional(); const buildEnv = () => { return createEnv({ server: { - SUPABASE_URL: urlString, - SUPABASE_PUBLISHABLE_KEY: nonEmptyString, LISTEE_API_URL: urlString, LISTEE_CLI_KEYCHAIN_SERVICE: optionalNonEmptyString, }, @@ -89,14 +87,6 @@ export const resetEnvCache = (): void => { const describeIssue = (issue: EnvIssue): string => { const path = issue.path.join("."); - if (path === "SUPABASE_URL") { - return "SUPABASE_URL is not set. Please configure the environment variable before continuing."; - } - - if (path === "SUPABASE_PUBLISHABLE_KEY") { - return "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing."; - } - if (path === "LISTEE_API_URL") { return "LISTEE_API_URL is not set. Please configure the environment variable before continuing."; } 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 index 0652e82..f5187c8 100644 --- a/src/services/api-client.ts +++ b/src/services/api-client.ts @@ -1,4 +1,8 @@ -import { EnvValidationError, getEnv } from "../env.js"; +import { + buildListeeApiUrl, + extractApiErrorMessage, + readApiPayload, +} from "./api-base.js"; import { getAuthenticatedAccessToken } from "./auth-service.js"; type AuthenticatedContext = { @@ -7,56 +11,6 @@ type AuthenticatedContext = { readonly authorizationValue: string; }; -const isRecord = (value: unknown): value is Record => { - return typeof value === "object" && value !== null; -}; - -const getApiBaseUrl = (): 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; - } -}; - -type ParsedPayload = - | { type: "json"; body: unknown } - | { type: "text"; body: string } - | { type: "empty"; body: null }; - -const readPayload = 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 }; - } - - if (contentType.toLowerCase().includes("application/json")) { - try { - return { type: "json", body: JSON.parse(text) }; - } 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 }; -}; - export const createAuthenticatedContext = async ( email?: string, ): Promise => { @@ -79,39 +33,7 @@ const buildHeaders = (authorizationValue: string): HeadersInit => { }; const buildUrl = (path: string): URL => { - if (!path.startsWith("/")) { - throw new Error("API path must start with '/' ."); - } - const base = getApiBaseUrl(); - const baseHref = base.href.endsWith("/") ? base.href : `${base.href}/`; - const normalizedPath = path.replace(/^\//u, ""); - return new URL(normalizedPath, baseHref); -}; - -const extractErrorMessage = ( - payload: ParsedPayload, - fallback: string, -): string => { - if (payload.type === "json") { - const body = payload.body; - if (isRecord(body)) { - const error = body.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; + return buildListeeApiUrl(path); }; export const requestJson = async ( @@ -128,9 +50,12 @@ export const requestJson = async ( }, }); - const payload = await readPayload(response); + const payload = await readApiPayload(response); if (!response.ok) { - const message = extractErrorMessage(payload, `status ${response.status}`); + const message = extractApiErrorMessage( + payload, + `status ${response.status}`, + ); throw new Error(`API request failed: ${message}`); } diff --git a/src/services/auth-service.test.ts b/src/services/auth-service.test.ts index 685ea7d..f0d9a89 100644 --- a/src/services/auth-service.test.ts +++ b/src/services/auth-service.test.ts @@ -3,7 +3,7 @@ import { Buffer } from "node:buffer"; import { resetEnvCache } from "../env.js"; import { type AccessTokenResult, - ensureSupabaseConfig, + ensureListeeApiConfig, parseSignupFragment, toAuthenticatedAccessTokenResult, } from "./auth-service.js"; @@ -22,30 +22,19 @@ const resetEnv = (): void => { 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"; +describe("ensureListeeApiConfig", () => { + it("throws when LISTEE_API_URL is missing", () => { + delete process.env.LISTEE_API_URL; expect(() => { - ensureSupabaseConfig(); - }).toThrow("SUPABASE_URL is not set"); + ensureListeeApiConfig(); + }).toThrow("LISTEE_API_URL is not set"); }); - it("throws when publishable key is missing", () => { - process.env.SUPABASE_URL = "https://example.supabase.co"; - delete process.env.SUPABASE_PUBLISHABLE_KEY; + it("does not throw when LISTEE_API_URL is set", () => { + process.env.LISTEE_API_URL = "https://api.example.dev"; - 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(); + expect(() => ensureListeeApiConfig()).not.toThrow(); }); }); diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 2f5e1d0..e9a708a 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -5,15 +5,19 @@ import { } from "@listee/auth"; import type { SupabaseToken } from "@listee/types"; import { AsyncEntry, findCredentials } from "@napi-rs/keyring"; -import { checkEnv, EnvValidationError, getEnv } from "../env.js"; +import { checkEnv, getEnv } from "../env.js"; import type { AccessTokenResult, AuthStatus, 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; @@ -37,23 +41,6 @@ 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 => { @@ -94,66 +81,9 @@ const listStoredCredentials = (service: string): StoredCredential[] => { } }; -const getSupabaseUrl = (): URL => { - try { - const env = getEnv(); - const rawUrl = env.SUPABASE_URL; - if (rawUrl === undefined) { - throw new Error( - "SUPABASE_URL is not set. Please configure the environment variable before continuing.", - ); - } - return new URL(rawUrl); - } catch (error) { - if (error instanceof EnvValidationError) { - const supabaseIssue = error.issues.find((issue) => { - return issue.path.join(".") === "SUPABASE_URL"; - }); - if (supabaseIssue !== undefined) { - const message = supabaseIssue.message.includes( - "expected string, received undefined", - ) - ? "SUPABASE_URL is not set. Please configure the environment variable before continuing." - : supabaseIssue.message; - throw new Error(message); - } - } - throw error; - } -}; - -const getSupabasePublishableKey = (): string => { - try { - const env = getEnv(); - const publishableKey = env.SUPABASE_PUBLISHABLE_KEY; - if (publishableKey === undefined) { - throw new Error( - "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing.", - ); - } - return publishableKey; - } catch (error) { - if (error instanceof EnvValidationError) { - const publishableIssue = error.issues.find((issue) => { - return issue.path.join(".") === "SUPABASE_PUBLISHABLE_KEY"; - }); - if (publishableIssue !== undefined) { - const message = publishableIssue.message.includes( - "expected string, received undefined", - ) - ? "SUPABASE_PUBLISHABLE_KEY is not set. Please configure the environment variable before continuing." - : publishableIssue.message; - throw new Error(message); - } - } - throw error; - } -}; - -export const ensureSupabaseConfig = (): void => { +export const ensureListeeApiConfig = (): void => { checkEnv(); - void getSupabaseUrl(); - void getSupabasePublishableKey(); + void getEnv().LISTEE_API_URL; }; const getKeychainServiceName = (): string => { @@ -161,49 +91,54 @@ const getKeychainServiceName = (): string => { 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; + +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) { + const message = extractApiErrorMessage( + payload, + `status ${response.status}`, + ); + throw new Error(`Listee API auth request failed: ${message}`); } - try { - return JSON.parse(raw); - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to parse Supabase response: ${error.message}`); - } + if (payload.type !== "json") { throw new Error( - "Failed to parse Supabase response due to an unknown error.", + `Listee API auth request expected JSON 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(": "); + return payload.body; +}; - if (details.length > 0) { - return details; - } +const toSupabaseTokenResponse = (payload: unknown): SupabaseTokenResponse => { + if (isSupabaseTokenResponse(payload)) { + return payload; } - return `Supabase request failed with status ${status}`; -}; + if ( + isRecord(payload) && + "data" in payload && + isSupabaseTokenResponse((payload as { data: unknown }).data) + ) { + return (payload as { data: SupabaseTokenResponse }).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 => { @@ -391,59 +326,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 = toSupabaseTokenResponse(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.refresh_token); return { - accessToken: payload.access_token, - expiresIn: payload.expires_in, - tokenType: payload.token_type, + accessToken: tokenResponse.access_token, + expiresIn: tokenResponse.expires_in, + tokenType: tokenResponse.token_type, }; }; @@ -455,30 +368,17 @@ 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 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.", - ); - } + const payload = await requestAuthJson("/auth/token", { + refreshToken: credential.refreshToken, + }); + const tokenResponse = toSupabaseTokenResponse(payload); - await storeRefreshToken(credential.account, payload.refresh_token); + await storeRefreshToken(credential.account, tokenResponse.refresh_token); return { - accessToken: payload.access_token, - expiresIn: payload.expires_in, - tokenType: payload.token_type, + accessToken: tokenResponse.access_token, + expiresIn: tokenResponse.expires_in, + tokenType: tokenResponse.token_type, }; }; diff --git a/src/types/auth.ts b/src/types/auth.ts index e3233c0..f4bc0df 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -5,14 +5,6 @@ export type SupabaseTokenResponse = { expires_in: number; }; -export type SupabaseErrorPayload = { - error?: string; - error_description?: string; - msg?: string; - message?: string; - status?: number; -}; - export type StoredCredential = { account: string; refreshToken: string; From 6fcd9d0f5d5f924a5d46b73725a164c733d9b880 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 11 Nov 2025 15:43:07 +0900 Subject: [PATCH 09/11] refactor(auth): adopt camelCase token payload --- src/services/auth-service.ts | 60 +++++++++++++++++------------------- src/types/auth.ts | 12 +++++--- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index e9a708a..8ff0924 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -3,15 +3,15 @@ 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, - SupabaseTokenResponse, } from "../types/auth.js"; import { buildListeeApiUrl, @@ -41,17 +41,15 @@ const isNumber = (value: unknown): value is number => { return typeof value === "number" && Number.isFinite(value); }; -const isSupabaseTokenResponse = ( - value: unknown, -): value is SupabaseTokenResponse => { +const isAuthTokenResponse = (value: unknown): value is AuthTokenResponse => { 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) && @@ -125,17 +123,17 @@ const requestAuthJson = async ( return payload.body; }; -const toSupabaseTokenResponse = (payload: unknown): SupabaseTokenResponse => { - if (isSupabaseTokenResponse(payload)) { +const toAuthTokenResponse = (payload: unknown): AuthTokenResponse => { + if (isAuthTokenResponse(payload)) { return payload; } if ( isRecord(payload) && "data" in payload && - isSupabaseTokenResponse((payload as { data: unknown }).data) + isAuthTokenResponse((payload as { data: unknown }).data) ) { - return (payload as { data: SupabaseTokenResponse }).data; + return (payload as { data: AuthTokenResponse }).data; } throw new Error("Listee API auth response did not include token details."); @@ -170,7 +168,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; } @@ -201,16 +199,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."); @@ -220,7 +218,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."); @@ -349,14 +347,14 @@ export const login = async ( email, password, }); - const tokenResponse = toSupabaseTokenResponse(payload); + const tokenResponse = toAuthTokenResponse(payload); - await storeRefreshToken(email, tokenResponse.refresh_token); + await storeRefreshToken(email, tokenResponse.refreshToken); return { - accessToken: tokenResponse.access_token, - expiresIn: tokenResponse.expires_in, - tokenType: tokenResponse.token_type, + accessToken: tokenResponse.accessToken, + expiresIn: tokenResponse.expiresIn, + tokenType: tokenResponse.tokenType, }; }; @@ -371,20 +369,20 @@ export const getAccessToken = async ( const payload = await requestAuthJson("/auth/token", { refreshToken: credential.refreshToken, }); - const tokenResponse = toSupabaseTokenResponse(payload); + const tokenResponse = toAuthTokenResponse(payload); - await storeRefreshToken(credential.account, tokenResponse.refresh_token); + await storeRefreshToken(credential.account, tokenResponse.refreshToken); return { - accessToken: tokenResponse.access_token, - expiresIn: tokenResponse.expires_in, - tokenType: tokenResponse.token_type, + accessToken: tokenResponse.accessToken, + expiresIn: tokenResponse.expiresIn, + tokenType: tokenResponse.tokenType, }; }; export type AuthenticatedAccessTokenResult = AccessTokenResult & { userId: string; - token: SupabaseToken; + token: AuthTokenClaims; }; export const toAuthenticatedAccessTokenResult = ( @@ -395,7 +393,7 @@ export const toAuthenticatedAccessTokenResult = ( throw new Error("Access token is empty."); } - const token = decodeSupabaseToken(accessToken); + const token = decodeAuthToken(accessToken); const userId = extractSubjectFromTokenPayload(token); return { @@ -443,7 +441,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/types/auth.ts b/src/types/auth.ts index f4bc0df..ffe3402 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,8 +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 AuthTokenResponse = { + accessToken: string; + refreshToken: string; + tokenType: string; + expiresIn: number; }; export type StoredCredential = { From bd6abd2576236e04f19f5eebcbd506eec3bbeaa5 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 12 Nov 2025 10:11:46 +0900 Subject: [PATCH 10/11] fix(auth): accept empty success bodies --- src/services/auth-service.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 8ff0924..c5aa120 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -106,21 +106,20 @@ const requestAuthJson = async ( }); const payload = await readApiPayload(response); - if (!response.ok) { - const message = extractApiErrorMessage( - payload, - `status ${response.status}`, - ); - throw new Error(`Listee API auth request failed: ${message}`); - } - - if (payload.type !== "json") { + if (response.ok) { + if (payload.type === "json") { + return payload.body; + } + if (payload.type === "empty") { + return null; + } throw new Error( - `Listee API auth request expected JSON but received ${payload.type}`, + `Listee API auth request expected JSON or empty response but received ${payload.type}`, ); } - return payload.body; + const message = extractApiErrorMessage(payload, `status ${response.status}`); + throw new Error(`Listee API auth request failed: ${message}`); }; const toAuthTokenResponse = (payload: unknown): AuthTokenResponse => { From e6392ee79a7545d7421560a0aadee5056dab94e4 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 12 Nov 2025 11:20:30 +0900 Subject: [PATCH 11/11] fix(cli): validate ids before API calls --- src/commands/categories.ts | 3 ++- src/commands/tasks.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/commands/categories.ts b/src/commands/categories.ts index b477719..2ef8d3c 100644 --- a/src/commands/categories.ts +++ b/src/commands/categories.ts @@ -127,9 +127,10 @@ export const registerCategoryCommand = (program: Command): void => { .action( execute( async (categoryId: string, options: { readonly email?: string }) => { + const id = ensureNonEmptyString(categoryId, "Category ID"); const response = await getCategory({ email: options.email, - categoryId, + categoryId: id, }); printCategoryDetails(response.data); }, diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index c24a53c..1b6f7c8 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -93,8 +93,9 @@ export const registerTaskCommand = (program: Command): void => { readonly category: string; readonly email?: string; }) => { + const categoryId = ensureNonEmptyString(options.category, "Category"); const response = await listTasksByCategory({ - categoryId: options.category, + categoryId, email: options.email, }); printTasks(response.data); @@ -108,7 +109,8 @@ export const registerTaskCommand = (program: Command): void => { .option("--email ", "Account email to use when fetching the task") .action( execute(async (taskId: string, options: { readonly email?: string }) => { - const response = await getTask({ taskId, email: options.email }); + const id = ensureNonEmptyString(taskId, "Task ID"); + const response = await getTask({ taskId: id, email: options.email }); printTaskDetails(response.data); }), );