thinkncollab-shell 0.0.4 → 0.0.6
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 +392 -16
- package/package.json +1 -1
- package/src/commands/auth/login.js +58 -58
- package/src/commands/auth/register.js +52 -0
- package/src/commands/index.js +62 -43
- package/src/commands/room/join.js +119 -45
- package/src/commands/shell/notify.js +88 -0
- package/src/commands/tasks/complete.js +47 -0
- package/src/commands/tasks/create-task.js +101 -0
- package/src/commands/tasks/suggestTask.js +0 -0
- package/src/commands/tasks/task.js +75 -0
- package/src/commands/tasks/tasks.js +74 -0
- package/src/commands/terminal/share.js +252 -0
- package/src/core/api-client.js +104 -50
- package/src/core/notification-window.js +2 -1
- package/src/core/project-config.js +100 -0
- package/src/core/shell.js +222 -210
- package/src/core/websocket.js +166 -101
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
name: 'myTask',
|
|
5
|
+
description: 'My Tasks in the Room',
|
|
6
|
+
aliases: ['mytasks'],
|
|
7
|
+
requiresAuth: true,
|
|
8
|
+
|
|
9
|
+
async execute(args, shell) {
|
|
10
|
+
const user = shell.api.getUser();
|
|
11
|
+
const token = shell.api.getToken();
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (!user || !token) {
|
|
15
|
+
console.log(chalk.red('❌ Please login first: login'));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// FIX: getCurrentRoom() lives on shell.ws, not shell.api
|
|
20
|
+
const roomId = shell.ws.getCurrentRoom();
|
|
21
|
+
|
|
22
|
+
if (!roomId) {
|
|
23
|
+
console.log(chalk.yellow('⚠️ Please join a room first: join <room-id>'));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!shell.api) {
|
|
28
|
+
console.log(chalk.red('❌ API not initialized'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.dim(' Fetching tasks...'));
|
|
33
|
+
|
|
34
|
+
// FIX: pass `roomId` (was passing undefined `roomId` from shell.api.getCurrentRoom())
|
|
35
|
+
const result = await shell.api.mytasks(user, token, roomId);
|
|
36
|
+
|
|
37
|
+
if (!result || !result.tasks || result.tasks.length === 0) {
|
|
38
|
+
console.log(chalk.yellow(' No tasks found in this room.'));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(chalk.cyan(`\n📋 Your Tasks in room: ${chalk.white(roomId)}\n`));
|
|
43
|
+
console.log(chalk.dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
44
|
+
|
|
45
|
+
result.tasks.forEach((task, index) => {
|
|
46
|
+
const statusColor = {
|
|
47
|
+
completed: chalk.green,
|
|
48
|
+
in_progress: chalk.yellow,
|
|
49
|
+
pending: chalk.dim,
|
|
50
|
+
}[task.status] || chalk.white;
|
|
51
|
+
|
|
52
|
+
const status = statusColor(`[${task.status || 'pending'}]`);
|
|
53
|
+
const title = chalk.white(task.title || task.name || 'Untitled');
|
|
54
|
+
const due = task.dueDate ? chalk.dim(` — due: ${new Date(task.dueDate).toLocaleDateString()}`) : '';
|
|
55
|
+
|
|
56
|
+
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${title}${due}`);
|
|
57
|
+
|
|
58
|
+
if (task.description) {
|
|
59
|
+
console.log(` ${chalk.dim(task.description)}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
console.log(chalk.dim('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
64
|
+
console.log(chalk.green(` ✅ ${result.tasks.length} task(s) fetched successfully.\n`));
|
|
65
|
+
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.name === 'ExitPromptError' || err.message?.includes('force closed')) {
|
|
68
|
+
console.log(chalk.yellow('⚠️ Cancelled'));
|
|
69
|
+
} else {
|
|
70
|
+
console.log(chalk.red(`❌ Fetching tasks failed: ${err.message}`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/terminal/share.js
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* share start — start sharing current terminal
|
|
6
|
+
* share stop — stop sharing
|
|
7
|
+
* share status — show who's viewing / requesting
|
|
8
|
+
* share grant <uid> — grant write to a viewer
|
|
9
|
+
* share revoke <uid>— revoke write
|
|
10
|
+
* share kick <uid> — kick a viewer
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
name: 'share',
|
|
17
|
+
description: 'Share your terminal with room members',
|
|
18
|
+
aliases: ['terminal-share', 'ts'],
|
|
19
|
+
requiresAuth: true,
|
|
20
|
+
requiresRoom: true,
|
|
21
|
+
|
|
22
|
+
async execute(args, shell) {
|
|
23
|
+
const sub = (args[0] || '').toLowerCase();
|
|
24
|
+
|
|
25
|
+
switch (sub) {
|
|
26
|
+
case 'start': return cmdStart(shell);
|
|
27
|
+
case 'stop': return cmdStop(shell);
|
|
28
|
+
case 'status': return cmdStatus(shell);
|
|
29
|
+
case 'grant': return cmdGrant(args[1], shell);
|
|
30
|
+
case 'revoke': return cmdRevoke(args[1], shell);
|
|
31
|
+
case 'kick': return cmdKick(args[1], shell);
|
|
32
|
+
default:
|
|
33
|
+
printHelp();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── start ─────────────────────────────────────────────────────────────────────
|
|
39
|
+
async function cmdStart(shell) {
|
|
40
|
+
if (shell._shareActive) {
|
|
41
|
+
console.log(chalk.yellow('⚠️ Already sharing. Run "share stop" first.'));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const roomId = shell.ws.getCurrentRoom();
|
|
46
|
+
if (!roomId) {
|
|
47
|
+
console.log(chalk.red('❌ Join a room first: join <room-id>'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const timeout = setTimeout(() => {
|
|
53
|
+
shell.ws.socket.off('terminal:share:started', onStarted);
|
|
54
|
+
console.log(chalk.red('❌ Share start timeout'));
|
|
55
|
+
resolve();
|
|
56
|
+
}, 6000);
|
|
57
|
+
|
|
58
|
+
function onStarted(data) {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
shell._shareActive = true;
|
|
61
|
+
shell._shareSessionId = data.sessionId;
|
|
62
|
+
|
|
63
|
+
console.log(chalk.green(`\n✅ Terminal sharing started`));
|
|
64
|
+
console.log(chalk.dim(` Session : ${data.sessionId}`));
|
|
65
|
+
console.log(chalk.dim(` Room : ${roomId}`));
|
|
66
|
+
console.log(chalk.dim(` URL : /terminal/${data.sessionId}`));
|
|
67
|
+
console.log(chalk.dim(`\n Room members can now view your terminal.`));
|
|
68
|
+
console.log(chalk.dim(` Use "share grant <userId>" to give write access.\n`));
|
|
69
|
+
|
|
70
|
+
// Hook into shell output to stream it
|
|
71
|
+
_hookOutput(shell, data.sessionId);
|
|
72
|
+
|
|
73
|
+
resolve();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
shell.ws.socket.once('terminal:share:started', onStarted);
|
|
77
|
+
shell.ws.socket.emit('terminal:share:start', { roomId });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── stop ──────────────────────────────────────────────────────────────────────
|
|
82
|
+
async function cmdStop(shell) {
|
|
83
|
+
if (!shell._shareActive) {
|
|
84
|
+
console.log(chalk.yellow('⚠️ No active share session.'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
shell.ws.socket.emit('terminal:share:stop');
|
|
89
|
+
_unhookOutput(shell);
|
|
90
|
+
shell._shareActive = false;
|
|
91
|
+
shell._shareSessionId = null;
|
|
92
|
+
console.log(chalk.green('✅ Terminal sharing stopped.'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── status ────────────────────────────────────────────────────────────────────
|
|
96
|
+
async function cmdStatus(shell) {
|
|
97
|
+
if (!shell._shareActive || !shell._shareSessionId) {
|
|
98
|
+
console.log(chalk.yellow('// No active share session'));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mem = shell._shareViewers || {};
|
|
103
|
+
const viewers = Object.values(mem);
|
|
104
|
+
|
|
105
|
+
console.log(chalk.cyan(`\n Terminal Share — ${shell._shareSessionId}`));
|
|
106
|
+
console.log(chalk.dim(' ' + '─'.repeat(44)));
|
|
107
|
+
|
|
108
|
+
if (!viewers.length) {
|
|
109
|
+
console.log(chalk.dim(' No viewers yet.'));
|
|
110
|
+
} else {
|
|
111
|
+
viewers.forEach(v => {
|
|
112
|
+
const access = v.canWrite ? chalk.blue('[write]') : chalk.dim('[read]');
|
|
113
|
+
const req = v.requested ? chalk.yellow(' ← requesting write') : '';
|
|
114
|
+
console.log(` ${chalk.green('●')} ${chalk.white(v.name)} ${access}${req}`);
|
|
115
|
+
console.log(chalk.dim(` userId: ${v.userId}`));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
console.log();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── grant ─────────────────────────────────────────────────────────────────────
|
|
122
|
+
async function cmdGrant(userId, shell) {
|
|
123
|
+
if (!userId) { console.log(chalk.red('Usage: share grant <userId>')); return; }
|
|
124
|
+
if (!shell._shareActive) { console.log(chalk.red('❌ No active share session')); return; }
|
|
125
|
+
|
|
126
|
+
shell.ws.socket.emit('terminal:grant', {
|
|
127
|
+
sessionId: shell._shareSessionId,
|
|
128
|
+
userId,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Update local viewer state
|
|
132
|
+
if (shell._shareViewers?.[userId]) {
|
|
133
|
+
shell._shareViewers[userId].canWrite = true;
|
|
134
|
+
shell._shareViewers[userId].requested = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(chalk.green(`✅ Write access granted to ${userId}`));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── revoke ────────────────────────────────────────────────────────────────────
|
|
141
|
+
async function cmdRevoke(userId, shell) {
|
|
142
|
+
if (!userId) { console.log(chalk.red('Usage: share revoke <userId>')); return; }
|
|
143
|
+
if (!shell._shareActive) { console.log(chalk.red('❌ No active share session')); return; }
|
|
144
|
+
|
|
145
|
+
shell.ws.socket.emit('terminal:revoke', {
|
|
146
|
+
sessionId: shell._shareSessionId,
|
|
147
|
+
userId,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (shell._shareViewers?.[userId]) {
|
|
151
|
+
shell._shareViewers[userId].canWrite = false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log(chalk.green(`✅ Write access revoked from ${userId}`));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── kick ──────────────────────────────────────────────────────────────────────
|
|
158
|
+
async function cmdKick(userId, shell) {
|
|
159
|
+
if (!userId) { console.log(chalk.red('Usage: share kick <userId>')); return; }
|
|
160
|
+
if (!shell._shareActive) { console.log(chalk.red('❌ No active share session')); return; }
|
|
161
|
+
|
|
162
|
+
shell.ws.socket.emit('terminal:kick', {
|
|
163
|
+
sessionId: shell._shareSessionId,
|
|
164
|
+
userId,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (shell._shareViewers) delete shell._shareViewers[userId];
|
|
168
|
+
console.log(chalk.green(`✅ ${userId} kicked from session`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Output hooking ────────────────────────────────────────────────────────────
|
|
172
|
+
// Intercept process.stdout.write to stream output to viewers
|
|
173
|
+
|
|
174
|
+
let _originalWrite = null;
|
|
175
|
+
|
|
176
|
+
function _hookOutput(shell, sessionId) {
|
|
177
|
+
shell._shareViewers = {};
|
|
178
|
+
_originalWrite = process.stdout.write.bind(process.stdout);
|
|
179
|
+
|
|
180
|
+
// Remove any existing listeners to prevent duplicates on re-share
|
|
181
|
+
shell.ws.socket.off('terminal:viewerJoined');
|
|
182
|
+
shell.ws.socket.off('terminal:viewerLeft');
|
|
183
|
+
shell.ws.socket.off('terminal:writeRequest');
|
|
184
|
+
shell.ws.socket.off('terminal:input');
|
|
185
|
+
|
|
186
|
+
process.stdout.write = function(chunk, encoding, callback) {
|
|
187
|
+
const str = chunk.toString();
|
|
188
|
+
// Send to viewers — stream everything including ANSI colors
|
|
189
|
+
if (shell.ws.isConnected() && shell._shareActive) {
|
|
190
|
+
shell.ws.socket.emit('terminal:output', {
|
|
191
|
+
sessionId,
|
|
192
|
+
data: str,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Still write to own terminal
|
|
196
|
+
return _originalWrite(chunk, encoding, callback);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Listen for viewer events
|
|
200
|
+
shell.ws.socket.on('terminal:viewerJoined', (d) => {
|
|
201
|
+
if (!shell._shareViewers) shell._shareViewers = {};
|
|
202
|
+
shell._shareViewers[d.userId] = { userId: d.userId, name: d.name, canWrite: false, requested: false };
|
|
203
|
+
console.log(chalk.dim(`\n 👁️ ${d.name} is now viewing your terminal`));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
shell.ws.socket.on('terminal:viewerLeft', (d) => {
|
|
207
|
+
if (shell._shareViewers) delete shell._shareViewers[d.userId];
|
|
208
|
+
console.log(chalk.dim(`\n 👁️ ${d.name} stopped viewing`));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
shell.ws.socket.on('terminal:writeRequest', (d) => {
|
|
212
|
+
if (shell._shareViewers?.[d.userId]) shell._shareViewers[d.userId].requested = true;
|
|
213
|
+
console.log(chalk.yellow(`\n ✍️ ${d.name} is requesting write access`));
|
|
214
|
+
console.log(chalk.dim(` share grant ${d.userId} or share kick ${d.userId}\n`));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
shell.ws.socket.on('terminal:input', (d) => {
|
|
218
|
+
// A writer sent input — execute it in the shell
|
|
219
|
+
console.log(chalk.blue(`\n [${d.fromName}] → ${d.input}`));
|
|
220
|
+
// The shell will process this as if the user typed it
|
|
221
|
+
shell.execute(d.input.trim());
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _unhookOutput(shell) {
|
|
226
|
+
if (_originalWrite) {
|
|
227
|
+
process.stdout.write = _originalWrite;
|
|
228
|
+
_originalWrite = null;
|
|
229
|
+
}
|
|
230
|
+
shell.ws.socket.off('terminal:viewerJoined');
|
|
231
|
+
shell.ws.socket.off('terminal:viewerLeft');
|
|
232
|
+
shell.ws.socket.off('terminal:writeRequest');
|
|
233
|
+
shell.ws.socket.off('terminal:input');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Help ──────────────────────────────────────────────────────────────────────
|
|
237
|
+
function printHelp() {
|
|
238
|
+
console.log(chalk.cyan('\n Terminal Share Commands'));
|
|
239
|
+
console.log(chalk.dim(' ' + '─'.repeat(40)));
|
|
240
|
+
const rows = [
|
|
241
|
+
['share start', 'Start sharing your terminal'],
|
|
242
|
+
['share stop', 'Stop sharing'],
|
|
243
|
+
['share status', 'Show viewers and write requests'],
|
|
244
|
+
['share grant <userId>', 'Give write access to a viewer'],
|
|
245
|
+
['share revoke <userId>','Revoke write access'],
|
|
246
|
+
['share kick <userId>', 'Remove a viewer'],
|
|
247
|
+
];
|
|
248
|
+
rows.forEach(([cmd, desc]) => {
|
|
249
|
+
console.log(` ${chalk.green(cmd.padEnd(26))} ${chalk.dim(desc)}`);
|
|
250
|
+
});
|
|
251
|
+
console.log();
|
|
252
|
+
}
|
package/src/core/api-client.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Handles HTTP requests to the backend
|
|
2
|
+
* src/core/api-client.js
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
5
|
const https = require('https');
|
|
@@ -12,22 +11,19 @@ const os = require('os');
|
|
|
12
11
|
class ThinkNCollabAPI {
|
|
13
12
|
|
|
14
13
|
constructor(options = {}) {
|
|
15
|
-
this.apiUrl
|
|
16
|
-
this.wsUrl
|
|
17
|
-
this.session
|
|
14
|
+
this.apiUrl = (options.apiUrl || 'https://thinkncollab.com').replace(/\/$/, '');
|
|
15
|
+
this.wsUrl = (options.wsUrl || 'https://thinkncollab.com').replace(/\/$/, '');
|
|
16
|
+
this.session = null;
|
|
18
17
|
this.sessionPath = path.join(os.homedir(), '.thinkncollab', 'session.json');
|
|
19
|
-
|
|
20
|
-
// Load existing session on startup
|
|
21
18
|
this._loadSession();
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
// ─── Session
|
|
21
|
+
// ─── Session ──────────────────────────────────────────────────────────────
|
|
25
22
|
|
|
26
23
|
_loadSession() {
|
|
27
24
|
try {
|
|
28
|
-
if (fs.existsSync(this.sessionPath))
|
|
25
|
+
if (fs.existsSync(this.sessionPath))
|
|
29
26
|
this.session = fs.readJsonSync(this.sessionPath);
|
|
30
|
-
}
|
|
31
27
|
} catch {}
|
|
32
28
|
}
|
|
33
29
|
|
|
@@ -40,33 +36,40 @@ class ThinkNCollabAPI {
|
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
_clearSession() {
|
|
43
|
-
try {
|
|
44
|
-
|
|
45
|
-
this.session = null;
|
|
46
|
-
} catch {}
|
|
39
|
+
try { fs.removeSync(this.sessionPath); } catch {}
|
|
40
|
+
this.session = null;
|
|
47
41
|
}
|
|
48
42
|
|
|
49
|
-
isAuthenticated() {
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
getToken() {
|
|
54
|
-
return this.session?.token || null;
|
|
55
|
-
}
|
|
43
|
+
isAuthenticated() { return !!(this.session?.token); }
|
|
44
|
+
getToken() { return this.session?.token || null; }
|
|
56
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Returns user in a CONSISTENT shape used everywhere:
|
|
48
|
+
* { _id, userId, email, name, userType }
|
|
49
|
+
*
|
|
50
|
+
* Both _id and userId are the same string so callers can use either.
|
|
51
|
+
*/
|
|
57
52
|
getUser() {
|
|
58
|
-
|
|
53
|
+
const u = this.session?.user;
|
|
54
|
+
if (!u) return null;
|
|
55
|
+
const id = (u._id || u.userId || '').toString();
|
|
56
|
+
return {
|
|
57
|
+
_id: id,
|
|
58
|
+
userId: id, // ws.setUser / _emitJoin use userId
|
|
59
|
+
email: u.email || '',
|
|
60
|
+
name: u.name || '',
|
|
61
|
+
userType: u.userType || u.model || 'User',
|
|
62
|
+
};
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
// ─── HTTP helper ──────────────────────────────────────────────────────────
|
|
62
66
|
|
|
63
67
|
_request(method, endpoint, body = null, headers = {}) {
|
|
64
68
|
return new Promise((resolve, reject) => {
|
|
65
|
-
const url
|
|
66
|
-
const isHttps
|
|
67
|
-
const lib
|
|
68
|
-
|
|
69
|
-
const bodyStr = body ? JSON.stringify(body) : null;
|
|
69
|
+
const url = new URL(this.apiUrl + endpoint);
|
|
70
|
+
const isHttps = url.protocol === 'https:';
|
|
71
|
+
const lib = isHttps ? https : http;
|
|
72
|
+
const bodyStr = body ? JSON.stringify(body) : null;
|
|
70
73
|
|
|
71
74
|
const options = {
|
|
72
75
|
hostname: url.hostname,
|
|
@@ -74,8 +77,9 @@ class ThinkNCollabAPI {
|
|
|
74
77
|
path: url.pathname + url.search,
|
|
75
78
|
method,
|
|
76
79
|
headers: {
|
|
77
|
-
'Content-Type':
|
|
78
|
-
'Accept':
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'Accept': 'application/json',
|
|
82
|
+
...(this.session?.token ? { 'x-shell-token': this.session.token } : {}),
|
|
79
83
|
...headers,
|
|
80
84
|
...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {})
|
|
81
85
|
}
|
|
@@ -87,23 +91,21 @@ class ThinkNCollabAPI {
|
|
|
87
91
|
res.on('end', () => {
|
|
88
92
|
try {
|
|
89
93
|
const parsed = JSON.parse(data);
|
|
90
|
-
if (res.statusCode >= 400)
|
|
94
|
+
if (res.statusCode >= 400)
|
|
91
95
|
reject(new Error(parsed.error || parsed.message || `HTTP ${res.statusCode}`));
|
|
92
|
-
|
|
96
|
+
else
|
|
93
97
|
resolve(parsed);
|
|
94
|
-
}
|
|
95
98
|
} catch {
|
|
96
|
-
reject(new Error(`Invalid response: ${data}`));
|
|
99
|
+
reject(new Error(`Invalid response: ${data.slice(0, 100)}`));
|
|
97
100
|
}
|
|
98
101
|
});
|
|
99
102
|
});
|
|
100
103
|
|
|
101
104
|
req.on('error', (err) => {
|
|
102
|
-
if (err.code === 'ECONNREFUSED')
|
|
105
|
+
if (err.code === 'ECONNREFUSED')
|
|
103
106
|
reject(new Error(`Cannot connect to server at ${this.apiUrl} — is it running?`));
|
|
104
|
-
|
|
107
|
+
else
|
|
105
108
|
reject(err);
|
|
106
|
-
}
|
|
107
109
|
});
|
|
108
110
|
|
|
109
111
|
if (bodyStr) req.write(bodyStr);
|
|
@@ -111,34 +113,31 @@ class ThinkNCollabAPI {
|
|
|
111
113
|
});
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
// ─── Auth
|
|
116
|
+
// ─── Auth ─────────────────────────────────────────────────────────────────
|
|
115
117
|
|
|
116
118
|
async login(email, password) {
|
|
117
|
-
// Your backend reads from headers
|
|
118
119
|
const result = await this._request('POST', '/thinknsh/login', null, {
|
|
119
|
-
'x-email':
|
|
120
|
-
'x-password':
|
|
121
|
-
'x-machine-id':
|
|
120
|
+
'x-email': email,
|
|
121
|
+
'x-password': password,
|
|
122
|
+
'x-machine-id': os.hostname()
|
|
122
123
|
});
|
|
123
124
|
|
|
124
|
-
//
|
|
125
|
+
// Normalise: store with BOTH _id and userId so getUser() always works
|
|
125
126
|
this._saveSession({
|
|
126
127
|
token: result.shellToken,
|
|
127
128
|
user: {
|
|
128
|
-
_id:
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
_id: result._id,
|
|
130
|
+
userId: result._id, // explicit — ws needs this
|
|
131
|
+
email: result.email,
|
|
132
|
+
name: result.name,
|
|
133
|
+
userType: result.userType || result.model || 'User',
|
|
131
134
|
},
|
|
132
135
|
timestamp: new Date().toISOString()
|
|
133
136
|
});
|
|
134
137
|
|
|
135
138
|
return {
|
|
136
139
|
token: result.shellToken,
|
|
137
|
-
user:
|
|
138
|
-
_id: result._id,
|
|
139
|
-
email: result.email,
|
|
140
|
-
name: result.name,
|
|
141
|
-
}
|
|
140
|
+
user: this.getUser() // return normalised shape
|
|
142
141
|
};
|
|
143
142
|
}
|
|
144
143
|
|
|
@@ -151,6 +150,61 @@ class ThinkNCollabAPI {
|
|
|
151
150
|
if (!this.isAuthenticated()) throw new Error('Not logged in');
|
|
152
151
|
return this.getUser();
|
|
153
152
|
}
|
|
153
|
+
|
|
154
|
+
// ─── Rooms ────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* joinRoom — validates membership server-side and returns room meta.
|
|
158
|
+
* Called by commands/room/join.js BEFORE the WebSocket thinknsh:join emit.
|
|
159
|
+
*/
|
|
160
|
+
async joinRoom(roomId, password) {
|
|
161
|
+
if (!roomId) throw new Error('roomId is required');
|
|
162
|
+
|
|
163
|
+
const result = await this._request('POST', `/thinknsh/rooms/${roomId}/join`,
|
|
164
|
+
password ? { password } : null
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Normalise response — server may return different shapes
|
|
168
|
+
return {
|
|
169
|
+
room: result.room || result,
|
|
170
|
+
participants: result.participants || result.members || [],
|
|
171
|
+
recentMessages: result.recentMessages || result.history || [],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* getRooms — list rooms the user belongs to
|
|
177
|
+
*/
|
|
178
|
+
async getRooms() {
|
|
179
|
+
return this._request('GET', '/thinknsh/rooms');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Tasks ────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async mytasks(roomId, limit) {
|
|
185
|
+
if (!roomId) throw new Error('roomId is required');
|
|
186
|
+
const user = this.getUser();
|
|
187
|
+
let ep = `/thinknsh/tasks?roomId=${encodeURIComponent(roomId)}`;
|
|
188
|
+
if (user?._id) ep += `&userId=${encodeURIComponent(user._id)}`;
|
|
189
|
+
if (limit) ep += `&n=${limit}`;
|
|
190
|
+
return this._request('GET', ep);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Generic request wrapper for task command modules that call
|
|
195
|
+
* shell.api.request(method, path, { params, body })
|
|
196
|
+
*/
|
|
197
|
+
async request(method, endpoint, options = {}) {
|
|
198
|
+
let ep = endpoint;
|
|
199
|
+
|
|
200
|
+
// Append query params
|
|
201
|
+
if (options.params && Object.keys(options.params).length) {
|
|
202
|
+
const qs = new URLSearchParams(options.params).toString();
|
|
203
|
+
ep += (ep.includes('?') ? '&' : '?') + qs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return this._request(method.toUpperCase(), ep, options.body || null);
|
|
207
|
+
}
|
|
154
208
|
}
|
|
155
209
|
|
|
156
210
|
module.exports = ThinkNCollabAPI;
|
|
@@ -71,6 +71,7 @@ function printNotification(data) {
|
|
|
71
71
|
break;
|
|
72
72
|
}
|
|
73
73
|
case 'typing':
|
|
74
|
+
beep();
|
|
74
75
|
process.stdout.write(`\r${chalk.dim(`✏️ ${data.username} is typing... `)}`);
|
|
75
76
|
break;
|
|
76
77
|
case 'stoppedTyping':
|
|
@@ -92,7 +93,7 @@ function connect() {
|
|
|
92
93
|
let buffer = '';
|
|
93
94
|
|
|
94
95
|
client.connect(PORT, HOST, () => {
|
|
95
|
-
console.log(chalk.green('
|
|
96
|
+
console.log(chalk.green(' Connected to shell!\n'));
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
client.on('data', (chunk) => {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/project-config.js
|
|
3
|
+
*
|
|
4
|
+
* Shell-side reader for .tncproject binary files.
|
|
5
|
+
* NO crypto code — shell only reads the plaintext roomId prefix,
|
|
6
|
+
* then sends the raw file to the server for decryption.
|
|
7
|
+
*
|
|
8
|
+
* File layout (written by server):
|
|
9
|
+
* [4 bytes] "TNCP" magic
|
|
10
|
+
* [1 byte] version 0x01
|
|
11
|
+
* [24 bytes] roomId ASCII hex ← only readable part
|
|
12
|
+
* [12 bytes] AES-GCM IV ← opaque
|
|
13
|
+
* [16 bytes] AES-GCM auth tag ← opaque
|
|
14
|
+
* [N bytes] encrypted payload ← opaque
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs-extra');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
const CONFIG_FILE = '.tncproject';
|
|
22
|
+
const MAGIC = Buffer.from('TNCP');
|
|
23
|
+
const ROOM_ID_SIZE = 24;
|
|
24
|
+
const MIN_SIZE = 4 + 1 + ROOM_ID_SIZE + 12 + 16 + 2; // 59 bytes minimum
|
|
25
|
+
|
|
26
|
+
function findConfigFile(startDir = process.cwd()) {
|
|
27
|
+
let dir = path.resolve(startDir);
|
|
28
|
+
const root = path.parse(dir).root;
|
|
29
|
+
|
|
30
|
+
while (true) {
|
|
31
|
+
const candidate = path.join(dir, CONFIG_FILE);
|
|
32
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
33
|
+
if (dir === root || dir === os.homedir()) break;
|
|
34
|
+
dir = path.dirname(dir);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read and lightly validate a .tncproject file.
|
|
41
|
+
* Returns { filePath, roomId, fileBase64 } — no decryption.
|
|
42
|
+
*
|
|
43
|
+
* @throws if file is missing, wrong format, or roomId looks wrong
|
|
44
|
+
*/
|
|
45
|
+
function loadProjectConfig(startDir = process.cwd()) {
|
|
46
|
+
const filePath = findConfigFile(startDir);
|
|
47
|
+
|
|
48
|
+
if (!filePath) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'No .tncproject file found in this directory or any parent.\n' +
|
|
51
|
+
' Download it from: ThinkNCollab → Room → Project Config'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const buf = fs.readFileSync(filePath);
|
|
56
|
+
|
|
57
|
+
if (buf.length < MIN_SIZE) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'Invalid .tncproject — file too small or corrupted.\n' +
|
|
60
|
+
' Re-download it from the dashboard.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!buf.slice(0, 4).equals(MAGIC)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'Not a ThinkNCollab project config file.\n' +
|
|
67
|
+
' Re-download from the dashboard.'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const version = buf.readUInt8(4);
|
|
72
|
+
if (version !== 0x01) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Unsupported .tncproject version (${version}).\n` +
|
|
75
|
+
' Update thinkncollab-shell: npm update -g thinkncollab-shell'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read roomId from plaintext prefix — bytes 5..28
|
|
80
|
+
const roomId = buf.slice(5, 5 + ROOM_ID_SIZE).toString('ascii').trim();
|
|
81
|
+
|
|
82
|
+
if (!/^[a-f0-9]{24}$/i.test(roomId)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'Corrupted .tncproject — invalid roomId.\n' +
|
|
85
|
+
' Re-download it from the dashboard.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
filePath,
|
|
91
|
+
roomId,
|
|
92
|
+
fileBase64: buf.toString('base64'), // sent as-is to server
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasProjectConfig(startDir = process.cwd()) {
|
|
97
|
+
return !!findConfigFile(startDir);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { loadProjectConfig, hasProjectConfig, CONFIG_FILE };
|