otherwise-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 +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- package/src/ui/utils/markdown.js +166 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Client for CLI Server
|
|
3
|
+
*
|
|
4
|
+
* Server-side Supabase client for validating auth tokens and
|
|
5
|
+
* storing messages with user context during WebSocket streaming.
|
|
6
|
+
*
|
|
7
|
+
* Note: This is optional - the frontend can communicate directly with Supabase.
|
|
8
|
+
* This module enables server-side validation and admin operations if needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createClient } from "@supabase/supabase-js";
|
|
12
|
+
|
|
13
|
+
// Environment variables for Supabase configuration
|
|
14
|
+
const supabaseUrl = process.env.SUPABASE_URL;
|
|
15
|
+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY;
|
|
16
|
+
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Service client - has admin privileges, use carefully
|
|
20
|
+
* Only initialize if service key is provided
|
|
21
|
+
*/
|
|
22
|
+
let serviceClient = null;
|
|
23
|
+
if (supabaseUrl && supabaseServiceKey) {
|
|
24
|
+
serviceClient = createClient(supabaseUrl, supabaseServiceKey, {
|
|
25
|
+
auth: {
|
|
26
|
+
autoRefreshToken: false,
|
|
27
|
+
persistSession: false,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
console.log("[Supabase] Service client initialized");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Public client - uses anon key, respects RLS
|
|
35
|
+
*/
|
|
36
|
+
let publicClient = null;
|
|
37
|
+
if (supabaseUrl && supabaseAnonKey) {
|
|
38
|
+
publicClient = createClient(supabaseUrl, supabaseAnonKey, {
|
|
39
|
+
auth: {
|
|
40
|
+
autoRefreshToken: false,
|
|
41
|
+
persistSession: false,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
console.log("[Supabase] Public client initialized");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if Supabase is configured
|
|
49
|
+
*/
|
|
50
|
+
export function isSupabaseConfigured() {
|
|
51
|
+
return !!(supabaseUrl && (supabaseServiceKey || supabaseAnonKey));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the service client (admin privileges)
|
|
56
|
+
*/
|
|
57
|
+
export function getServiceClient() {
|
|
58
|
+
return serviceClient;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the public client (respects RLS)
|
|
63
|
+
*/
|
|
64
|
+
export function getPublicClient() {
|
|
65
|
+
return publicClient;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a client authenticated as a specific user
|
|
70
|
+
* Used when we have the user's JWT from the frontend
|
|
71
|
+
* @param {string} accessToken - The user's JWT access token
|
|
72
|
+
* @returns {SupabaseClient} - Client authenticated as the user
|
|
73
|
+
*/
|
|
74
|
+
export function createUserClient(accessToken) {
|
|
75
|
+
if (!supabaseUrl || !supabaseAnonKey) {
|
|
76
|
+
throw new Error("Supabase not configured");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
80
|
+
auth: {
|
|
81
|
+
autoRefreshToken: false,
|
|
82
|
+
persistSession: false,
|
|
83
|
+
},
|
|
84
|
+
global: {
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${accessToken}`,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verify a JWT token and get the user
|
|
94
|
+
* @param {string} accessToken - The JWT to verify
|
|
95
|
+
* @returns {Promise<Object|null>} - User object or null if invalid
|
|
96
|
+
*/
|
|
97
|
+
export async function verifyToken(accessToken) {
|
|
98
|
+
if (!serviceClient) {
|
|
99
|
+
console.warn(
|
|
100
|
+
"[Supabase] Service client not available for token verification",
|
|
101
|
+
);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const {
|
|
107
|
+
data: { user },
|
|
108
|
+
error,
|
|
109
|
+
} = await serviceClient.auth.getUser(accessToken);
|
|
110
|
+
|
|
111
|
+
if (error) {
|
|
112
|
+
console.error("[Supabase] Token verification failed:", error.message);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return user;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("[Supabase] Token verification error:", err);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get user from access token (alias for verifyToken)
|
|
125
|
+
*/
|
|
126
|
+
export const getUserFromToken = verifyToken;
|
|
127
|
+
|
|
128
|
+
// ============================================
|
|
129
|
+
// Chat Operations (Admin/Service Key)
|
|
130
|
+
// ============================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a chat for a user (admin operation). Uses same id for local/cloud parity when id provided.
|
|
134
|
+
* If id is provided, uses upsert so frontend-created row is not duplicated (no error).
|
|
135
|
+
*/
|
|
136
|
+
export async function createChatForUser(userId, title, id = null) {
|
|
137
|
+
if (!serviceClient) {
|
|
138
|
+
throw new Error("Service client not available");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const row = id
|
|
142
|
+
? { id, user_id: userId, title: title || "New Chat" }
|
|
143
|
+
: { user_id: userId, title: title || "New Chat" };
|
|
144
|
+
if (id) {
|
|
145
|
+
const { data, error } = await serviceClient
|
|
146
|
+
.from("chats")
|
|
147
|
+
.upsert(row, { onConflict: "id" })
|
|
148
|
+
.select()
|
|
149
|
+
.single();
|
|
150
|
+
if (error) throw error;
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
const { data, error } = await serviceClient
|
|
154
|
+
.from("chats")
|
|
155
|
+
.insert(row)
|
|
156
|
+
.select()
|
|
157
|
+
.single();
|
|
158
|
+
if (error) throw error;
|
|
159
|
+
return data;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Add a message to a chat (admin operation)
|
|
164
|
+
* @param {string} chatId - Supabase chat UUID
|
|
165
|
+
* @param {string} role - Message role
|
|
166
|
+
* @param {string} content - Message content
|
|
167
|
+
* @param {Object} metadata - Optional metadata
|
|
168
|
+
*/
|
|
169
|
+
export async function addMessageToChat(chatId, role, content, metadata = null) {
|
|
170
|
+
if (!serviceClient) {
|
|
171
|
+
throw new Error("Service client not available");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { data, error } = await serviceClient
|
|
175
|
+
.from("messages")
|
|
176
|
+
.insert({ chat_id: chatId, role, content, metadata })
|
|
177
|
+
.select()
|
|
178
|
+
.single();
|
|
179
|
+
|
|
180
|
+
if (error) throw error;
|
|
181
|
+
|
|
182
|
+
// Update chat timestamp
|
|
183
|
+
await serviceClient
|
|
184
|
+
.from("chats")
|
|
185
|
+
.update({ updated_at: new Date().toISOString() })
|
|
186
|
+
.eq("id", chatId);
|
|
187
|
+
|
|
188
|
+
return data;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Update a chat in Supabase (e.g. title)
|
|
193
|
+
* @param {string} chatId - Supabase chat UUID
|
|
194
|
+
* @param {Object} updates - { title?, updated_at? }
|
|
195
|
+
*/
|
|
196
|
+
export async function updateChatInSupabase(chatId, updates) {
|
|
197
|
+
if (!serviceClient) {
|
|
198
|
+
throw new Error("Service client not available");
|
|
199
|
+
}
|
|
200
|
+
const updateData = { ...updates };
|
|
201
|
+
if (!updateData.updated_at) {
|
|
202
|
+
updateData.updated_at = new Date().toISOString();
|
|
203
|
+
}
|
|
204
|
+
const { error } = await serviceClient
|
|
205
|
+
.from("chats")
|
|
206
|
+
.update(updateData)
|
|
207
|
+
.eq("id", chatId);
|
|
208
|
+
if (error) throw error;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get all chats for a user (admin operation)
|
|
213
|
+
* @param {string} userId - The user's UUID
|
|
214
|
+
*/
|
|
215
|
+
export async function getChatsForUser(userId) {
|
|
216
|
+
if (!serviceClient) {
|
|
217
|
+
throw new Error("Service client not available");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const { data, error } = await serviceClient
|
|
221
|
+
.from("chats")
|
|
222
|
+
.select("*")
|
|
223
|
+
.eq("user_id", userId)
|
|
224
|
+
.order("updated_at", { ascending: false });
|
|
225
|
+
|
|
226
|
+
if (error) throw error;
|
|
227
|
+
return data;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get a chat with messages (admin operation)
|
|
232
|
+
* @param {number} chatId - Chat ID
|
|
233
|
+
*/
|
|
234
|
+
export async function getChatWithMessages(chatId) {
|
|
235
|
+
if (!serviceClient) {
|
|
236
|
+
throw new Error("Service client not available");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { data, error } = await serviceClient
|
|
240
|
+
.from("chats")
|
|
241
|
+
.select(
|
|
242
|
+
`
|
|
243
|
+
*,
|
|
244
|
+
messages (
|
|
245
|
+
id,
|
|
246
|
+
role,
|
|
247
|
+
content,
|
|
248
|
+
metadata,
|
|
249
|
+
created_at
|
|
250
|
+
)
|
|
251
|
+
`,
|
|
252
|
+
)
|
|
253
|
+
.eq("id", chatId)
|
|
254
|
+
.single();
|
|
255
|
+
|
|
256
|
+
if (error) throw error;
|
|
257
|
+
return data;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Search chats in Supabase by title and message content (admin operation).
|
|
262
|
+
* @param {string} userId - User UUID
|
|
263
|
+
* @param {string} query - Search string
|
|
264
|
+
* @param {number} limit - Max results
|
|
265
|
+
* @returns {Array<{ chatId, title, updated_at, similarity, matchSource, snippet }>}
|
|
266
|
+
*/
|
|
267
|
+
export async function searchChatsInSupabase(userId, query, limit = 20) {
|
|
268
|
+
if (!serviceClient) throw new Error("Service client not available");
|
|
269
|
+
const trimmed = (query || "").trim();
|
|
270
|
+
if (!trimmed) return [];
|
|
271
|
+
|
|
272
|
+
const pattern = `%${trimmed}%`;
|
|
273
|
+
|
|
274
|
+
const [titleRes, msgRes] = await Promise.all([
|
|
275
|
+
serviceClient
|
|
276
|
+
.from("chats")
|
|
277
|
+
.select("id, title, updated_at")
|
|
278
|
+
.eq("user_id", userId)
|
|
279
|
+
.ilike("title", pattern)
|
|
280
|
+
.order("updated_at", { ascending: false })
|
|
281
|
+
.limit(limit),
|
|
282
|
+
serviceClient
|
|
283
|
+
.from("messages")
|
|
284
|
+
.select("chat_id, content, role")
|
|
285
|
+
.ilike("content", pattern)
|
|
286
|
+
.order("created_at", { ascending: false })
|
|
287
|
+
.limit(100),
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
if (titleRes.error) console.warn("[Supabase:searchChats] title error:", titleRes.error.message);
|
|
291
|
+
if (msgRes.error) console.warn("[Supabase:searchChats] msg error:", msgRes.error.message);
|
|
292
|
+
|
|
293
|
+
const titleMatches = titleRes.data || [];
|
|
294
|
+
const msgMatches = msgRes.data || [];
|
|
295
|
+
|
|
296
|
+
const results = new Map();
|
|
297
|
+
|
|
298
|
+
for (const chat of titleMatches) {
|
|
299
|
+
results.set(chat.id, {
|
|
300
|
+
chatId: chat.id,
|
|
301
|
+
title: chat.title,
|
|
302
|
+
updated_at: chat.updated_at,
|
|
303
|
+
similarity: 0.9,
|
|
304
|
+
matchSource: "title",
|
|
305
|
+
snippet: chat.title,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const msg of msgMatches) {
|
|
310
|
+
const existing = results.get(msg.chat_id);
|
|
311
|
+
const idx = msg.content.toLowerCase().indexOf(trimmed.toLowerCase());
|
|
312
|
+
const start = Math.max(0, idx - 40);
|
|
313
|
+
const end = Math.min(msg.content.length, idx + trimmed.length + 40);
|
|
314
|
+
const snippet = (start > 0 ? "..." : "") + msg.content.slice(start, end) + (end < msg.content.length ? "..." : "");
|
|
315
|
+
|
|
316
|
+
if (existing) {
|
|
317
|
+
existing.similarity = Math.min(existing.similarity + 0.1, 1.0);
|
|
318
|
+
existing.matchSource = "title+content";
|
|
319
|
+
if (!existing.snippet || existing.matchSource === "title") existing.snippet = snippet;
|
|
320
|
+
} else {
|
|
321
|
+
results.set(msg.chat_id, {
|
|
322
|
+
chatId: msg.chat_id,
|
|
323
|
+
similarity: 0.6,
|
|
324
|
+
matchSource: "content",
|
|
325
|
+
snippet,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Fill in title/updated_at for content-only matches
|
|
331
|
+
const contentOnlyIds = [...results.values()].filter((r) => !r.title).map((r) => r.chatId);
|
|
332
|
+
if (contentOnlyIds.length > 0) {
|
|
333
|
+
const { data: chatRows } = await serviceClient
|
|
334
|
+
.from("chats")
|
|
335
|
+
.select("id, title, updated_at")
|
|
336
|
+
.in("id", contentOnlyIds);
|
|
337
|
+
for (const row of chatRows || []) {
|
|
338
|
+
const r = results.get(row.id);
|
|
339
|
+
if (r) {
|
|
340
|
+
r.title = row.title;
|
|
341
|
+
r.updated_at = row.updated_at;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return [...results.values()]
|
|
347
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
348
|
+
.slice(0, limit);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export default {
|
|
352
|
+
isSupabaseConfigured,
|
|
353
|
+
getServiceClient,
|
|
354
|
+
getPublicClient,
|
|
355
|
+
createUserClient,
|
|
356
|
+
verifyToken,
|
|
357
|
+
getUserFromToken,
|
|
358
|
+
createChatForUser,
|
|
359
|
+
addMessageToChat,
|
|
360
|
+
updateChatInSupabase,
|
|
361
|
+
getChatsForUser,
|
|
362
|
+
getChatWithMessages,
|
|
363
|
+
searchChatsInSupabase,
|
|
364
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
let tunnelProcess = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if cloudflared is installed
|
|
10
|
+
*/
|
|
11
|
+
export function isCloudflaredInstalled() {
|
|
12
|
+
try {
|
|
13
|
+
execSync('cloudflared --version', { stdio: 'pipe' });
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get cloudflared installation instructions
|
|
22
|
+
*/
|
|
23
|
+
export function getInstallInstructions() {
|
|
24
|
+
const platform = process.platform;
|
|
25
|
+
|
|
26
|
+
switch (platform) {
|
|
27
|
+
case 'darwin':
|
|
28
|
+
return 'Install cloudflared with: brew install cloudflared';
|
|
29
|
+
case 'linux':
|
|
30
|
+
return 'Install cloudflared from: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
|
|
31
|
+
case 'win32':
|
|
32
|
+
return 'Download cloudflared from: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
|
|
33
|
+
default:
|
|
34
|
+
return 'Install cloudflared from: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start a quick tunnel (temporary URL)
|
|
40
|
+
* @param {number} port - Local port to tunnel
|
|
41
|
+
* @returns {Promise<string>} - Public URL
|
|
42
|
+
*/
|
|
43
|
+
export function startQuickTunnel(port) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
if (!isCloudflaredInstalled()) {
|
|
46
|
+
reject(new Error(`cloudflared not installed. ${getInstallInstructions()}`));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (tunnelProcess) {
|
|
51
|
+
reject(new Error('Tunnel already running. Stop it first.'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log('[Tunnel] Starting quick tunnel...');
|
|
56
|
+
|
|
57
|
+
tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
58
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let output = '';
|
|
62
|
+
let resolved = false;
|
|
63
|
+
|
|
64
|
+
// Look for the tunnel URL in output
|
|
65
|
+
const checkForUrl = (data) => {
|
|
66
|
+
output += data.toString();
|
|
67
|
+
const urlMatch = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
68
|
+
if (urlMatch && !resolved) {
|
|
69
|
+
resolved = true;
|
|
70
|
+
resolve(urlMatch[0]);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
tunnelProcess.stdout.on('data', checkForUrl);
|
|
75
|
+
tunnelProcess.stderr.on('data', checkForUrl);
|
|
76
|
+
|
|
77
|
+
tunnelProcess.on('error', (err) => {
|
|
78
|
+
tunnelProcess = null;
|
|
79
|
+
if (!resolved) reject(err);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
tunnelProcess.on('close', (code) => {
|
|
83
|
+
tunnelProcess = null;
|
|
84
|
+
if (!resolved) {
|
|
85
|
+
reject(new Error(`Tunnel exited with code ${code}`));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Timeout if URL not found in 30 seconds
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
if (!resolved) {
|
|
92
|
+
stopTunnel();
|
|
93
|
+
reject(new Error('Timeout waiting for tunnel URL'));
|
|
94
|
+
}
|
|
95
|
+
}, 30000);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start a named tunnel with custom domain
|
|
101
|
+
* Requires prior setup: cloudflared tunnel login && cloudflared tunnel create <name>
|
|
102
|
+
* @param {string} tunnelName - Name of the configured tunnel
|
|
103
|
+
* @param {number} port - Local port to tunnel
|
|
104
|
+
* @param {string} hostname - Custom hostname
|
|
105
|
+
*/
|
|
106
|
+
export function startNamedTunnel(tunnelName, port, hostname) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
if (!isCloudflaredInstalled()) {
|
|
109
|
+
reject(new Error(`cloudflared not installed. ${getInstallInstructions()}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (tunnelProcess) {
|
|
114
|
+
reject(new Error('Tunnel already running. Stop it first.'));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Create config file
|
|
119
|
+
const configDir = join(homedir(), '.cloudflared');
|
|
120
|
+
const configPath = join(configDir, 'otherwise-config.yml');
|
|
121
|
+
|
|
122
|
+
if (!existsSync(configDir)) {
|
|
123
|
+
mkdirSync(configDir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const configContent = `
|
|
127
|
+
tunnel: ${tunnelName}
|
|
128
|
+
credentials-file: ${join(configDir, `${tunnelName}.json`)}
|
|
129
|
+
|
|
130
|
+
ingress:
|
|
131
|
+
- hostname: ${hostname}
|
|
132
|
+
service: http://localhost:${port}
|
|
133
|
+
- service: http_status:404
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
writeFileSync(configPath, configContent);
|
|
137
|
+
|
|
138
|
+
console.log(`[Tunnel] Starting named tunnel: ${tunnelName}`);
|
|
139
|
+
|
|
140
|
+
tunnelProcess = spawn('cloudflared', ['tunnel', '--config', configPath, 'run'], {
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
let started = false;
|
|
145
|
+
|
|
146
|
+
tunnelProcess.stdout.on('data', (data) => {
|
|
147
|
+
const output = data.toString();
|
|
148
|
+
console.log('[Tunnel]', output.trim());
|
|
149
|
+
if (output.includes('Registered tunnel connection') && !started) {
|
|
150
|
+
started = true;
|
|
151
|
+
resolve(`https://${hostname}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
tunnelProcess.stderr.on('data', (data) => {
|
|
156
|
+
console.log('[Tunnel]', data.toString().trim());
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
tunnelProcess.on('error', (err) => {
|
|
160
|
+
tunnelProcess = null;
|
|
161
|
+
reject(err);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
tunnelProcess.on('close', (code) => {
|
|
165
|
+
tunnelProcess = null;
|
|
166
|
+
if (!started) {
|
|
167
|
+
reject(new Error(`Tunnel exited with code ${code}`));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Timeout if not started in 60 seconds
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (!started) {
|
|
174
|
+
stopTunnel();
|
|
175
|
+
reject(new Error('Timeout starting tunnel'));
|
|
176
|
+
}
|
|
177
|
+
}, 60000);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stop the running tunnel
|
|
183
|
+
*/
|
|
184
|
+
export function stopTunnel() {
|
|
185
|
+
if (tunnelProcess) {
|
|
186
|
+
tunnelProcess.kill();
|
|
187
|
+
tunnelProcess = null;
|
|
188
|
+
console.log('[Tunnel] Stopped');
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check if tunnel is running
|
|
196
|
+
*/
|
|
197
|
+
export function isTunnelRunning() {
|
|
198
|
+
return tunnelProcess !== null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Setup instructions for custom domain tunnel
|
|
203
|
+
*/
|
|
204
|
+
export function getSetupInstructions() {
|
|
205
|
+
return `
|
|
206
|
+
To set up a custom domain tunnel:
|
|
207
|
+
|
|
208
|
+
1. Install cloudflared:
|
|
209
|
+
${getInstallInstructions()}
|
|
210
|
+
|
|
211
|
+
2. Login to Cloudflare:
|
|
212
|
+
cloudflared tunnel login
|
|
213
|
+
|
|
214
|
+
3. Create a tunnel:
|
|
215
|
+
cloudflared tunnel create otherwise
|
|
216
|
+
|
|
217
|
+
4. Add the tunnel ID to Otherwise config:
|
|
218
|
+
otherwise config set tunnel.name otherwise
|
|
219
|
+
otherwise config set tunnel.domain yourdomain.com
|
|
220
|
+
|
|
221
|
+
5. In Cloudflare Dashboard:
|
|
222
|
+
- Go to your domain's DNS settings
|
|
223
|
+
- Add a CNAME record pointing to your-tunnel-id.cfargotunnel.com
|
|
224
|
+
|
|
225
|
+
6. Start the tunnel:
|
|
226
|
+
otherwise deploy
|
|
227
|
+
|
|
228
|
+
For quick testing without a custom domain:
|
|
229
|
+
otherwise deploy --quick
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default {
|
|
234
|
+
isCloudflaredInstalled,
|
|
235
|
+
getInstallInstructions,
|
|
236
|
+
startQuickTunnel,
|
|
237
|
+
startNamedTunnel,
|
|
238
|
+
stopTunnel,
|
|
239
|
+
isTunnelRunning,
|
|
240
|
+
getSetupInstructions,
|
|
241
|
+
};
|