keyline-cli 1.0.0 → 1.0.2

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/keyline.js DELETED
@@ -1,577 +0,0 @@
1
- #!/usr/bin/env node
2
- const { createClient } = require('@supabase/supabase-js');
3
- const inquirer = require('inquirer');
4
- const chalk = require('chalk');
5
- const figlet = require('figlet');
6
- const ora = require('ora');
7
- const { customAlphabet } = require('nanoid');
8
- require('dotenv').config();
9
-
10
- // Default Configuration (KeyLine Public Network)
11
- const DEFAULT_CONFIG = {
12
- SUPABASE_URL: 'https://xpzaefdnzddsibxqgwnx.supabase.co',
13
- SUPABASE_ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhwemFlZmRuemRkc2lieHFnd254Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjY5MDk3MDksImV4cCI6MjA4MjQ4NTcwOX0.tyjkDMoC34msnoBOky7JUVuuhEw4m7cLsl86JUTSPQY'
14
- };
15
-
16
- const SUPABASE_URL = process.env.SUPABASE_URL || DEFAULT_CONFIG.SUPABASE_URL;
17
- const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || DEFAULT_CONFIG.SUPABASE_ANON_KEY;
18
-
19
- const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
20
- const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 7);
21
-
22
- const hackerTheme = {
23
- primary: chalk.greenBright,
24
- secondary: chalk.green,
25
- error: chalk.redBright,
26
- info: chalk.cyan,
27
- system: chalk.white.dim,
28
- warning: chalk.yellowBright,
29
- accent: chalk.magentaBright,
30
- bg: chalk.bgBlack
31
- };
32
-
33
- const SLEEP = (ms) => new Promise(resolve => setTimeout(resolve, ms));
34
-
35
- async function typewriter(text, color = hackerTheme.primary, speed = 10) {
36
- for (const char of text) {
37
- process.stdout.write(color(char));
38
- await SLEEP(speed);
39
- }
40
- process.stdout.write('\n');
41
- }
42
-
43
- function clearConsole() {
44
- process.stdout.write('\x1Bc');
45
- }
46
-
47
- function renderBox(content, title = 'SYSTEM') {
48
- const lines = content.split('\n');
49
- const width = Math.max(...lines.map(l => l.length), title.length) + 4;
50
- const border = '═'.repeat(width);
51
-
52
- console.log(hackerTheme.secondary(`╔═ ${title} ${'═'.repeat(width - title.length - 3)}╗`));
53
- lines.forEach(line => {
54
- console.log(hackerTheme.secondary('║ ') + hackerTheme.primary(line.padEnd(width - 2)) + hackerTheme.secondary('║'));
55
- });
56
- console.log(hackerTheme.secondary(`╚${border}╝`));
57
- }
58
-
59
- function showBanner() {
60
- console.log(hackerTheme.primary(figlet.textSync('KeyLine', { horizontalLayout: 'full' })));
61
- console.log(hackerTheme.system('--- SECURE TERMINAL CONNECTION ESTABLISHED ---\n'));
62
- }
63
-
64
- async function generateUniqueID() {
65
- // ID format: 3 letters + 4 numbers (e.g., ABC1234)
66
- const letters = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 3)();
67
- const numbers = customAlphabet('0123456789', 4)();
68
- return `${letters}${numbers}`;
69
- }
70
-
71
- async function main() {
72
- clearConsole();
73
- showBanner();
74
-
75
- // Credentials now guaranteed by defaults
76
-
77
- const { action } = await inquirer.prompt([
78
- {
79
- type: 'list',
80
- name: 'action',
81
- message: hackerTheme.primary('Select initial protocol:'),
82
- choices: [
83
- { name: hackerTheme.secondary('[LOGIN] Access existing terminal'), value: 'login' },
84
- { name: hackerTheme.secondary('[SIGNUP] Register new node'), value: 'signup' },
85
- { name: hackerTheme.error('[EXIT] Terminate session'), value: 'exit' }
86
- ]
87
- }
88
- ]);
89
-
90
- if (action === 'exit') process.exit(0);
91
-
92
- if (action === 'signup') {
93
- await handleSignup();
94
- } else {
95
- await handleLogin();
96
- }
97
- }
98
-
99
- async function handleSignup() {
100
- const { displayName } = await inquirer.prompt([
101
- { type: 'input', name: 'displayName', message: hackerTheme.primary('Enter Display Name (or type "back"):') }
102
- ]);
103
-
104
- if (displayName.toLowerCase() === 'back') return main();
105
-
106
- const { email, password, chatPassword } = await inquirer.prompt([
107
- { type: 'input', name: 'email', message: hackerTheme.primary('Enter Email (for identity):') },
108
- { type: 'password', name: 'password', message: hackerTheme.primary('Enter Secure Account Password:') },
109
- { type: 'password', name: 'chatPassword', message: hackerTheme.warning('Set Chat Access Password (others need this to add you):') }
110
- ]);
111
-
112
- const spinner = ora(hackerTheme.info('Encrypting identity and generating ID...')).start();
113
-
114
- try {
115
- const { data: authData, error: authError } = await supabase.auth.signUp({
116
- email,
117
- password
118
- });
119
-
120
- if (authError) throw authError;
121
-
122
- if (authData.session) {
123
- const uniqueID = await generateUniqueID();
124
-
125
- const { error: profileError } = await supabase
126
- .from('profiles')
127
- .insert([
128
- { id: authData.user.id, display_name: displayName, unique_id: uniqueID, chat_password: chatPassword }
129
- ]);
130
-
131
- if (profileError) throw profileError;
132
-
133
- spinner.succeed(hackerTheme.primary(`Identity registered! Your Unique ID is: ${chalk.white.bold(uniqueID)}`));
134
- await showMainMenu(authData.user);
135
- } else {
136
- spinner.succeed(hackerTheme.primary('Identity registered!'));
137
- console.log(hackerTheme.warning('\n[SYSTEM] ATTENTION: Verification required.'));
138
- console.log(hackerTheme.info('Please check your mailbox and verify your email ID.'));
139
- console.log(hackerTheme.info('Return to this terminal and [LOGIN] after verification.\n'));
140
-
141
- await inquirer.prompt([{ type: 'input', name: 'continue', message: hackerTheme.system('Press Enter to return to main menu...') }]);
142
- await main();
143
- }
144
- } catch (err) {
145
- spinner.fail(hackerTheme.error(`Registration failed: ${err.message}`));
146
- await inquirer.prompt([{ type: 'input', name: 'continue', message: hackerTheme.system('Press Enter to retry...') }]);
147
- await main();
148
- }
149
- }
150
-
151
- async function handleLogin() {
152
- const { email } = await inquirer.prompt([
153
- { type: 'input', name: 'email', message: hackerTheme.primary('Enter Email (or type "back"):') }
154
- ]);
155
-
156
- if (email.toLowerCase() === 'back') return main();
157
-
158
- const { password } = await inquirer.prompt([
159
- { type: 'password', name: 'password', message: hackerTheme.primary('Enter Password:') }
160
- ]);
161
-
162
- const spinner = ora(hackerTheme.info('Authenticating credentials...')).start();
163
-
164
- try {
165
- const { data, error } = await supabase.auth.signInWithPassword({
166
- email,
167
- password
168
- });
169
-
170
- if (error) throw error;
171
-
172
- spinner.succeed(hackerTheme.primary('Access granted. Welcome back.'));
173
- await showMainMenu(data.user);
174
- } catch (err) {
175
- spinner.fail(hackerTheme.error(`Authentication failed: ${err.message}`));
176
- await inquirer.prompt([{ type: 'input', name: 'continue', message: hackerTheme.system('Press Enter to retry...') }]);
177
- await main();
178
- }
179
- }
180
-
181
- async function handleMissingProfile(user) {
182
- const { displayName, chatPassword } = await inquirer.prompt([
183
- { type: 'input', name: 'displayName', message: hackerTheme.primary('Complete your profile. Enter Display Name:') },
184
- { type: 'password', name: 'chatPassword', message: hackerTheme.warning('Set Chat Access Password (others need this to add you):') }
185
- ]);
186
-
187
- const spinner = ora(hackerTheme.info('Completing registration...')).start();
188
- const uniqueID = await generateUniqueID();
189
-
190
- try {
191
- const { error: profileError } = await supabase
192
- .from('profiles')
193
- .insert([
194
- { id: user.id, display_name: displayName, unique_id: uniqueID, chat_password: chatPassword }
195
- ]);
196
-
197
- if (profileError) throw profileError;
198
-
199
- spinner.succeed(hackerTheme.primary(`Profile created! Your ID is: ${chalk.white.bold(uniqueID)}`));
200
- await showMainMenu(user);
201
- } catch (err) {
202
- spinner.fail(hackerTheme.error(`Failed to create profile: ${err.message}`));
203
- await inquirer.prompt([{ type: 'input', name: 'continue', message: hackerTheme.system('Press Enter to retry...') }]);
204
- await main();
205
- }
206
- }
207
-
208
- async function showMainMenu(user) {
209
- const { error: profileError, data: profile } = await supabase
210
- .from('profiles')
211
- .select('*')
212
- .eq('id', user.id)
213
- .single();
214
-
215
- if (profileError) {
216
- if (profileError.code === 'PGRST116') {
217
- console.log(hackerTheme.warning('\n[SYSTEM] Warning: Profile not found for this account.'));
218
- return handleMissingProfile(user);
219
- }
220
- console.log(hackerTheme.error(`[ERROR] Profile retrieval failed: ${profileError.message}`));
221
- await inquirer.prompt([{ type: 'input', name: 'continue', message: hackerTheme.system('Press Enter to return to main menu...') }]);
222
- return main();
223
- }
224
-
225
- clearConsole();
226
- showBanner();
227
- console.log(hackerTheme.info(`User: ${profile.display_name} | ID: ${profile.unique_id}\n`));
228
-
229
- const { action } = await inquirer.prompt([
230
- {
231
- type: 'list',
232
- name: 'action',
233
- message: hackerTheme.primary('COMMAND MENU:'),
234
- choices: [
235
- { name: hackerTheme.secondary('[SEARCH] Find user by ID'), value: 'search' },
236
- { name: hackerTheme.secondary('[REQUESTS] View chat requests'), value: 'requests' },
237
- { name: hackerTheme.secondary('[CHATS] My active conversations'), value: 'chats' },
238
- { name: hackerTheme.error('[LOGOUT] Disconnect'), value: 'logout' }
239
- ]
240
- }
241
- ]);
242
-
243
- switch (action) {
244
- case 'search':
245
- await handleSearch(profile);
246
- break;
247
- case 'requests':
248
- await handleRequests(profile);
249
- break;
250
- case 'chats':
251
- await handleChats(profile);
252
- break;
253
- case 'logout':
254
- await supabase.auth.signOut();
255
- await main();
256
- break;
257
- }
258
- }
259
-
260
- async function handleSearch(profile) {
261
- const { targetID } = await inquirer.prompt([
262
- { type: 'input', name: 'targetID', message: hackerTheme.primary('Enter Unique ID to search (or type "back"):') }
263
- ]);
264
-
265
- if (targetID.toLowerCase() === 'back') return showMainMenu({ id: profile.id });
266
-
267
- if (targetID === profile.unique_id) {
268
- console.log(hackerTheme.error('[ERROR] Cannot search for yourself.'));
269
- return showMainMenu({ id: profile.id });
270
- }
271
-
272
- const spinner = ora(hackerTheme.info('Pinging target ID...')).start();
273
-
274
- try {
275
- const { data: targetProfile, error } = await supabase
276
- .from('profiles')
277
- .select('*')
278
- .eq('unique_id', targetID)
279
- .single();
280
-
281
- if (error || !targetProfile) {
282
- spinner.fail(hackerTheme.error('ID not found in network.'));
283
- } else {
284
- spinner.stop();
285
- // Check if relationship already exists
286
- const { data: existing, error: checkError } = await supabase
287
- .from('chat_requests')
288
- .select('*')
289
- .or(`and(sender_id.eq.${profile.id},receiver_id.eq.${targetProfile.id}),and(sender_id.eq.${targetProfile.id},receiver_id.eq.${profile.id})`)
290
- .single();
291
-
292
- if (existing) {
293
- if (existing.status === 'accepted') {
294
- console.log(hackerTheme.info(`[SYSTEM] Connection with ${targetProfile.display_name} already exists.`));
295
- } else if (existing.sender_id === profile.id) {
296
- console.log(hackerTheme.info(`[SYSTEM] Signal already transmitted. Waiting for ${targetProfile.display_name} to accept.`));
297
- } else {
298
- console.log(hackerTheme.warning(`[SYSTEM] ${targetProfile.display_name} has already sent you a request! Check your [REQUESTS].`));
299
- }
300
- } else {
301
- console.log(hackerTheme.primary(`Target found: ${targetProfile.display_name}`));
302
-
303
- // Chat Password Verification
304
- if (targetProfile.chat_password) {
305
- const { inputPassword } = await inquirer.prompt([
306
- { type: 'password', name: 'inputPassword', message: hackerTheme.warning(`Enter Chat Access Password for ${targetProfile.unique_id}:`) }
307
- ]);
308
-
309
- if (inputPassword !== targetProfile.chat_password) {
310
- console.log(hackerTheme.error('[ACCESS DENIED] Incorrect Chat Password.'));
311
- return showMainMenu({ id: profile.id });
312
- }
313
- console.log(hackerTheme.secondary('[ACCESS GRANTED] Credentials verified.'));
314
- }
315
-
316
- const { confirm } = await inquirer.prompt([
317
- {
318
- type: 'confirm',
319
- name: 'confirm',
320
- message: hackerTheme.primary(`Send chat request to ${targetProfile.display_name}?`),
321
- default: true
322
- }
323
- ]);
324
-
325
- if (confirm) {
326
- const { error: reqError } = await supabase
327
- .from('chat_requests')
328
- .insert([
329
- { sender_id: profile.id, receiver_id: targetProfile.id, status: 'pending' }
330
- ]);
331
-
332
- if (reqError) throw reqError;
333
- console.log(hackerTheme.primary('Request transmitted successfully.'));
334
- }
335
- }
336
- }
337
- } catch (err) {
338
- spinner.fail(hackerTheme.error(`Operation failed: ${err.message}`));
339
- }
340
-
341
- await showMainMenu({ id: profile.id });
342
- }
343
-
344
- async function handleRequests(profile) {
345
- const spinner = ora(hackerTheme.info('Fetching pending requests...')).start();
346
-
347
- try {
348
- const { data: requests, error } = await supabase
349
- .from('chat_requests')
350
- .select(`
351
- id,
352
- sender_id,
353
- profiles:sender_id (display_name, unique_id)
354
- `)
355
- .eq('receiver_id', profile.id)
356
- .eq('status', 'pending');
357
-
358
- if (error) throw error;
359
-
360
- spinner.stop();
361
-
362
- if (requests.length === 0) {
363
- console.log(hackerTheme.info('No pending requests.'));
364
- } else {
365
- const { requestAction } = await inquirer.prompt([
366
- {
367
- type: 'list',
368
- name: 'requestAction',
369
- message: hackerTheme.primary('PENDING REQUESTS:'),
370
- choices: [
371
- ...requests.map(r => ({
372
- name: `${r.profiles.display_name} (${r.profiles.unique_id})`,
373
- value: r
374
- })),
375
- { name: hackerTheme.error('[BACK] Main menu'), value: 'back' }
376
- ]
377
- }
378
- ]);
379
-
380
- if (requestAction !== 'back') {
381
- const { decision } = await inquirer.prompt([
382
- {
383
- type: 'list',
384
- name: 'decision',
385
- message: `Action for ${requestAction.profiles.display_name}:`,
386
- choices: [
387
- { name: hackerTheme.primary('[ACCEPT] Establish link'), value: 'accepted' },
388
- { name: hackerTheme.error('[REJECT] Deny access'), value: 'rejected' },
389
- { name: hackerTheme.system('[CANCEL] do nothing'), value: 'cancel' }
390
- ]
391
- }
392
- ]);
393
-
394
- if (decision !== 'cancel') {
395
- const { error: updateError } = await supabase
396
- .from('chat_requests')
397
- .update({ status: decision })
398
- .eq('id', requestAction.id);
399
-
400
- if (updateError) throw updateError;
401
- console.log(hackerTheme.primary(`Link status updated to: ${decision}`));
402
- }
403
- }
404
- }
405
- } catch (err) {
406
- spinner.fail(hackerTheme.error(`Failed to manage requests: ${err.message}`));
407
- }
408
-
409
- await showMainMenu({ id: profile.id });
410
- }
411
-
412
- async function handleChats(profile) {
413
- const spinner = ora(hackerTheme.info('Opening active channels...')).start();
414
-
415
- try {
416
- // Get accepted requests where user is sender or receiver
417
- const { data: links, error } = await supabase
418
- .from('chat_requests')
419
- .select(`
420
- id,
421
- sender_id,
422
- receiver_id,
423
- sender:sender_id (id, display_name, unique_id),
424
- receiver:receiver_id (id, display_name, unique_id)
425
- `)
426
- .or(`sender_id.eq.${profile.id},receiver_id.eq.${profile.id}`)
427
- .eq('status', 'accepted');
428
-
429
- if (error) throw error;
430
-
431
- spinner.stop();
432
-
433
- if (links.length === 0) {
434
- console.log(hackerTheme.info('No active chats found. Find someone by ID first!'));
435
- } else {
436
- const { chat } = await inquirer.prompt([
437
- {
438
- type: 'list',
439
- name: 'chat',
440
- message: hackerTheme.primary('ACTIVE CHANNELS:'),
441
- choices: [
442
- ...links.map(l => {
443
- const other = l.sender_id === profile.id ? l.receiver : l.sender;
444
- return { name: `${other.display_name} (${other.unique_id})`, value: other };
445
- }),
446
- { name: hackerTheme.error('[BACK] Main menu'), value: 'back' }
447
- ]
448
- }
449
- ]);
450
-
451
- if (chat !== 'back') {
452
- await startChat(profile, chat);
453
- }
454
- }
455
- } catch (err) {
456
- spinner.fail(hackerTheme.error(`Failed to load chats: ${err.message}`));
457
- }
458
-
459
- await showMainMenu({ id: profile.id });
460
- }
461
-
462
- async function startChat(me, other) {
463
- clearConsole();
464
- showBanner();
465
- renderBox(`CONNECTION ESTABLISHED\nPEER: ${other.display_name}\nID: ${other.unique_id}\nSTATUS: ENCRYPTED`, 'SECURE_CHANNEL');
466
- console.log(hackerTheme.system('Type "/help" for protocol list or "/exit" to disconnect.\n'));
467
-
468
- // Fetch history
469
- const { data: messages, error: fetchError } = await supabase
470
- .from('messages')
471
- .select('*')
472
- .or(`and(sender_id.eq.${me.id},receiver_id.eq.${other.id}),and(sender_id.eq.${other.id},receiver_id.eq.${me.id})`)
473
- .order('created_at', { ascending: true });
474
-
475
- if (!fetchError) {
476
- messages.forEach(msg => {
477
- const label = msg.sender_id === me.id ? hackerTheme.primary('[YOU]') : hackerTheme.secondary(`[${other.display_name}]`);
478
- console.log(`${label}: ${msg.content}`);
479
- });
480
- }
481
-
482
- // Subscribe to new messages
483
- const channel = supabase
484
- .channel(`chat_${me.id}_${other.id}`)
485
- .on('postgres_changes', {
486
- event: 'INSERT',
487
- schema: 'public',
488
- table: 'messages',
489
- filter: `receiver_id=eq.${me.id}`
490
- }, payload => {
491
- if (payload.new.sender_id === other.id) {
492
- console.log(`${hackerTheme.secondary(`[${other.display_name}]`)}: ${payload.new.content}`);
493
- }
494
- })
495
- .subscribe();
496
-
497
- let active = true;
498
- while (active) {
499
- const { content } = await inquirer.prompt([
500
- { type: 'input', name: 'content', message: hackerTheme.primary('>_ ') }
501
- ]);
502
-
503
- if (content.startsWith('/')) {
504
- const cmd = content.split(' ')[0].toLowerCase();
505
- switch (cmd) {
506
- case '/exit':
507
- active = false;
508
- break;
509
- case '/help':
510
- renderBox(
511
- '/help - Show this manual\n' +
512
- '/clear - Flush display buffer\n' +
513
- '/info - Peer identity status\n' +
514
- '/shrug - Inject ASCII shrug\n' +
515
- '/flip - Table maneuver\n' +
516
- '/nuke - Emergency disconnect',
517
- 'COMMAND_PROTOCOLS'
518
- );
519
- break;
520
- case '/clear':
521
- clearConsole();
522
- showBanner();
523
- renderBox(`PEER: ${other.display_name}\nID: ${other.unique_id}`, 'SESSION_RECOVERED');
524
- break;
525
- case '/info':
526
- renderBox(`NAME: ${other.display_name}\nUID: ${other.unique_id}\nLINK_STABILITY: 100%`, 'PEER_INTEL');
527
- break;
528
- case '/shrug':
529
- await sendMessage(me.id, other.id, '¯\\_(ツ)_/¯');
530
- break;
531
- case '/flip':
532
- await sendMessage(me.id, other.id, '(╯°□°)╯︵ ┻━┻');
533
- break;
534
- case '/nuke':
535
- await handleNuke();
536
- process.exit(0);
537
- break;
538
- default:
539
- console.log(hackerTheme.error(`[SYSTEM] Invalid protocol: ${cmd}`));
540
- }
541
- } else if (content.trim()) {
542
- await sendMessage(me.id, other.id, content);
543
- }
544
- }
545
-
546
- supabase.removeChannel(channel);
547
- }
548
-
549
- async function sendMessage(senderId, receiverId, content) {
550
- const { error: sendError } = await supabase
551
- .from('messages')
552
- .insert([{ sender_id: senderId, receiver_id: receiverId, content }]);
553
-
554
- if (sendError) {
555
- console.log(hackerTheme.error(`[ERROR] Transmission failed: ${sendError.message}`));
556
- } else {
557
- // Local echo for self messages if not handled by subscription (subscription only filters for receiver)
558
- console.log(`${hackerTheme.primary('[YOU]')}: ${content}`);
559
- }
560
- }
561
-
562
- async function handleNuke() {
563
- const chars = '█▓▒░/\\<>#!?@$%^&*()';
564
- const lines = 10;
565
- for (let i = 0; i < lines; i++) {
566
- let noise = '';
567
- for (let j = 0; j < 50; j++) {
568
- noise += chars[Math.floor(Math.random() * chars.length)];
569
- }
570
- console.log(hackerTheme.error(noise));
571
- await SLEEP(50);
572
- }
573
- console.log(hackerTheme.error('\n!!! CRITICAL SYSTEM FAILURE: EMERGENCY SHUTDOWN INITIATED !!!\n'));
574
- await SLEEP(500);
575
- }
576
-
577
- main();
package/update_schema.sql DELETED
@@ -1 +0,0 @@
1
- ALTER TABLE profiles ADD COLUMN IF NOT EXISTS chat_password TEXT;
package/website/README.md DELETED
@@ -1,36 +0,0 @@
1
- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
-
3
- ## Getting Started
4
-
5
- First, run the development server:
6
-
7
- ```bash
8
- npm run dev
9
- # or
10
- yarn dev
11
- # or
12
- pnpm dev
13
- # or
14
- bun dev
15
- ```
16
-
17
- Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
-
19
- You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
-
21
- This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
-
23
- ## Learn More
24
-
25
- To learn more about Next.js, take a look at the following resources:
26
-
27
- - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
- - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
-
30
- You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
-
32
- ## Deploy on Vercel
33
-
34
- The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
-
36
- Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Binary file