summaryrefslogtreecommitdiff
path: root/apps/mobile/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/mobile/lib')
-rw-r--r--apps/mobile/lib/api.ts289
-rw-r--r--apps/mobile/lib/auth.ts210
-rw-r--r--apps/mobile/lib/config.ts89
-rw-r--r--apps/mobile/lib/index.ts18
-rw-r--r--apps/mobile/lib/supabase.ts28
5 files changed, 634 insertions, 0 deletions
diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts
new file mode 100644
index 0000000..ec848b6
--- /dev/null
+++ b/apps/mobile/lib/api.ts
@@ -0,0 +1,289 @@
+import { supabase } from './supabase';
+
+// =============================================================================
+// API Configuration
+// =============================================================================
+
+const API_CONFIG = {
+ local: {
+ http: 'http://localhost:8080',
+ ws: 'ws://localhost:8080',
+ },
+ production: {
+ http: 'https://api.makima.jp',
+ ws: 'wss://api.makima.jp',
+ },
+} as const;
+
+type Environment = 'local' | 'production';
+
+// Detect environment based on __DEV__ flag
+const env: Environment = __DEV__ ? 'local' : 'production';
+
+export const API_BASE = API_CONFIG[env].http;
+export const WS_BASE = API_CONFIG[env].ws;
+
+export function getEnvironment(): Environment {
+ return env;
+}
+
+// =============================================================================
+// Authentication Helper
+// =============================================================================
+
+/**
+ * Fetch wrapper that automatically adds authentication headers
+ * Gets the access token from Supabase session
+ */
+async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
+ const { data: { session } } = await supabase.auth.getSession();
+
+ const headers: HeadersInit = {
+ 'Content-Type': 'application/json',
+ };
+
+ if (session?.access_token) {
+ headers['Authorization'] = `Bearer ${session.access_token}`;
+ }
+
+ return fetch(`${API_BASE}${url}`, {
+ ...options,
+ headers: { ...headers, ...options.headers },
+ });
+}
+
+// =============================================================================
+// Mesh/Task Types
+// =============================================================================
+
+export type TaskStatus =
+ | 'pending'
+ | 'initializing'
+ | 'starting'
+ | 'running'
+ | 'paused'
+ | 'blocked'
+ | 'done'
+ | 'failed'
+ | 'merged';
+
+export type MergeMode = 'pr' | 'auto' | 'manual';
+export type CompletionAction = 'none' | 'branch' | 'merge' | 'pr';
+export type ContractPhase = 'research' | 'specify' | 'plan' | 'execute' | 'review';
+
+export interface TaskSummary {
+ id: string;
+ contractId: string | null;
+ contractName: string | null;
+ contractPhase: ContractPhase | null;
+ parentTaskId: string | null;
+ depth: number;
+ name: string;
+ status: TaskStatus;
+ priority: number;
+ progressSummary: string | null;
+ subtaskCount: number;
+ isSupervisor: boolean;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Task {
+ id: string;
+ ownerId: string;
+ contractId: string | null;
+ parentTaskId: string | null;
+ depth: number;
+ name: string;
+ description: string | null;
+ status: TaskStatus;
+ priority: number;
+ plan: string;
+ daemonId: string | null;
+ containerId: string | null;
+ overlayPath: string | null;
+ repositoryUrl: string | null;
+ baseBranch: string | null;
+ targetBranch: string | null;
+ mergeMode: MergeMode | null;
+ prUrl: string | null;
+ targetRepoPath: string | null;
+ completionAction: CompletionAction | null;
+ progressSummary: string | null;
+ lastOutput: string | null;
+ errorMessage: string | null;
+ startedAt: string | null;
+ completedAt: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+ isSupervisor: boolean;
+}
+
+export interface TaskWithSubtasks extends Task {
+ subtasks: TaskSummary[];
+}
+
+export interface TaskListResponse {
+ tasks: TaskSummary[];
+ total: number;
+}
+
+export interface TaskOutputEntry {
+ id: string;
+ taskId: string;
+ messageType: string;
+ content: string;
+ toolName?: string;
+ toolInput?: Record<string, unknown>;
+ isError?: boolean;
+ costUsd?: number;
+ durationMs?: number;
+ createdAt: string;
+}
+
+export interface TaskOutputResponse {
+ entries: TaskOutputEntry[];
+ total: number;
+ taskId: string;
+}
+
+export interface SendMessageResponse {
+ success: boolean;
+ taskId: string;
+ messageLength: number;
+}
+
+// =============================================================================
+// Pending Question Types
+// =============================================================================
+
+export interface PendingQuestion {
+ questionId: string;
+ taskId: string;
+ contractId: string;
+ question: string;
+ choices: string[];
+ context: string | null;
+ createdAt: string;
+}
+
+export interface AnswerQuestionResponse {
+ success: boolean;
+}
+
+// =============================================================================
+// Task API Functions
+// =============================================================================
+
+/**
+ * List all tasks for the current user
+ */
+export async function listTasks(): Promise<TaskListResponse> {
+ const res = await authFetch('/api/v1/mesh/tasks');
+ if (!res.ok) {
+ throw new Error(`Failed to list tasks: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get a specific task with its subtasks
+ */
+export async function getTask(id: string): Promise<TaskWithSubtasks> {
+ const res = await authFetch(`/api/v1/mesh/tasks/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get task: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Start a pending task
+ */
+export async function startTask(id: string): Promise<Task> {
+ const res = await authFetch(`/api/v1/mesh/tasks/${id}/start`, {
+ method: 'POST',
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to start task: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Stop a running task
+ */
+export async function stopTask(id: string): Promise<Task> {
+ const res = await authFetch(`/api/v1/mesh/tasks/${id}/stop`, {
+ method: 'POST',
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to stop task: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Send a message to a running task's stdin
+ */
+export async function sendTaskMessage(
+ taskId: string,
+ message: string
+): Promise<SendMessageResponse> {
+ const res = await authFetch(`/api/v1/mesh/tasks/${taskId}/message`, {
+ method: 'POST',
+ body: JSON.stringify({ message }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to send message: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get task output history
+ */
+export async function getTaskOutput(taskId: string): Promise<TaskOutputResponse> {
+ const res = await authFetch(`/api/v1/mesh/tasks/${taskId}/output`);
+ if (!res.ok) {
+ throw new Error(`Failed to get task output: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Question API Functions
+// =============================================================================
+
+/**
+ * Get all pending supervisor questions for the current user
+ */
+export async function listPendingQuestions(): Promise<PendingQuestion[]> {
+ const res = await authFetch('/api/v1/mesh/questions');
+ if (!res.ok) {
+ throw new Error(`Failed to list questions: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Answer a pending supervisor question
+ */
+export async function answerQuestion(
+ questionId: string,
+ response: string
+): Promise<AnswerQuestionResponse> {
+ const res = await authFetch(`/api/v1/mesh/questions/${questionId}/answer`, {
+ method: 'POST',
+ body: JSON.stringify({ response }),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to answer question: ${res.statusText}`);
+ }
+ return res.json();
+}
diff --git a/apps/mobile/lib/auth.ts b/apps/mobile/lib/auth.ts
new file mode 100644
index 0000000..0005949
--- /dev/null
+++ b/apps/mobile/lib/auth.ts
@@ -0,0 +1,210 @@
+import type { Session, User, AuthError } from '@supabase/supabase-js';
+import { supabase } from './supabase';
+
+/**
+ * Auth response types
+ */
+export interface AuthResult {
+ success: boolean;
+ error?: string;
+}
+
+export interface SignInResult extends AuthResult {
+ user?: User;
+ session?: Session;
+}
+
+export interface SessionResult {
+ session: Session | null;
+ error?: string;
+}
+
+export interface UserResult {
+ user: User | null;
+ error?: string;
+}
+
+/**
+ * Helper function to extract error message from AuthError
+ */
+function getErrorMessage(error: AuthError | null): string | undefined {
+ if (!error) return undefined;
+ return error.message || 'An unknown error occurred';
+}
+
+/**
+ * Sign in with email and password
+ * @param email - User's email address
+ * @param password - User's password
+ * @returns SignInResult with user and session on success, or error on failure
+ */
+export async function signIn(email: string, password: string): Promise<SignInResult> {
+ try {
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email: email.trim().toLowerCase(),
+ password,
+ });
+
+ if (error) {
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+
+ return {
+ success: true,
+ user: data.user ?? undefined,
+ session: data.session ?? undefined,
+ };
+ } catch (error) {
+ console.error('Sign in error:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to sign in',
+ };
+ }
+}
+
+/**
+ * Sign out the current user and clear the session
+ * @returns AuthResult indicating success or failure
+ */
+export async function signOut(): Promise<AuthResult> {
+ try {
+ const { error } = await supabase.auth.signOut();
+
+ if (error) {
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('Sign out error:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to sign out',
+ };
+ }
+}
+
+/**
+ * Get the current session
+ * @returns SessionResult with the current session or null
+ */
+export async function getSession(): Promise<SessionResult> {
+ try {
+ const { data, error } = await supabase.auth.getSession();
+
+ if (error) {
+ return {
+ session: null,
+ error: getErrorMessage(error),
+ };
+ }
+
+ return { session: data.session };
+ } catch (error) {
+ console.error('Get session error:', error);
+ return {
+ session: null,
+ error: error instanceof Error ? error.message : 'Failed to get session',
+ };
+ }
+}
+
+/**
+ * Get the current user
+ * @returns UserResult with the current user or null
+ */
+export async function getCurrentUser(): Promise<UserResult> {
+ try {
+ const { data, error } = await supabase.auth.getUser();
+
+ if (error) {
+ return {
+ user: null,
+ error: getErrorMessage(error),
+ };
+ }
+
+ return { user: data.user };
+ } catch (error) {
+ console.error('Get current user error:', error);
+ return {
+ user: null,
+ error: error instanceof Error ? error.message : 'Failed to get current user',
+ };
+ }
+}
+
+/**
+ * Refresh the current session
+ * @returns SessionResult with the refreshed session
+ */
+export async function refreshSession(): Promise<SessionResult> {
+ try {
+ const { data, error } = await supabase.auth.refreshSession();
+
+ if (error) {
+ return {
+ session: null,
+ error: getErrorMessage(error),
+ };
+ }
+
+ return { session: data.session };
+ } catch (error) {
+ console.error('Refresh session error:', error);
+ return {
+ session: null,
+ error: error instanceof Error ? error.message : 'Failed to refresh session',
+ };
+ }
+}
+
+/**
+ * Check if the user is currently authenticated
+ * @returns boolean indicating if user is authenticated
+ */
+export async function isAuthenticated(): Promise<boolean> {
+ const { session } = await getSession();
+ return session !== null;
+}
+
+/**
+ * Sign up with email and password (for future use)
+ * @param email - User's email address
+ * @param password - User's password
+ * @returns SignInResult with user on success
+ */
+export async function signUp(email: string, password: string): Promise<SignInResult> {
+ try {
+ const { data, error } = await supabase.auth.signUp({
+ email: email.trim().toLowerCase(),
+ password,
+ });
+
+ if (error) {
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+
+ return {
+ success: true,
+ user: data.user ?? undefined,
+ session: data.session ?? undefined,
+ };
+ } catch (error) {
+ console.error('Sign up error:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to sign up',
+ };
+ }
+}
diff --git a/apps/mobile/lib/config.ts b/apps/mobile/lib/config.ts
new file mode 100644
index 0000000..70fce52
--- /dev/null
+++ b/apps/mobile/lib/config.ts
@@ -0,0 +1,89 @@
+import { Platform } from 'react-native';
+
+/**
+ * API configuration for Makima mobile app
+ * Supports different environments and platforms
+ */
+
+// Environment detection
+const isDevelopment = __DEV__;
+
+/**
+ * API base URLs for different environments
+ */
+export const API_CONFIG = {
+ local: 'http://localhost:8080',
+ // Use 10.0.2.2 for Android emulator to access host machine's localhost
+ localAndroid: 'http://10.0.2.2:8080',
+ production: 'https://api.makima.jp',
+} as const;
+
+/**
+ * Get the appropriate API base URL based on environment and platform
+ */
+export function getApiBaseUrl(): string {
+ // Check for environment variable override
+ const envUrl = process.env.EXPO_PUBLIC_API_URL;
+ if (envUrl) {
+ return envUrl;
+ }
+
+ // In production, use the production API
+ if (!isDevelopment) {
+ return API_CONFIG.production;
+ }
+
+ // In development, use local API with platform-specific URL
+ if (Platform.OS === 'android') {
+ return API_CONFIG.localAndroid;
+ }
+
+ return API_CONFIG.local;
+}
+
+/**
+ * App configuration object
+ */
+export const config = {
+ /**
+ * API base URL for making requests
+ */
+ apiBaseUrl: getApiBaseUrl(),
+
+ /**
+ * Whether the app is running in development mode
+ */
+ isDevelopment,
+
+ /**
+ * Supabase URL (from environment or default)
+ */
+ supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL || 'https://ynxyjytytmfwxjqxljzm.supabase.co',
+
+ /**
+ * App version
+ */
+ version: '1.0.0',
+
+ /**
+ * Support/help URL
+ */
+ supportUrl: 'https://makima.jp/support',
+
+ /**
+ * Privacy policy URL
+ */
+ privacyPolicyUrl: 'https://makima.jp/privacy',
+
+ /**
+ * Terms of service URL
+ */
+ termsOfServiceUrl: 'https://makima.jp/terms',
+
+ /**
+ * Forgot password URL (opens in browser)
+ */
+ forgotPasswordUrl: 'https://makima.jp/forgot-password',
+} as const;
+
+export type Config = typeof config;
diff --git a/apps/mobile/lib/index.ts b/apps/mobile/lib/index.ts
new file mode 100644
index 0000000..8213fb0
--- /dev/null
+++ b/apps/mobile/lib/index.ts
@@ -0,0 +1,18 @@
+/**
+ * Library exports
+ */
+export { supabase, SUPABASE_URL } from './supabase';
+export { config, getApiBaseUrl, API_CONFIG } from './config';
+export {
+ signIn,
+ signOut,
+ getSession,
+ getCurrentUser,
+ refreshSession,
+ isAuthenticated,
+ signUp,
+ type AuthResult,
+ type SignInResult,
+ type SessionResult,
+ type UserResult,
+} from './auth';
diff --git a/apps/mobile/lib/supabase.ts b/apps/mobile/lib/supabase.ts
new file mode 100644
index 0000000..f7d005a
--- /dev/null
+++ b/apps/mobile/lib/supabase.ts
@@ -0,0 +1,28 @@
+import { createClient } from '@supabase/supabase-js';
+import * as SecureStore from 'expo-secure-store';
+
+// Supabase configuration from environment variables
+export const SUPABASE_URL = process.env.EXPO_PUBLIC_SUPABASE_URL || 'https://ynxyjytytmfwxjqxljzm.supabase.co';
+const SUPABASE_ANON_KEY = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || 'your-anon-key';
+
+// Custom storage using expo-secure-store for React Native
+const ExpoSecureStoreAdapter = {
+ getItem: (key: string) => {
+ return SecureStore.getItemAsync(key);
+ },
+ setItem: (key: string, value: string) => {
+ return SecureStore.setItemAsync(key, value);
+ },
+ removeItem: (key: string) => {
+ return SecureStore.deleteItemAsync(key);
+ },
+};
+
+export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
+ auth: {
+ storage: ExpoSecureStoreAdapter,
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: false,
+ },
+});