thinkncollab-cli 0.0.10 → 0.0.12
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 +4 -1
- package/bin/index.js +105 -32
- package/commands/init.js +48 -0
- package/commands/pull.js +124 -0
- package/package.json +1 -1
package/Readme.md
CHANGED
package/bin/index.js
CHANGED
|
@@ -6,26 +6,14 @@ import os from "os";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
import crypto from "crypto";
|
|
8
8
|
import FormData from "form-data";
|
|
9
|
+
import init from "../commands/init"
|
|
9
10
|
|
|
10
11
|
const RC_FILE = path.join(os.homedir(), ".tncrc");
|
|
11
|
-
const
|
|
12
|
+
const VERSION_FILE = path.join(process.cwd(), ".tncversions");
|
|
13
|
+
const BASE_URL = "http://localhost:3001/rooms";
|
|
14
|
+
const CWD = process.cwd();
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
async function logout() {
|
|
15
|
-
try {
|
|
16
|
-
if (fs.existsSync(RC_FILE)) {
|
|
17
|
-
// Remove the .tncrc file safely
|
|
18
|
-
await fs.promises.rm(RC_FILE, { force: true });
|
|
19
|
-
console.log("✅ Logged out successfully. Local credentials removed.");
|
|
20
|
-
} else {
|
|
21
|
-
console.log("ℹ️ No active session found.");
|
|
22
|
-
}
|
|
23
|
-
} catch (err) {
|
|
24
|
-
console.error("❌ Error during logout:", err.message);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** ========== LOGIN ========== **/
|
|
16
|
+
/** ------------------ LOGIN ------------------ **/
|
|
29
17
|
async function login() {
|
|
30
18
|
const answers = await inquirer.prompt([
|
|
31
19
|
{ type: "input", name: "email", message: "Email:" },
|
|
@@ -47,6 +35,21 @@ async function login() {
|
|
|
47
35
|
}
|
|
48
36
|
}
|
|
49
37
|
|
|
38
|
+
/** ------------------ LOGOUT ------------------ **/
|
|
39
|
+
async function logout() {
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(RC_FILE)) {
|
|
42
|
+
await fs.promises.rm(RC_FILE, { force: true });
|
|
43
|
+
console.log("✅ Logged out successfully. Local credentials removed.");
|
|
44
|
+
} else {
|
|
45
|
+
console.log("ℹ️ No active session found.");
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error("❌ Error during logout:", err.message);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ------------------ TOKEN UTILS ------------------ **/
|
|
50
53
|
function readToken() {
|
|
51
54
|
if (!fs.existsSync(RC_FILE)) {
|
|
52
55
|
console.error("❌ Not logged in. Run 'tnc login' first.");
|
|
@@ -56,7 +59,7 @@ function readToken() {
|
|
|
56
59
|
return { token: data.token, email: data.email };
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
/**
|
|
62
|
+
/** ------------------ IGNORE HANDLING ------------------ **/
|
|
60
63
|
function loadIgnore(folderPath) {
|
|
61
64
|
const ignoreFile = path.join(folderPath, ".ignoretnc");
|
|
62
65
|
if (!fs.existsSync(ignoreFile)) return [];
|
|
@@ -80,7 +83,7 @@ function shouldIgnore(relativePath, ignoreList) {
|
|
|
80
83
|
});
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
/**
|
|
86
|
+
/** ------------------ SCAN FOLDER ------------------ **/
|
|
84
87
|
function scanFolder(folderPath, ignoreList, rootPath = folderPath) {
|
|
85
88
|
const items = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
86
89
|
const result = [];
|
|
@@ -97,14 +100,15 @@ function scanFolder(folderPath, ignoreList, rootPath = folderPath) {
|
|
|
97
100
|
result.push({
|
|
98
101
|
name: item.name,
|
|
99
102
|
type: "folder",
|
|
100
|
-
children: scanFolder(fullPath, ignoreList, rootPath)
|
|
103
|
+
children: scanFolder(fullPath, ignoreList, rootPath),
|
|
104
|
+
path: relativePath
|
|
101
105
|
});
|
|
102
106
|
} else {
|
|
103
107
|
const stats = fs.statSync(fullPath);
|
|
104
108
|
result.push({
|
|
105
109
|
name: item.name,
|
|
106
110
|
type: "file",
|
|
107
|
-
path:
|
|
111
|
+
path: relativePath,
|
|
108
112
|
size: stats.size
|
|
109
113
|
});
|
|
110
114
|
}
|
|
@@ -112,7 +116,48 @@ function scanFolder(folderPath, ignoreList, rootPath = folderPath) {
|
|
|
112
116
|
return result;
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
/**
|
|
119
|
+
/** ------------------ VERSIONING ------------------ **/
|
|
120
|
+
function loadVersions() {
|
|
121
|
+
if (!fs.existsSync(VERSION_FILE)) return {};
|
|
122
|
+
return JSON.parse(fs.readFileSync(VERSION_FILE, "utf-8"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function saveVersions(versionMap) {
|
|
126
|
+
fs.writeFileSync(VERSION_FILE, JSON.stringify(versionMap, null, 2));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function computeHashCLI(filePath) {
|
|
130
|
+
if (!fs.existsSync(filePath)) return null;
|
|
131
|
+
const stats = fs.statSync(filePath);
|
|
132
|
+
if (stats.isDirectory()) {
|
|
133
|
+
const items = fs.readdirSync(filePath);
|
|
134
|
+
const combined = items.map(name => computeHashCLI(path.join(filePath, name))).join("");
|
|
135
|
+
return crypto.createHash("sha256").update(filePath + combined).digest("hex");
|
|
136
|
+
} else {
|
|
137
|
+
const content = fs.readFileSync(filePath);
|
|
138
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkChanges(fileTree, versionMap, rootPath = CWD) {
|
|
143
|
+
return fileTree.map(item => {
|
|
144
|
+
const fullPath = path.join(rootPath, item.path || item.name);
|
|
145
|
+
const hash = computeHashCLI(fullPath);
|
|
146
|
+
const prevHash = versionMap[item.path || fullPath] || null;
|
|
147
|
+
const changed = hash !== prevHash;
|
|
148
|
+
|
|
149
|
+
let newItem = { ...item, changed, hash };
|
|
150
|
+
|
|
151
|
+
if (item.type === "folder" && item.children.length > 0) {
|
|
152
|
+
newItem.children = checkChanges(item.children, versionMap, rootPath);
|
|
153
|
+
newItem.changed = newItem.changed || newItem.children.some(c => c.changed);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return newItem;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** ------------------ CLOUDINARY UPLOAD ------------------ **/
|
|
116
161
|
async function uploadFileSigned(filePath, folder, roomId, token, email) {
|
|
117
162
|
const filename = path.basename(filePath);
|
|
118
163
|
|
|
@@ -152,26 +197,28 @@ async function uploadTree(fileTree, folderHex, roomId, token, email, parentPath
|
|
|
152
197
|
uploaded.push({
|
|
153
198
|
name: node.name,
|
|
154
199
|
type: "folder",
|
|
200
|
+
path: relativePath,
|
|
155
201
|
children
|
|
156
202
|
});
|
|
157
|
-
} else {
|
|
203
|
+
} else if (node.changed) {
|
|
158
204
|
const url = await uploadFileSigned(node.path, `tnc_uploads/${folderHex}`, roomId, token, email);
|
|
159
205
|
console.log(`📦 Uploaded: ${relativePath} → ${url}`);
|
|
160
|
-
|
|
161
206
|
uploaded.push({
|
|
162
207
|
name: node.name,
|
|
163
208
|
type: "file",
|
|
164
209
|
path: relativePath,
|
|
165
210
|
size: node.size,
|
|
166
|
-
url
|
|
211
|
+
url
|
|
167
212
|
});
|
|
213
|
+
} else {
|
|
214
|
+
uploaded.push(node); // unchanged, no upload
|
|
168
215
|
}
|
|
169
216
|
}
|
|
170
217
|
|
|
171
218
|
return uploaded;
|
|
172
219
|
}
|
|
173
220
|
|
|
174
|
-
/**
|
|
221
|
+
/** ------------------ PUSH FUNCTION ------------------ **/
|
|
175
222
|
async function push(roomId, targetPath) {
|
|
176
223
|
const { token, email } = readToken();
|
|
177
224
|
const stats = fs.statSync(targetPath);
|
|
@@ -185,7 +232,7 @@ async function push(roomId, targetPath) {
|
|
|
185
232
|
const relativePath = path.basename(targetPath);
|
|
186
233
|
content = shouldIgnore(relativePath, ignoreList)
|
|
187
234
|
? []
|
|
188
|
-
: [{ name: relativePath, type: "file", path:
|
|
235
|
+
: [{ name: relativePath, type: "file", path: relativePath, size: stats.size }];
|
|
189
236
|
}
|
|
190
237
|
|
|
191
238
|
if (!content.length) {
|
|
@@ -193,11 +240,20 @@ async function push(roomId, targetPath) {
|
|
|
193
240
|
return;
|
|
194
241
|
}
|
|
195
242
|
|
|
243
|
+
const previousVersions = loadVersions();
|
|
244
|
+
const contentWithChanges = checkChanges(content, previousVersions);
|
|
245
|
+
const hasChanges = contentWithChanges.some(item => item.changed || (item.children && item.children.some(c => c.changed)));
|
|
246
|
+
|
|
247
|
+
if (!hasChanges) {
|
|
248
|
+
console.log("ℹ️ No changes detected since last push.");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
196
252
|
try {
|
|
197
253
|
const folderHex = crypto.createHash("md5").update(path.basename(targetPath) + Date.now()).digest("hex");
|
|
198
254
|
|
|
199
255
|
console.log("🚀 Uploading to Cloudinary...");
|
|
200
|
-
const uploadedTree = await uploadTree(
|
|
256
|
+
const uploadedTree = await uploadTree(contentWithChanges, folderHex, roomId, token, email);
|
|
201
257
|
|
|
202
258
|
console.log("🗂️ Sending metadata to backend...");
|
|
203
259
|
await axios.post(
|
|
@@ -207,12 +263,24 @@ async function push(roomId, targetPath) {
|
|
|
207
263
|
);
|
|
208
264
|
|
|
209
265
|
console.log("✅ Upload complete! Metadata stored successfully.");
|
|
266
|
+
|
|
267
|
+
// Save version hashes
|
|
268
|
+
const flattenVersionMap = {};
|
|
269
|
+
const flatten = items => {
|
|
270
|
+
for (const item of items) {
|
|
271
|
+
flattenVersionMap[item.path || item.name] = item.hash;
|
|
272
|
+
if (item.children) flatten(item.children);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
flatten(contentWithChanges);
|
|
276
|
+
saveVersions(flattenVersionMap);
|
|
277
|
+
|
|
210
278
|
} catch (err) {
|
|
211
279
|
console.error("❌ Upload failed:", err.response?.data || err.message);
|
|
212
280
|
}
|
|
213
281
|
}
|
|
214
282
|
|
|
215
|
-
/**
|
|
283
|
+
/** ------------------ CLI HANDLER ------------------ **/
|
|
216
284
|
const args = process.argv.slice(2);
|
|
217
285
|
|
|
218
286
|
switch (args[0]) {
|
|
@@ -220,6 +288,10 @@ switch (args[0]) {
|
|
|
220
288
|
login();
|
|
221
289
|
break;
|
|
222
290
|
|
|
291
|
+
case "logout":
|
|
292
|
+
logout();
|
|
293
|
+
break;
|
|
294
|
+
|
|
223
295
|
case "push": {
|
|
224
296
|
const roomIndex = args.indexOf("--room");
|
|
225
297
|
if (roomIndex === -1 || !args[roomIndex + 1] || !args[roomIndex + 2]) {
|
|
@@ -231,8 +303,8 @@ switch (args[0]) {
|
|
|
231
303
|
push(roomId, targetPath);
|
|
232
304
|
break;
|
|
233
305
|
}
|
|
234
|
-
case "
|
|
235
|
-
|
|
306
|
+
case "init" :{
|
|
307
|
+
init();
|
|
236
308
|
break;
|
|
237
309
|
}
|
|
238
310
|
|
|
@@ -241,4 +313,5 @@ switch (args[0]) {
|
|
|
241
313
|
console.log("Commands:");
|
|
242
314
|
console.log(" tnc login");
|
|
243
315
|
console.log(" tnc push --room <roomId> <path>");
|
|
244
|
-
|
|
316
|
+
console.log(" tnc logout");
|
|
317
|
+
}
|
package/commands/init.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import inquirer from "inquirer";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import { builtinModules } from "module";
|
|
7
|
+
const CWD = process.cwd();
|
|
8
|
+
|
|
9
|
+
async function projectInit() {
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const answer = await inquirer.prompt([
|
|
13
|
+
{type: "input", name: "projectName", message: "Enter Project Name:"},
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const HomeDir = os.homedir();
|
|
19
|
+
const data = fs.readFileSync(path.join(HomeDir, ".tncrc"));
|
|
20
|
+
const currentUser = JSON.parse(data).email;
|
|
21
|
+
// let us make the a .tncmeta.json file for keeping track of branch info
|
|
22
|
+
//.tncmeta.json should be in the a separate folder at the root of the project
|
|
23
|
+
|
|
24
|
+
const response = await axios.post("http://localhost:3001/cli/init", {
|
|
25
|
+
projectName : answer.projectName,
|
|
26
|
+
owner: currentUser
|
|
27
|
+
})
|
|
28
|
+
const projectId = response.data.project._id;
|
|
29
|
+
|
|
30
|
+
const tncFolder = fs.mkdirSync(path.join(CWD, ".tnc"), { recursive: true });
|
|
31
|
+
|
|
32
|
+
const metaFilePath = path.join(tncFolder, ".tncmeta.json");
|
|
33
|
+
|
|
34
|
+
fs.writeFileSync(metaFilePath, JSON.stringify({"projectId": projectId,
|
|
35
|
+
"projectName": answer.projectName,
|
|
36
|
+
"currentBranch": "main",
|
|
37
|
+
"lastCommit": null,
|
|
38
|
+
"files": {},
|
|
39
|
+
},
|
|
40
|
+
null,
|
|
41
|
+
2
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
console.log("✅ Project initialized successfully!");
|
|
45
|
+
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = projectInit();
|
package/commands/pull.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import { createUploader } from "./cloudUploader.js";
|
|
8
|
+
|
|
9
|
+
const BASE_URL = "http://localhost:3001";
|
|
10
|
+
const CWD = process.cwd();
|
|
11
|
+
|
|
12
|
+
function readToken() {
|
|
13
|
+
const homeDir = os.homedir();
|
|
14
|
+
return JSON.parse(fs.readFileSync(path.join(homeDir, ".tncrc")));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readMeta() {
|
|
18
|
+
const metaPath = path.join(CWD, ".tnc", ".tncmeta.json");
|
|
19
|
+
return fs.existsSync(metaPath) ? JSON.parse(fs.readFileSync(metaPath)) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeMeta(meta) {
|
|
23
|
+
const metaPath = path.join(CWD, ".tnc", ".tncmeta.json");
|
|
24
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ========== IGNORE HANDLING ==========
|
|
28
|
+
function loadIgnore(folderPath) {
|
|
29
|
+
const ignoreFile = path.join(folderPath, ".ignoretnc");
|
|
30
|
+
if (!fs.existsSync(ignoreFile)) return [];
|
|
31
|
+
return fs.readFileSync(ignoreFile, "utf-8")
|
|
32
|
+
.split("\n")
|
|
33
|
+
.map(l => l.trim())
|
|
34
|
+
.filter(l => l && !l.startsWith("#"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shouldIgnore(relativePath, ignoreList) {
|
|
38
|
+
relativePath = relativePath.replace(/\\/g, "/");
|
|
39
|
+
return ignoreList.some(pattern => {
|
|
40
|
+
pattern = pattern.replace(/\\/g, "/");
|
|
41
|
+
if (pattern.endsWith("/**")) {
|
|
42
|
+
const folder = pattern.slice(0, -3);
|
|
43
|
+
return relativePath === folder || relativePath.startsWith(folder + "/");
|
|
44
|
+
}
|
|
45
|
+
if (pattern.startsWith("*.")) return relativePath.endsWith(pattern.slice(1));
|
|
46
|
+
return relativePath === pattern;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ========== SCAN FOLDER ==========
|
|
51
|
+
function scanFolder(folderPath, ignoreList, rootPath = folderPath) {
|
|
52
|
+
const items = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
53
|
+
const result = [];
|
|
54
|
+
for (const item of items) {
|
|
55
|
+
const fullPath = path.join(folderPath, item.name);
|
|
56
|
+
const relativePath = path.relative(rootPath, fullPath).replace(/\\/g, "/");
|
|
57
|
+
if (shouldIgnore(relativePath, ignoreList)) continue;
|
|
58
|
+
|
|
59
|
+
if (item.isDirectory()) {
|
|
60
|
+
result.push({ name: item.name, type: "folder", children: scanFolder(fullPath, ignoreList, rootPath) });
|
|
61
|
+
} else {
|
|
62
|
+
const stats = fs.statSync(fullPath);
|
|
63
|
+
result.push({ name: item.name, type: "file", path: fullPath, size: stats.size });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ========== PUSH EVERYTHING ==========
|
|
70
|
+
export async function dpushAll() {
|
|
71
|
+
const { token, email } = readToken();
|
|
72
|
+
const meta = readMeta();
|
|
73
|
+
if (!meta) return console.log("❌ Project not initialized locally. Run init first.");
|
|
74
|
+
const { projectId, currentBranch } = meta;
|
|
75
|
+
|
|
76
|
+
const stats = fs.statSync(CWD);
|
|
77
|
+
const ignoreList = loadIgnore(CWD);
|
|
78
|
+
|
|
79
|
+
const content = scanFolder(CWD, ignoreList);
|
|
80
|
+
if (!content.length) return console.log("⚠️ Nothing to upload (all ignored).");
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const uploader = createUploader(BASE_URL, token, email);
|
|
84
|
+
const folderHex = crypto.createHash("md5").update(path.basename(CWD) + Date.now()).digest("hex");
|
|
85
|
+
|
|
86
|
+
console.log("🚀 Uploading entire project...");
|
|
87
|
+
const uploadedTree = await uploader.uploadTree(content, folderHex, projectId);
|
|
88
|
+
|
|
89
|
+
// Prepare metadata
|
|
90
|
+
const filesMetadata = [];
|
|
91
|
+
function flattenTree(nodes, parent = "") {
|
|
92
|
+
for (const node of nodes) {
|
|
93
|
+
const relPath = path.join(parent, node.name).replace(/\\/g, "/");
|
|
94
|
+
if (node.type === "file") {
|
|
95
|
+
const hash = crypto.createHash("md5").update(fs.readFileSync(path.join(CWD, relPath))).digest("hex");
|
|
96
|
+
filesMetadata.push({ filename: relPath, contentHash: hash, path: node.url, version: 0 });
|
|
97
|
+
} else if (node.type === "folder") {
|
|
98
|
+
flattenTree(node.children, relPath);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
flattenTree(uploadedTree);
|
|
103
|
+
|
|
104
|
+
console.log("🗂️ Sending metadata to backend...");
|
|
105
|
+
const pushRes = await axios.post(
|
|
106
|
+
`${BASE_URL}/cli/push`,
|
|
107
|
+
{ projectId, branchName: currentBranch, files: filesMetadata, author: email, message: "Full project push" },
|
|
108
|
+
{ headers: { authorization: `Bearer ${token}`, email } }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
console.log("✅ Push successful:", pushRes.data);
|
|
112
|
+
|
|
113
|
+
// Update local meta
|
|
114
|
+
meta.lastCommit = pushRes.data.commitId;
|
|
115
|
+
for (const f of pushRes.data.files) meta.files[f.filename] = f.version;
|
|
116
|
+
writeMeta(meta);
|
|
117
|
+
console.log("📝 Local metadata updated.");
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error("❌ Push failed:", err.response?.data || err.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Call push all
|
|
124
|
+
dpushAll();
|