ninja-terminals 2.0.0 → 2.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/public/app.js CHANGED
@@ -2,6 +2,190 @@
2
2
 
3
3
  const WS_BASE = `ws://${location.host}`;
4
4
  const API_BASE = '';
5
+ const AUTH_API = '/api';
6
+ const TOKEN_KEY = 'ninja_token';
7
+
8
+ // ── Auth Module ──────────────────────────────────────────────
9
+
10
+ const auth = {
11
+ token: null,
12
+ user: null,
13
+ tier: null,
14
+ terminalsMax: 2,
15
+
16
+ init() {
17
+ const stored = localStorage.getItem(TOKEN_KEY);
18
+ if (!stored) return false;
19
+
20
+ try {
21
+ // Decode JWT payload (base64url)
22
+ const parts = stored.split('.');
23
+ if (parts.length !== 3) {
24
+ localStorage.removeItem(TOKEN_KEY);
25
+ return false;
26
+ }
27
+
28
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
29
+
30
+ // Check expiration
31
+ if (payload.exp && payload.exp * 1000 < Date.now()) {
32
+ localStorage.removeItem(TOKEN_KEY);
33
+ return false;
34
+ }
35
+
36
+ this.token = stored;
37
+ this.user = payload.sub || payload.email || payload.username || null;
38
+ return true;
39
+ } catch {
40
+ localStorage.removeItem(TOKEN_KEY);
41
+ return false;
42
+ }
43
+ },
44
+
45
+ async login(usernameOrEmail, password) {
46
+ const res = await fetch(`${AUTH_API}/auth/login`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ username: usernameOrEmail, password }),
50
+ });
51
+
52
+ if (!res.ok) {
53
+ const err = await res.json().catch(() => ({}));
54
+ throw new Error(err.message || 'Login failed');
55
+ }
56
+
57
+ const data = await res.json();
58
+ this.token = data.token || data.accessToken;
59
+ localStorage.setItem(TOKEN_KEY, this.token);
60
+
61
+ await this.validateTier();
62
+ return data;
63
+ },
64
+
65
+ async activateLicense(key) {
66
+ const res = await fetch(`${AUTH_API}/ninja/activate-license`, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ licenseKey: key }),
70
+ });
71
+
72
+ if (!res.ok) {
73
+ const err = await res.json().catch(() => ({}));
74
+ throw new Error(err.message || 'Invalid license key');
75
+ }
76
+
77
+ const data = await res.json();
78
+ this.token = data.token || data.accessToken;
79
+ localStorage.setItem(TOKEN_KEY, this.token);
80
+
81
+ await this.validateTier();
82
+ return data;
83
+ },
84
+
85
+ async validateTier() {
86
+ const res = await fetch(`${API_BASE}/api/session`, {
87
+ method: 'POST',
88
+ headers: {
89
+ 'Content-Type': 'application/json',
90
+ ...this.getAuthHeader(),
91
+ },
92
+ body: JSON.stringify({ token: this.token }),
93
+ });
94
+
95
+ if (!res.ok) {
96
+ // Session validation failed, but we still have local token
97
+ // Proceed with defaults
98
+ console.warn('Session validation failed, using defaults');
99
+ return;
100
+ }
101
+
102
+ const data = await res.json();
103
+ this.tier = data.tier || 'free';
104
+ this.terminalsMax = data.terminalsMax || 2;
105
+ if (data.user) this.user = data.user;
106
+ },
107
+
108
+ async logout() {
109
+ try {
110
+ await fetch(`${API_BASE}/api/session`, {
111
+ method: 'DELETE',
112
+ headers: this.getAuthHeader(),
113
+ });
114
+ } catch {
115
+ // Ignore errors on logout
116
+ }
117
+
118
+ this.token = null;
119
+ this.user = null;
120
+ this.tier = null;
121
+ localStorage.removeItem(TOKEN_KEY);
122
+
123
+ showAuthOverlay();
124
+ },
125
+
126
+ getAuthHeader() {
127
+ return this.token ? { 'Authorization': `Bearer ${this.token}` } : {};
128
+ },
129
+ };
130
+
131
+ // ── Auth UI ──────────────────────────────────────────────────
132
+
133
+ function showAuthOverlay() {
134
+ const overlay = document.getElementById('auth-overlay');
135
+ overlay.classList.remove('hidden');
136
+ document.getElementById('login-email').focus();
137
+ }
138
+
139
+ function hideAuthOverlay() {
140
+ const overlay = document.getElementById('auth-overlay');
141
+ overlay.classList.add('hidden');
142
+ }
143
+
144
+ function setupAuthForms() {
145
+ const loginForm = document.getElementById('login-form');
146
+ const licenseForm = document.getElementById('license-form');
147
+ const loginError = document.getElementById('login-error');
148
+ const logoutBtn = document.getElementById('logout-btn');
149
+
150
+ loginForm.addEventListener('submit', async (e) => {
151
+ e.preventDefault();
152
+ loginError.textContent = '';
153
+
154
+ const email = document.getElementById('login-email').value.trim();
155
+ const password = document.getElementById('login-password').value;
156
+
157
+ try {
158
+ await auth.login(email, password);
159
+ hideAuthOverlay();
160
+ startApp();
161
+ } catch (err) {
162
+ loginError.textContent = err.message;
163
+ }
164
+ });
165
+
166
+ licenseForm.addEventListener('submit', async (e) => {
167
+ e.preventDefault();
168
+ loginError.textContent = '';
169
+
170
+ const key = document.getElementById('license-key').value.trim();
171
+ if (!key) {
172
+ loginError.textContent = 'Please enter a license key';
173
+ return;
174
+ }
175
+
176
+ try {
177
+ await auth.activateLicense(key);
178
+ hideAuthOverlay();
179
+ startApp();
180
+ } catch (err) {
181
+ loginError.textContent = err.message;
182
+ }
183
+ });
184
+
185
+ logoutBtn.addEventListener('click', () => {
186
+ auth.logout();
187
+ });
188
+ }
5
189
 
