1+ /**
2+ * Unified API Service for Salamander Web
3+ *
4+ * This service provides a standardized way to interact with the Salamander API,
5+ * following the same patterns used by the CLI and Flutter applications.
6+ *
7+ * Key features:
8+ * - Uses HTTP API endpoints instead of direct Firestore access
9+ * - Extensible base class for different resource types
10+ * - Consistent error handling across all operations
11+ * - Authentication token management
12+ */
13+
14+ export class ApiError extends Error {
15+ constructor (
16+ message : string ,
17+ public status : number ,
18+ public response ?: any
19+ ) {
20+ super ( message ) ;
21+ this . name = 'ApiError' ;
22+ }
23+ }
24+
25+ export interface ApiResponse < T = any > {
26+ data ?: T ;
27+ error ?: string ;
28+ message ?: string ;
29+ }
30+
31+ /**
32+ * Base API Service class that can be extended for specific resources
33+ */
34+ export abstract class BaseApiService {
35+ protected static readonly API_BASE_URL = 'https://api.salamander.space/v1' ;
36+
37+ /**
38+ * Get authentication token from localStorage
39+ */
40+ protected static getAuthToken ( ) : string {
41+ const token = localStorage . getItem ( 'salamander_token' ) ;
42+ if ( ! token ) {
43+ throw new Error ( 'User not authenticated - no token found' ) ;
44+ }
45+ return token ;
46+ }
47+
48+ /**
49+ * Make authenticated HTTP request to the API
50+ */
51+ protected static async makeRequest < T > (
52+ endpoint : string ,
53+ options : RequestInit = { }
54+ ) : Promise < T > {
55+ const token = this . getAuthToken ( ) ;
56+ const url = `${ this . API_BASE_URL } ${ endpoint } ` ;
57+
58+ const config : RequestInit = {
59+ ...options ,
60+ headers : {
61+ 'Authorization' : `Bearer ${ token } ` ,
62+ 'Content-Type' : 'application/json' ,
63+ ...options . headers ,
64+ } ,
65+ } ;
66+
67+ try {
68+ const response = await fetch ( url , config ) ;
69+
70+ if ( ! response . ok ) {
71+ let errorMessage = `HTTP ${ response . status } ` ;
72+ let errorResponse ;
73+
74+ try {
75+ errorResponse = await response . json ( ) ;
76+ errorMessage = errorResponse . message || errorMessage ;
77+ } catch {
78+ errorMessage = await response . text ( ) || errorMessage ;
79+ }
80+
81+ throw new ApiError ( errorMessage , response . status , errorResponse ) ;
82+ }
83+
84+ // Handle 204 No Content responses
85+ if ( response . status === 204 ) {
86+ return { } as T ;
87+ }
88+
89+ return await response . json ( ) ;
90+ } catch ( error ) {
91+ if ( error instanceof ApiError ) {
92+ throw error ;
93+ }
94+
95+ // Handle network errors, CORS issues, etc.
96+ throw new ApiError (
97+ error instanceof Error ? error . message : 'Network error occurred' ,
98+ 0 ,
99+ { originalError : error }
100+ ) ;
101+ }
102+ }
103+
104+ /**
105+ * Generic GET request
106+ */
107+ protected static async get < T > ( endpoint : string ) : Promise < T > {
108+ return this . makeRequest < T > ( endpoint , { method : 'GET' } ) ;
109+ }
110+
111+ /**
112+ * Generic POST request
113+ */
114+ protected static async post < T > ( endpoint : string , data ?: any ) : Promise < T > {
115+ return this . makeRequest < T > ( endpoint , {
116+ method : 'POST' ,
117+ body : data ? JSON . stringify ( data ) : undefined ,
118+ } ) ;
119+ }
120+
121+ /**
122+ * Generic PUT request
123+ */
124+ protected static async put < T > ( endpoint : string , data ?: any ) : Promise < T > {
125+ return this . makeRequest < T > ( endpoint , {
126+ method : 'PUT' ,
127+ body : data ? JSON . stringify ( data ) : undefined ,
128+ } ) ;
129+ }
130+
131+ /**
132+ * Generic DELETE request
133+ */
134+ protected static async delete < T > ( endpoint : string ) : Promise < T > {
135+ return this . makeRequest < T > ( endpoint , { method : 'DELETE' } ) ;
136+ }
137+ }
138+
139+ /**
140+ * Runner API Service - extends BaseApiService for runner-specific operations
141+ */
142+ export class RunnerApiService extends BaseApiService {
143+ /**
144+ * Delete a runner using the API endpoint (matches CLI and Flutter pattern)
145+ */
146+ static async deleteRunner ( runnerId : string ) : Promise < void > {
147+ if ( ! runnerId || typeof runnerId !== 'string' ) {
148+ throw new Error ( 'Runner ID is required and must be a string' ) ;
149+ }
150+
151+ try {
152+ await this . delete ( `/runner/${ runnerId } ` ) ;
153+ } catch ( error ) {
154+ if ( error instanceof ApiError ) {
155+ // Re-throw API errors with more context
156+ throw new ApiError (
157+ `Failed to delete runner: ${ error . message } ` ,
158+ error . status ,
159+ error . response
160+ ) ;
161+ }
162+ throw error ;
163+ }
164+ }
165+
166+ /**
167+ * Create a runner using the API endpoint
168+ */
169+ static async createRunner ( data : {
170+ name : string ;
171+ directory ?: string ;
172+ machineId ?: string ;
173+ machineName ?: string ;
174+ } ) : Promise < { id : string } > {
175+ if ( ! data . name ) {
176+ throw new Error ( 'Runner name is required' ) ;
177+ }
178+
179+ try {
180+ return await this . post ( '/runner' , data ) ;
181+ } catch ( error ) {
182+ if ( error instanceof ApiError ) {
183+ throw new ApiError (
184+ `Failed to create runner: ${ error . message } ` ,
185+ error . status ,
186+ error . response
187+ ) ;
188+ }
189+ throw error ;
190+ }
191+ }
192+
193+ /**
194+ * Update a runner's name using the API endpoint
195+ * Uses PUT /v1/runner/{runner_id} with "name" in request body (matches CLI pattern)
196+ */
197+ static async updateRunnerName ( runnerId : string , name : string ) : Promise < void > {
198+ if ( ! runnerId ) {
199+ throw new Error ( 'Runner ID is required' ) ;
200+ }
201+ if ( ! name || typeof name !== 'string' || ! name . trim ( ) ) {
202+ throw new Error ( 'Runner name is required and must be a non-empty string' ) ;
203+ }
204+
205+ try {
206+ await this . put ( `/runner/${ runnerId } ` , { name : name . trim ( ) } ) ;
207+ } catch ( error ) {
208+ if ( error instanceof ApiError ) {
209+ throw new ApiError (
210+ `Failed to update runner name: ${ error . message } ` ,
211+ error . status ,
212+ error . response
213+ ) ;
214+ }
215+ throw error ;
216+ }
217+ }
218+
219+ /**
220+ * Generic update runner method for other properties
221+ */
222+ static async updateRunner ( runnerId : string , data : {
223+ name ?: string ;
224+ lastMessage ?: string ;
225+ } ) : Promise < void > {
226+ if ( ! runnerId ) {
227+ throw new Error ( 'Runner ID is required' ) ;
228+ }
229+
230+ try {
231+ await this . put ( `/runner/${ runnerId } ` , data ) ;
232+ } catch ( error ) {
233+ if ( error instanceof ApiError ) {
234+ throw new ApiError (
235+ `Failed to update runner: ${ error . message } ` ,
236+ error . status ,
237+ error . response
238+ ) ;
239+ }
240+ throw error ;
241+ }
242+ }
243+ }
244+
245+ /**
246+ * Usage example:
247+ *
248+ * // Delete a runner
249+ * try {
250+ * await RunnerApiService.deleteRunner('runner-id-123');
251+ * console.log('Runner deleted successfully');
252+ * } catch (error) {
253+ * if (error instanceof ApiError) {
254+ * console.error(`API Error (${error.status}): ${error.message}`);
255+ * } else {
256+ * console.error('Unexpected error:', error);
257+ * }
258+ * }
259+ *
260+ * // Create a new runner
261+ * try {
262+ * const { id } = await RunnerApiService.createRunner({
263+ * name: 'My New Runner',
264+ * directory: '/path/to/project'
265+ * });
266+ * console.log('Created runner with ID:', id);
267+ * } catch (error) {
268+ * console.error('Failed to create runner:', error);
269+ * }
270+ *
271+ * // Rename a runner
272+ * try {
273+ * await RunnerApiService.updateRunnerName('runner-id-123', 'Updated Runner Name');
274+ * console.log('Runner renamed successfully');
275+ * } catch (error) {
276+ * if (error instanceof ApiError) {
277+ * console.error(`Failed to rename runner: ${error.message}`);
278+ * } else {
279+ * console.error('Unexpected error:', error);
280+ * }
281+ * }
282+ */
0 commit comments