@every-env/spiral-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -0
- package/package.json +69 -0
- package/src/api.ts +520 -0
- package/src/attachments/index.ts +174 -0
- package/src/auth.ts +160 -0
- package/src/cli.ts +952 -0
- package/src/config.ts +49 -0
- package/src/drafts/editor.ts +105 -0
- package/src/drafts/index.ts +208 -0
- package/src/notes/index.ts +130 -0
- package/src/styles/index.ts +45 -0
- package/src/suggestions/diff.ts +33 -0
- package/src/suggestions/index.ts +205 -0
- package/src/suggestions/parser.ts +83 -0
- package/src/theme.ts +23 -0
- package/src/tools/renderer.ts +104 -0
- package/src/types/marked-terminal.d.ts +31 -0
- package/src/types.ts +170 -0
- package/src/workspaces/index.ts +55 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { type EventSourceMessage, createParser } from "eventsource-parser";
|
|
2
|
+
import { getAuthToken, sanitizeError } from "./auth";
|
|
3
|
+
import {
|
|
4
|
+
ApiError,
|
|
5
|
+
type Conversation,
|
|
6
|
+
type Draft,
|
|
7
|
+
type DraftVersion,
|
|
8
|
+
type Message,
|
|
9
|
+
NetworkError,
|
|
10
|
+
type PatchOperation,
|
|
11
|
+
type ToolCall,
|
|
12
|
+
type Workspace,
|
|
13
|
+
type WritingStyle,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
// Use dev server in development, production backend otherwise
|
|
17
|
+
const API_BASE =
|
|
18
|
+
process.env.SPIRAL_API_URL ||
|
|
19
|
+
(process.env.NODE_ENV === "development"
|
|
20
|
+
? "http://localhost:8000"
|
|
21
|
+
: "https://spiral-next-backend-production.up.railway.app");
|
|
22
|
+
|
|
23
|
+
const HEARTBEAT_TIMEOUT_MS = 20_000; // 20s (backend pings every 15s)
|
|
24
|
+
const MAX_MESSAGE_LENGTH = 100_000; // Security: limit input size
|
|
25
|
+
|
|
26
|
+
export interface StreamCallbacks {
|
|
27
|
+
onChunk: (chunk: string) => void;
|
|
28
|
+
onThinking: (thought: string) => void;
|
|
29
|
+
onToolCall: (tool: ToolCall) => void;
|
|
30
|
+
onSessionName: (name: string) => void;
|
|
31
|
+
onRetry: (info: { attempt: number; max: number; message: string }) => void;
|
|
32
|
+
onModelDowngrade: (from: string, to: string) => void;
|
|
33
|
+
onComplete: (sessionId: string) => void;
|
|
34
|
+
onError: (error: Error) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate session ID format to prevent SSRF
|
|
39
|
+
* @security Reject malformed session IDs
|
|
40
|
+
*/
|
|
41
|
+
function isValidSessionId(id: string): boolean {
|
|
42
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StreamChatOptions {
|
|
46
|
+
attachments?: Array<{ name: string; content: string; type: "text" | "binary" }>;
|
|
47
|
+
notes?: Array<{ content: string; feedback?: string }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Stream chat response from Spiral API
|
|
52
|
+
* @param signal AbortSignal for cancellation
|
|
53
|
+
*/
|
|
54
|
+
export async function streamChat(
|
|
55
|
+
message: string,
|
|
56
|
+
sessionId: string | null,
|
|
57
|
+
callbacks: StreamCallbacks,
|
|
58
|
+
signal?: AbortSignal,
|
|
59
|
+
options?: StreamChatOptions,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
// Security: Validate inputs
|
|
62
|
+
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
63
|
+
throw new ApiError(`Message too long (max ${MAX_MESSAGE_LENGTH} chars)`, 400);
|
|
64
|
+
}
|
|
65
|
+
if (sessionId && !isValidSessionId(sessionId)) {
|
|
66
|
+
throw new ApiError("Invalid session ID format", 400);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const token = await getAuthToken();
|
|
70
|
+
|
|
71
|
+
const formData = new FormData();
|
|
72
|
+
formData.append("user_message", message);
|
|
73
|
+
if (sessionId) {
|
|
74
|
+
formData.append("session_id", sessionId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add file attachments
|
|
78
|
+
if (options?.attachments && options.attachments.length > 0) {
|
|
79
|
+
for (const attachment of options.attachments) {
|
|
80
|
+
const content =
|
|
81
|
+
attachment.type === "binary"
|
|
82
|
+
? Buffer.from(attachment.content, "base64")
|
|
83
|
+
: attachment.content;
|
|
84
|
+
const blob = new Blob([content], {
|
|
85
|
+
type: attachment.type === "text" ? "text/plain" : "application/octet-stream",
|
|
86
|
+
});
|
|
87
|
+
formData.append("files", blob, attachment.name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Add notes/scratchpad
|
|
92
|
+
if (options?.notes && options.notes.length > 0) {
|
|
93
|
+
formData.append("notes", JSON.stringify(options.notes));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let response: Response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetch(`${API_BASE}/api/v1/team/stream`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${token}`,
|
|
102
|
+
Accept: "text/event-stream",
|
|
103
|
+
},
|
|
104
|
+
body: formData,
|
|
105
|
+
signal,
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (signal?.aborted) return;
|
|
109
|
+
throw new NetworkError(`Connection failed: ${(error as Error).message}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Parse error response body for better error messages
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const errorText = await response.text().catch(() => "");
|
|
115
|
+
let errorMessage = `API error: ${response.status}`;
|
|
116
|
+
try {
|
|
117
|
+
const errorJson = JSON.parse(errorText);
|
|
118
|
+
errorMessage = errorJson.detail?.message || errorJson.detail || errorMessage;
|
|
119
|
+
} catch {
|
|
120
|
+
/* use default message */
|
|
121
|
+
}
|
|
122
|
+
if (process.env.DEBUG) {
|
|
123
|
+
console.debug("API error response:", {
|
|
124
|
+
status: response.status,
|
|
125
|
+
url: `${API_BASE}/api/v1/team/stream`,
|
|
126
|
+
errorText: errorText.substring(0, 200),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
throw new ApiError(sanitizeError(new Error(errorMessage), token), response.status, errorText);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Performance: Use array accumulation (O(n) vs O(n²) string concat)
|
|
133
|
+
let currentSessionId = sessionId || "";
|
|
134
|
+
let lastHeartbeat = Date.now();
|
|
135
|
+
|
|
136
|
+
// Heartbeat timeout detection
|
|
137
|
+
const heartbeatCheck = setInterval(() => {
|
|
138
|
+
if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
|
|
139
|
+
clearInterval(heartbeatCheck);
|
|
140
|
+
callbacks.onError(new NetworkError("Stream timeout: no heartbeat received"));
|
|
141
|
+
}
|
|
142
|
+
}, 5000);
|
|
143
|
+
|
|
144
|
+
const parser = createParser({
|
|
145
|
+
onEvent(event: EventSourceMessage) {
|
|
146
|
+
lastHeartbeat = Date.now(); // Reset heartbeat on any event
|
|
147
|
+
|
|
148
|
+
if (event.data === "[DONE]") {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const data = JSON.parse(event.data);
|
|
154
|
+
|
|
155
|
+
// Handle ALL event types from the SSE spec
|
|
156
|
+
switch (event.event) {
|
|
157
|
+
// Content events
|
|
158
|
+
case "RunResponseContent":
|
|
159
|
+
case "RunResponse":
|
|
160
|
+
if (data.content) callbacks.onChunk(data.content);
|
|
161
|
+
if (data.thinking) callbacks.onThinking(data.thinking);
|
|
162
|
+
if (data.reasoning_content) callbacks.onThinking(data.reasoning_content);
|
|
163
|
+
break;
|
|
164
|
+
|
|
165
|
+
// Session events
|
|
166
|
+
case "RunStarted":
|
|
167
|
+
currentSessionId = data.run_id || data.session_id || currentSessionId;
|
|
168
|
+
break;
|
|
169
|
+
case "SessionName":
|
|
170
|
+
callbacks.onSessionName(data.content || data.name || data.session_name);
|
|
171
|
+
break;
|
|
172
|
+
|
|
173
|
+
// Tool events
|
|
174
|
+
case "ToolCallStarted":
|
|
175
|
+
if (data.tools) {
|
|
176
|
+
for (const tool of data.tools) {
|
|
177
|
+
callbacks.onToolCall({ ...tool, status: "started" });
|
|
178
|
+
}
|
|
179
|
+
} else if (data.tool) {
|
|
180
|
+
const toolName = typeof data.tool === "string" ? data.tool : data.tool.tool_name;
|
|
181
|
+
callbacks.onToolCall({ tool_name: toolName, status: "started" });
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
case "ToolCallArgumentsDelta":
|
|
185
|
+
callbacks.onToolCall({
|
|
186
|
+
tool_name: data.tool_name || "unknown",
|
|
187
|
+
tool_call_id: data.tool_call_id,
|
|
188
|
+
status: "arguments_delta",
|
|
189
|
+
});
|
|
190
|
+
break;
|
|
191
|
+
case "ToolCallArgumentsDone":
|
|
192
|
+
callbacks.onToolCall({
|
|
193
|
+
tool_name: data.tool_name || "unknown",
|
|
194
|
+
tool_call_id: data.tool_call_id,
|
|
195
|
+
status: "arguments_done",
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
case "ToolCallCompleted":
|
|
199
|
+
if (data.tools) {
|
|
200
|
+
for (const tool of data.tools) {
|
|
201
|
+
callbacks.onToolCall({ ...tool, status: "completed" });
|
|
202
|
+
}
|
|
203
|
+
} else if (data.tool) {
|
|
204
|
+
const toolName = typeof data.tool === "string" ? data.tool : data.tool.tool_name;
|
|
205
|
+
callbacks.onToolCall({ tool_name: toolName, status: "completed" });
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
|
|
209
|
+
// Completion
|
|
210
|
+
case "RunCompleted":
|
|
211
|
+
callbacks.onComplete(currentSessionId);
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
// Handoff notification (agent switching)
|
|
215
|
+
case "HandoffNotification":
|
|
216
|
+
if (process.env.DEBUG) {
|
|
217
|
+
console.debug(`Agent handoff: ${data.from_agent} → ${data.to_agent}`);
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
|
|
221
|
+
// Error handling events
|
|
222
|
+
case "retry_started":
|
|
223
|
+
case "retry_attempt":
|
|
224
|
+
try {
|
|
225
|
+
const retryData = typeof data.content === "string" ? JSON.parse(data.content) : data;
|
|
226
|
+
callbacks.onRetry({
|
|
227
|
+
attempt: retryData.retry_attempt || 1,
|
|
228
|
+
max: retryData.max_retries || 3,
|
|
229
|
+
message: retryData.message || "Retrying...",
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
callbacks.onRetry({ attempt: 1, max: 3, message: "Retrying..." });
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
case "retry_failed":
|
|
236
|
+
callbacks.onError(new ApiError(data.message || data.content || "Retry failed", 500));
|
|
237
|
+
break;
|
|
238
|
+
case "model_downgrade":
|
|
239
|
+
try {
|
|
240
|
+
const downgradeData =
|
|
241
|
+
typeof data.content === "string" ? JSON.parse(data.content) : data;
|
|
242
|
+
callbacks.onModelDowngrade(
|
|
243
|
+
downgradeData.from_model || "unknown",
|
|
244
|
+
downgradeData.to_model || "unknown",
|
|
245
|
+
);
|
|
246
|
+
} catch {
|
|
247
|
+
callbacks.onModelDowngrade("unknown", "unknown");
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case "Error":
|
|
251
|
+
callbacks.onError(
|
|
252
|
+
new ApiError(data.content || "Unknown error", data.status_code || 500),
|
|
253
|
+
);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
} catch (_parseError) {
|
|
257
|
+
// Log parse errors in debug mode (don't silently swallow)
|
|
258
|
+
if (process.env.DEBUG) {
|
|
259
|
+
console.debug("Skipping non-JSON SSE event:", event.data?.substring(0, 100));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
onComment() {
|
|
264
|
+
lastHeartbeat = Date.now(); // Pings appear as comments
|
|
265
|
+
},
|
|
266
|
+
onError(error) {
|
|
267
|
+
if (process.env.DEBUG) {
|
|
268
|
+
console.debug("SSE parse error:", error);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const reader = response.body?.getReader();
|
|
274
|
+
if (!reader) throw new NetworkError("No response body");
|
|
275
|
+
|
|
276
|
+
const decoder = new TextDecoder();
|
|
277
|
+
try {
|
|
278
|
+
while (true) {
|
|
279
|
+
const { done, value } = await reader.read();
|
|
280
|
+
if (done) break;
|
|
281
|
+
if (signal?.aborted) break;
|
|
282
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
clearInterval(heartbeatCheck);
|
|
286
|
+
reader.releaseLock();
|
|
287
|
+
parser.reset(); // Clear internal buffers (per performance review)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Fetch conversations list
|
|
293
|
+
* @note Uses /conversations not /sessions (architecture review fix)
|
|
294
|
+
*/
|
|
295
|
+
export async function fetchConversations(): Promise<Conversation[]> {
|
|
296
|
+
const token = await getAuthToken();
|
|
297
|
+
|
|
298
|
+
const response = await fetch(`${API_BASE}/api/v1/conversations`, {
|
|
299
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
throw new ApiError(`Failed to fetch conversations: ${response.status}`, response.status);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const data = await response.json();
|
|
307
|
+
return data.sessions || [];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Fetch conversation messages
|
|
312
|
+
*/
|
|
313
|
+
export async function fetchMessages(conversationId: string): Promise<Message[]> {
|
|
314
|
+
if (!isValidSessionId(conversationId)) {
|
|
315
|
+
throw new ApiError("Invalid conversation ID format", 400);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const token = await getAuthToken();
|
|
319
|
+
|
|
320
|
+
const response = await fetch(
|
|
321
|
+
`${API_BASE}/api/v1/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
322
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
throw new ApiError(`Failed to fetch messages: ${response.status}`, response.status);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const data = await response.json();
|
|
330
|
+
return data.messages || [];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get the current API base URL (for debugging)
|
|
335
|
+
*/
|
|
336
|
+
export function getApiBaseUrl(): string {
|
|
337
|
+
return API_BASE;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ============================================================================
|
|
341
|
+
// Draft API Methods
|
|
342
|
+
// ============================================================================
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Fetch all drafts for a session
|
|
346
|
+
*/
|
|
347
|
+
export async function fetchSessionDrafts(sessionId: string): Promise<Draft[]> {
|
|
348
|
+
if (!isValidSessionId(sessionId)) {
|
|
349
|
+
throw new ApiError("Invalid session ID format", 400);
|
|
350
|
+
}
|
|
351
|
+
const token = await getAuthToken();
|
|
352
|
+
const response = await fetch(`${API_BASE}/api/v1/sessions/${sessionId}/drafts`, {
|
|
353
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
354
|
+
});
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
throw new ApiError(`Failed to fetch drafts: ${response.status}`, response.status);
|
|
357
|
+
}
|
|
358
|
+
const data = await response.json();
|
|
359
|
+
// Backend returns array directly, not wrapped in {drafts: ...}
|
|
360
|
+
return Array.isArray(data) ? data : data.drafts || [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Fetch a single draft with full content
|
|
365
|
+
*/
|
|
366
|
+
export async function fetchDraft(sessionId: string, draftId: string): Promise<Draft> {
|
|
367
|
+
if (!isValidSessionId(sessionId)) {
|
|
368
|
+
throw new ApiError("Invalid session ID format", 400);
|
|
369
|
+
}
|
|
370
|
+
const token = await getAuthToken();
|
|
371
|
+
const response = await fetch(
|
|
372
|
+
`${API_BASE}/api/v1/sessions/${sessionId}/drafts/${draftId}/nodes?mode=full`,
|
|
373
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
374
|
+
);
|
|
375
|
+
if (!response.ok) {
|
|
376
|
+
throw new ApiError(`Failed to fetch draft: ${response.status}`, response.status);
|
|
377
|
+
}
|
|
378
|
+
return response.json();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Update draft content
|
|
383
|
+
*/
|
|
384
|
+
export async function updateDraft(
|
|
385
|
+
sessionId: string,
|
|
386
|
+
draftId: string,
|
|
387
|
+
content: string,
|
|
388
|
+
title?: string,
|
|
389
|
+
): Promise<Draft> {
|
|
390
|
+
if (!isValidSessionId(sessionId)) {
|
|
391
|
+
throw new ApiError("Invalid session ID format", 400);
|
|
392
|
+
}
|
|
393
|
+
const token = await getAuthToken();
|
|
394
|
+
const response = await fetch(`${API_BASE}/api/v1/sessions/${sessionId}/drafts/${draftId}`, {
|
|
395
|
+
method: "PUT",
|
|
396
|
+
headers: {
|
|
397
|
+
Authorization: `Bearer ${token}`,
|
|
398
|
+
"Content-Type": "application/json",
|
|
399
|
+
},
|
|
400
|
+
body: JSON.stringify({ content, title }),
|
|
401
|
+
});
|
|
402
|
+
if (!response.ok) {
|
|
403
|
+
throw new ApiError(`Failed to update draft: ${response.status}`, response.status);
|
|
404
|
+
}
|
|
405
|
+
return response.json();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Fetch draft version history
|
|
410
|
+
*/
|
|
411
|
+
export async function fetchDraftVersions(
|
|
412
|
+
sessionId: string,
|
|
413
|
+
draftId: string,
|
|
414
|
+
): Promise<DraftVersion[]> {
|
|
415
|
+
if (!isValidSessionId(sessionId)) {
|
|
416
|
+
throw new ApiError("Invalid session ID format", 400);
|
|
417
|
+
}
|
|
418
|
+
const token = await getAuthToken();
|
|
419
|
+
const response = await fetch(
|
|
420
|
+
`${API_BASE}/api/v1/sessions/${sessionId}/drafts/${draftId}/versions`,
|
|
421
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
422
|
+
);
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
throw new ApiError(`Failed to fetch versions: ${response.status}`, response.status);
|
|
425
|
+
}
|
|
426
|
+
const data = await response.json();
|
|
427
|
+
return data.versions || [];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Restore a draft to a specific version
|
|
432
|
+
*/
|
|
433
|
+
export async function restoreDraftVersion(
|
|
434
|
+
sessionId: string,
|
|
435
|
+
draftId: string,
|
|
436
|
+
versionId: string,
|
|
437
|
+
): Promise<Draft> {
|
|
438
|
+
if (!isValidSessionId(sessionId)) {
|
|
439
|
+
throw new ApiError("Invalid session ID format", 400);
|
|
440
|
+
}
|
|
441
|
+
const token = await getAuthToken();
|
|
442
|
+
const response = await fetch(
|
|
443
|
+
`${API_BASE}/api/v1/sessions/${sessionId}/drafts/${draftId}/restore/${versionId}`,
|
|
444
|
+
{
|
|
445
|
+
method: "POST",
|
|
446
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
if (!response.ok) {
|
|
450
|
+
throw new ApiError(`Failed to restore version: ${response.status}`, response.status);
|
|
451
|
+
}
|
|
452
|
+
return response.json();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Apply patch operations to a draft
|
|
457
|
+
*/
|
|
458
|
+
export async function applyDraftPatch(
|
|
459
|
+
sessionId: string,
|
|
460
|
+
draftId: string,
|
|
461
|
+
operations: PatchOperation[],
|
|
462
|
+
): Promise<Draft> {
|
|
463
|
+
if (!isValidSessionId(sessionId)) {
|
|
464
|
+
throw new ApiError("Invalid session ID format", 400);
|
|
465
|
+
}
|
|
466
|
+
const token = await getAuthToken();
|
|
467
|
+
const response = await fetch(
|
|
468
|
+
`${API_BASE}/api/v1/sessions/${sessionId}/drafts/${draftId}/apply-patch`,
|
|
469
|
+
{
|
|
470
|
+
method: "POST",
|
|
471
|
+
headers: {
|
|
472
|
+
Authorization: `Bearer ${token}`,
|
|
473
|
+
"Content-Type": "application/json",
|
|
474
|
+
},
|
|
475
|
+
body: JSON.stringify({ operations }),
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
if (!response.ok) {
|
|
479
|
+
throw new ApiError(`Failed to apply patch: ${response.status}`, response.status);
|
|
480
|
+
}
|
|
481
|
+
return response.json();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ============================================================================
|
|
485
|
+
// Writing Styles API
|
|
486
|
+
// ============================================================================
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Fetch all writing styles
|
|
490
|
+
*/
|
|
491
|
+
export async function fetchWritingStyles(): Promise<WritingStyle[]> {
|
|
492
|
+
const token = await getAuthToken();
|
|
493
|
+
const response = await fetch(`${API_BASE}/api/v1/writing-styles/`, {
|
|
494
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
495
|
+
});
|
|
496
|
+
if (!response.ok) {
|
|
497
|
+
throw new ApiError(`Failed to fetch writing styles: ${response.status}`, response.status);
|
|
498
|
+
}
|
|
499
|
+
const data = await response.json();
|
|
500
|
+
return data.styles || data || [];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ============================================================================
|
|
504
|
+
// Workspaces API
|
|
505
|
+
// ============================================================================
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Fetch all workspaces
|
|
509
|
+
*/
|
|
510
|
+
export async function fetchWorkspaces(): Promise<Workspace[]> {
|
|
511
|
+
const token = await getAuthToken();
|
|
512
|
+
const response = await fetch(`${API_BASE}/api/v1/workspaces/`, {
|
|
513
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
514
|
+
});
|
|
515
|
+
if (!response.ok) {
|
|
516
|
+
throw new ApiError(`Failed to fetch workspaces: ${response.status}`, response.status);
|
|
517
|
+
}
|
|
518
|
+
const data = await response.json();
|
|
519
|
+
return data.workspaces || data || [];
|
|
520
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// File attachment handling with security mitigations
|
|
2
|
+
// See plan: Path traversal and symlink security risks identified
|
|
3
|
+
|
|
4
|
+
import { lstatSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
import type { Attachment } from "../types";
|
|
8
|
+
|
|
9
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
10
|
+
const MAX_TOTAL_SIZE = 50 * 1024 * 1024; // 50MB total
|
|
11
|
+
const MAX_FILES = 5;
|
|
12
|
+
|
|
13
|
+
// SECURITY: Paths that should never be accessed
|
|
14
|
+
const SENSITIVE_PATHS = ["/etc", "/var", "/root", "/private"];
|
|
15
|
+
const HOME = process.env.HOME || "";
|
|
16
|
+
if (HOME) {
|
|
17
|
+
SENSITIVE_PATHS.push(`${HOME}/.ssh`, `${HOME}/.gnupg`, `${HOME}/.aws`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Known text file extensions
|
|
21
|
+
const TEXT_EXTENSIONS = new Set([
|
|
22
|
+
".txt",
|
|
23
|
+
".md",
|
|
24
|
+
".json",
|
|
25
|
+
".yaml",
|
|
26
|
+
".yml",
|
|
27
|
+
".xml",
|
|
28
|
+
".html",
|
|
29
|
+
".css",
|
|
30
|
+
".js",
|
|
31
|
+
".ts",
|
|
32
|
+
".tsx",
|
|
33
|
+
".jsx",
|
|
34
|
+
".py",
|
|
35
|
+
".rb",
|
|
36
|
+
".go",
|
|
37
|
+
".rs",
|
|
38
|
+
".java",
|
|
39
|
+
".c",
|
|
40
|
+
".cpp",
|
|
41
|
+
".h",
|
|
42
|
+
".sh",
|
|
43
|
+
".bash",
|
|
44
|
+
".zsh",
|
|
45
|
+
".sql",
|
|
46
|
+
".csv",
|
|
47
|
+
".log",
|
|
48
|
+
".env",
|
|
49
|
+
".gitignore",
|
|
50
|
+
".toml",
|
|
51
|
+
".ini",
|
|
52
|
+
".conf",
|
|
53
|
+
".markdown",
|
|
54
|
+
".rst",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate and sanitize file path
|
|
59
|
+
* @security Prevents path traversal, blocks sensitive paths, rejects symlinks
|
|
60
|
+
*/
|
|
61
|
+
function sanitizePath(inputPath: string): string {
|
|
62
|
+
const resolved = resolve(inputPath);
|
|
63
|
+
|
|
64
|
+
// Block path traversal
|
|
65
|
+
if (inputPath.includes("..")) {
|
|
66
|
+
throw new Error(`Path traversal attempt: ${inputPath}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Block sensitive paths
|
|
70
|
+
for (const sensitive of SENSITIVE_PATHS) {
|
|
71
|
+
if (resolved.startsWith(sensitive)) {
|
|
72
|
+
throw new Error(`Access to sensitive path denied: ${inputPath}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Block symlinks (could point anywhere)
|
|
77
|
+
try {
|
|
78
|
+
const stat = lstatSync(resolved);
|
|
79
|
+
if (stat.isSymbolicLink()) {
|
|
80
|
+
throw new Error(`Symlinks not allowed: ${inputPath}`);
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return resolved;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Process file attachments for sending with messages
|
|
91
|
+
*/
|
|
92
|
+
export async function processAttachments(
|
|
93
|
+
filePaths: string[],
|
|
94
|
+
options: { quiet?: boolean } = {},
|
|
95
|
+
): Promise<Attachment[]> {
|
|
96
|
+
if (filePaths.length > MAX_FILES) {
|
|
97
|
+
throw new Error(`Too many files. Maximum ${MAX_FILES} files allowed.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const spinner = options.quiet ? null : ora("Processing attachments...").start();
|
|
101
|
+
const attachments: Attachment[] = [];
|
|
102
|
+
let totalSize = 0;
|
|
103
|
+
|
|
104
|
+
for (const filePath of filePaths) {
|
|
105
|
+
// SECURITY: Sanitize path before access
|
|
106
|
+
const safePath = sanitizePath(filePath);
|
|
107
|
+
const file = Bun.file(safePath);
|
|
108
|
+
|
|
109
|
+
if (!(await file.exists())) {
|
|
110
|
+
throw new Error(`File not found: ${filePath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const size = file.size;
|
|
114
|
+
|
|
115
|
+
if (size > MAX_FILE_SIZE) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`File too large: ${filePath} (${(size / 1024 / 1024).toFixed(1)}MB). Max ${MAX_FILE_SIZE / 1024 / 1024}MB.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
totalSize += size;
|
|
122
|
+
if (totalSize > MAX_TOTAL_SIZE) {
|
|
123
|
+
throw new Error(`Total attachment size exceeds ${MAX_TOTAL_SIZE / 1024 / 1024}MB limit.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const fileName = filePath.split("/").pop() || filePath;
|
|
127
|
+
const ext = `.${fileName.split(".").pop()?.toLowerCase() || ""}`;
|
|
128
|
+
|
|
129
|
+
// Determine if text or binary
|
|
130
|
+
const isText = TEXT_EXTENSIONS.has(ext);
|
|
131
|
+
const mimeType = file.type || (isText ? "text/plain" : "application/octet-stream");
|
|
132
|
+
|
|
133
|
+
if (spinner) {
|
|
134
|
+
spinner.text = `Processing ${fileName}...`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let content: string;
|
|
138
|
+
if (isText) {
|
|
139
|
+
content = await file.text();
|
|
140
|
+
} else {
|
|
141
|
+
const buffer = await file.arrayBuffer();
|
|
142
|
+
content = Buffer.from(buffer).toString("base64");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
attachments.push({
|
|
146
|
+
name: fileName,
|
|
147
|
+
content,
|
|
148
|
+
type: isText ? "text" : "binary",
|
|
149
|
+
size,
|
|
150
|
+
mimeType,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
spinner?.succeed(`Processed ${attachments.length} file(s)`);
|
|
155
|
+
|
|
156
|
+
return attachments;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Format attachments summary for display
|
|
161
|
+
*/
|
|
162
|
+
export function formatAttachmentsSummary(attachments: Attachment[]): string {
|
|
163
|
+
if (attachments.length === 0) return "";
|
|
164
|
+
|
|
165
|
+
const totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
|
|
166
|
+
const sizeStr =
|
|
167
|
+
totalSize < 1024
|
|
168
|
+
? `${totalSize}B`
|
|
169
|
+
: totalSize < 1024 * 1024
|
|
170
|
+
? `${(totalSize / 1024).toFixed(1)}KB`
|
|
171
|
+
: `${(totalSize / 1024 / 1024).toFixed(1)}MB`;
|
|
172
|
+
|
|
173
|
+
return `${attachments.length} file(s), ${sizeStr} total`;
|
|
174
|
+
}
|