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.
Files changed (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. 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
+ };