6
190
  // ── State ────────────────────────────────────────────────────
7
191
 
@@ -208,8 +392,9 @@ function createTerminalUI(termData) {
208
392
  try { fitAddon.fit(); } catch {}
209
393
  });
210
394
 
211
- // WebSocket
212
- const ws = new WebSocket(`${WS_BASE}/ws/${id}`);
395
+ // WebSocket — include auth token in query string
396
+ const wsUrl = auth.token ? `${WS_BASE}/ws/${id}?token=${encodeURIComponent(auth.token)}` : `${WS_BASE}/ws/${id}`;
397
+ const ws = new WebSocket(wsUrl);
213
398
  ws.binaryType = 'arraybuffer';
214
399
 
215
400
  ws.onopen = () => {
@@ -302,7 +487,7 @@ function startLabelEdit(id, labelEl) {
302
487
  // Persist to server
303
488
  fetch(`${API_BASE}/api/terminals/${id}/label`, {
304
489
  method: 'POST',
305
- headers: { 'Content-Type': 'application/json' },
490
+ headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
306
491
  body: JSON.stringify({ label: newLabel }),
307
492
  }).catch(() => {});
308
493
  };
@@ -410,7 +595,7 @@ async function closeTerminal(id) {
410
595
  if (!t) return;
411
596
 
412
597
  try {
413
- await fetch(`${API_BASE}/api/terminals/${id}`, { method: 'DELETE' });
598
+ await fetch(`${API_BASE}/api/terminals/${id}`, { method: 'DELETE', headers: auth.getAuthHeader() });
414
599
  } catch {}
415
600
 
416
601
  t.ws.close();
@@ -432,7 +617,7 @@ async function closeTerminal(id) {
432
617
 
433
618
  async function killTerminal(id) {
434
619
  try {
435
- await fetch(`${API_BASE}/api/terminals/${id}/kill`, { method: 'POST' });
620
+ await fetch(`${API_BASE}/api/terminals/${id}/kill`, { method: 'POST', headers: auth.getAuthHeader() });
436
621
  addFeedEntry(`Kill sent to terminal`, id);
437
622
  } catch (err) {
438
623
  console.error('Kill failed:', err);
@@ -444,7 +629,7 @@ async function pauseTerminal(id) {
444
629
  try {
445
630
  await fetch(`${API_BASE}/api/terminals/${id}/input`, {
446
631
  method: 'POST',
447
- headers: { 'Content-Type': 'application/json' },
632
+ headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
448
633
  body: JSON.stringify({ text: '\x1b' }),
449
634
  });
450
635
  addFeedEntry(`Escape sent to terminal`, id);
@@ -455,7 +640,7 @@ async function pauseTerminal(id) {
455
640
 
456
641
  async function restartTerminal(id) {
457
642
  try {
458
- await fetch(`${API_BASE}/api/terminals/${id}/restart`, { method: 'POST' });
643
+ await fetch(`${API_BASE}/api/terminals/${id}/restart`, { method: 'POST', headers: auth.getAuthHeader() });
459
644
  addFeedEntry(`Restart requested`, id);
460
645
  } catch (err) {
461
646
  console.error('Restart failed:', err);
@@ -474,7 +659,7 @@ async function addNewTerminal() {
474
659
  try {
475
660
  const res = await fetch(`${API_BASE}/api/terminals`, {
476
661
  method: 'POST',
477
- headers: { 'Content-Type': 'application/json' },
662
+ headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
478
663
  });
479
664
  const data = await res.json();
480
665
  createTerminalUI(data);
@@ -699,7 +884,7 @@ function connectSSE() {
699
884
 
700
885
  async function fetchTasks() {
701
886
  try {
702
- const res = await fetch(`${API_BASE}/api/tasks`);
887
+ const res = await fetch(`${API_BASE}/api/tasks`, { headers: auth.getAuthHeader() });
703
888
  if (!res.ok) {
704
889
  taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
705
890
  return;
@@ -735,7 +920,7 @@ function renderTasks(tasks) {
735
920
 
736
921
  async function pollStatus() {
737
922
  try {
738
- const res = await fetch(`${API_BASE}/api/terminals`);
923
+ const res = await fetch(`${API_BASE}/api/terminals`, { headers: auth.getAuthHeader() });
739
924
  const list = await res.json();
740
925
 
741
926
  for (const item of list) {
@@ -815,19 +1000,22 @@ window.addEventListener('resize', () => {
815
1000
  resizeTimeout = setTimeout(() => fitAll(), 100);
816
1001
  });
817
1002
 
818
- // ── Initialize ───────────────────────────────────────────────
1003
+ // ── Start App (after auth) ───────────────────────────────────
819
1004
 
820
- async function init() {
1005
+ async function startApp() {
821
1006
  // Request desktop notification permission
822
1007
  requestNotificationPermission();
823
1008
 
824
1009
  // Setup sidebar
825
1010
  setupSidebar();
826
1011
  setupAddTask();
1012
+ setupLearnings();
827
1013
 
828
1014
  // Load existing terminals
829
1015
  try {
830
- const res = await fetch(`${API_BASE}/api/terminals`);
1016
+ const res = await fetch(`${API_BASE}/api/terminals`, {
1017
+ headers: auth.getAuthHeader(),
1018
+ });
831
1019
  const list = await res.json();
832
1020
  for (const item of list) {
833
1021
  createTerminalUI(item);
@@ -857,4 +1045,85 @@ async function init() {
857
1045
  addFeedEntry('Ninja Terminals v2 started');
858
1046
  }
859
1047
 
1048
+ // ── Learnings Module ───────────────────────────────────────────
1049
+
1050
+ function setupLearnings() {
1051
+ const btn = document.getElementById('learnings-btn');
1052
+ const overlay = document.getElementById('learnings-overlay');
1053
+ const closeBtn = document.getElementById('learnings-close');
1054
+ const refreshBtn = document.getElementById('learnings-refresh');
1055
+ const endSessionBtn = document.getElementById('learnings-end-session');
1056
+ const content = document.getElementById('learnings-content');
1057
+
1058
+ if (!btn || !overlay) return;
1059
+
1060
+ async function fetchLearnings() {
1061
+ content.textContent = 'Loading...';
1062
+ try {
1063
+ const res = await fetch(`${API_BASE}/api/learnings/latest`, {
1064
+ headers: auth.getAuthHeader(),
1065
+ });
1066
+ if (!res.ok) throw new Error('Failed to fetch');
1067
+ const data = await res.json();
1068
+ content.textContent = data.plainText || 'No learnings available yet.';
1069
+ } catch (err) {
1070
+ content.textContent = `Error: ${err.message}\n\nRun some sessions first to generate learnings.`;
1071
+ }
1072
+ }
1073
+
1074
+ async function endSession() {
1075
+ content.textContent = 'Analyzing session...';
1076
+ try {
1077
+ const res = await fetch(`${API_BASE}/api/session/end`, {
1078
+ method: 'POST',
1079
+ headers: auth.getAuthHeader(),
1080
+ });
1081
+ if (!res.ok) throw new Error('Failed to end session');
1082
+ const data = await res.json();
1083
+ content.textContent = data.learningSummary?.plainText || 'Session ended. No learnings generated.';
1084
+ addFeedEntry('Session analyzed - check learnings');
1085
+ } catch (err) {
1086
+ content.textContent = `Error: ${err.message}`;
1087
+ }
1088
+ }
1089
+
1090
+ btn.addEventListener('click', () => {
1091
+ overlay.classList.remove('hidden');
1092
+ fetchLearnings();
1093
+ });
1094
+
1095
+ closeBtn.addEventListener('click', () => {
1096
+ overlay.classList.add('hidden');
1097
+ });
1098
+
1099
+ overlay.addEventListener('click', (e) => {
1100
+ if (e.target === overlay) {
1101
+ overlay.classList.add('hidden');
1102
+ }
1103
+ });
1104
+
1105
+ refreshBtn.addEventListener('click', fetchLearnings);
1106
+ endSessionBtn.addEventListener('click', endSession);
1107
+ }
1108
+
1109
+ // ── Initialize ───────────────────────────────────────────────
1110
+
1111
+ async function init() {
1112
+ // Setup auth form handlers
1113
+ setupAuthForms();
1114
+
1115
+ // Check for existing valid session
1116
+ if (auth.init()) {
1117
+ try {
1118
+ await auth.validateTier();
1119
+ } catch (err) {
1120
+ console.warn('Tier validation failed:', err);
1121
+ }
1122
+ hideAuthOverlay();
1123
+ startApp();
1124
+ } else {
1125
+ showAuthOverlay();
1126
+ }
1127
+ }
1128
+
860
1129
  init();
package/public/index.html CHANGED
@@ -11,12 +11,57 @@
11
11
  <link rel="stylesheet" href="style.css">
12
12
  </head>
13
13
  <body>
14
+ <!-- Learnings Modal -->
15
+ <div id="learnings-overlay" class="hidden">
16
+ <div class="learnings-modal">
17
+ <div class="learnings-header">
18
+ <h2>🧠 Session Learnings</h2>
19
+ <button id="learnings-close" class="learnings-close">&times;</button>
20
+ </div>
21
+ <pre id="learnings-content" class="learnings-content">Loading...</pre>
22
+ <div class="learnings-footer">
23
+ <button id="learnings-refresh" class="auth-btn">Refresh</button>
24
+ <button id="learnings-end-session" class="auth-btn auth-btn-secondary">End Session & Analyze</button>
25
+ </div>
26
+ </div>
27
+ </div>
28
+
29
+ <!-- Auth Overlay -->
30
+ <div id="auth-overlay">
31
+ <div class="auth-card">
32
+ <div class="auth-stripes">
33
+ <div class="stripe s1"></div>
34
+ <div class="stripe s2"></div>
35
+ <div class="stripe s3"></div>
36
+ <div class="stripe s4"></div>
37
+ </div>
38
+ <div class="auth-card-inner">
39
+ <h1 class="logo-text">NINJA TERMINALS</h1>
40
+ <p class="auth-subtitle">Multi-Agent Claude Code Orchestrator</p>
41
+ <form id="login-form">
42
+ <input type="text" id="login-email" placeholder="Email or username" required autocomplete="username">
43
+ <input type="password" id="login-password" placeholder="Password" required autocomplete="current-password">
44
+ <button type="submit" class="auth-btn">Sign In</button>
45
+ <p class="auth-error" id="login-error"></p>
46
+ </form>
47
+ <div class="auth-divider"><span>or</span></div>
48
+ <form id="license-form">
49
+ <input type="text" id="license-key" placeholder="Enter license key" autocomplete="off">
50
+ <button type="submit" class="auth-btn auth-btn-secondary">Activate License</button>
51
+ </form>
52
+ <p class="auth-footer">Don't have an account? <a href="https://ninjaterminals.com" target="_blank">Sign up</a></p>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
14
57
  <div id="app">
15
58
  <aside id="sidebar">
16
59
  <div class="sidebar-header">
17
60
  <div class="logo-card">
18
61
  <span class="logo">NINJA TERMINALS</span>
19
62
  <span class="logo-sub">Multi-Agent Orchestrator</span>
63
+ <button id="learnings-btn" class="learnings-btn" title="View Session Learnings">🧠</button>
64
+ <button id="logout-btn" class="logout-btn" title="Sign out">Logout</button>
20
65
  </div>
21
66
  <div class="logo-stripes">
22
67
  <div class="stripe s1"></div>