ngx-git 1.0.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/cli.js +643 -0
- package/data/test.txt +1 -0
- package/package.json +24 -0
- package/readmi.txt +189 -0
package/cli.js
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NGX CLI v4.0
|
|
5
|
+
* Pushes code directly to your GitHub repos via NGX server (OAuth proxy).
|
|
6
|
+
* No git installation needed. No commit setup. No branch config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const http = require("http");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
|
|
14
|
+
const axios = require("axios");
|
|
15
|
+
|
|
16
|
+
const _log = console.log;
|
|
17
|
+
console.log = () => {};
|
|
18
|
+
require("dotenv").config();
|
|
19
|
+
console.log = _log;
|
|
20
|
+
|
|
21
|
+
const VERSION = "4.0.0";
|
|
22
|
+
const CONFIG_PATH = path.join(os.homedir(), ".ngxconfig");
|
|
23
|
+
const CB_PORT = 9988; // local callback server port for OAuth
|
|
24
|
+
|
|
25
|
+
const encoded = atob("aHR0cHM6Ly9uZ3gubmdpbnhzb2Z0Lm9ubGluZS9hcGk=");
|
|
26
|
+
// console.log(encoded);
|
|
27
|
+
// ─── Default ignore patterns ──────────────────────────────────────────────────
|
|
28
|
+
const DEFAULT_IGNORE = new Set([
|
|
29
|
+
".env",
|
|
30
|
+
"node_modules",
|
|
31
|
+
".git",
|
|
32
|
+
".DS_Store",
|
|
33
|
+
"Thumbs.db",
|
|
34
|
+
"dist",
|
|
35
|
+
"build",
|
|
36
|
+
".cache",
|
|
37
|
+
"coverage",
|
|
38
|
+
".nyc_output",
|
|
39
|
+
"tmp",
|
|
40
|
+
"temp",
|
|
41
|
+
"__pycache__",
|
|
42
|
+
".pytest_cache",
|
|
43
|
+
".mypy_cache",
|
|
44
|
+
"*.log",
|
|
45
|
+
"*.sqlite",
|
|
46
|
+
"*.db",
|
|
47
|
+
"*.pem",
|
|
48
|
+
"*.key",
|
|
49
|
+
"*.p12",
|
|
50
|
+
"*.pfx",
|
|
51
|
+
"*.cer",
|
|
52
|
+
"*.crt",
|
|
53
|
+
"secrets",
|
|
54
|
+
".secrets",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
58
|
+
function cfg() {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function saveCfg(d) {
|
|
66
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(d, null, 2));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getServer() {
|
|
70
|
+
return cfg().server || process.env.NGX_SERVER || encoded;
|
|
71
|
+
}
|
|
72
|
+
function getToken() {
|
|
73
|
+
return cfg().token || process.env.NGX_TOKEN || null;
|
|
74
|
+
}
|
|
75
|
+
function getUsername() {
|
|
76
|
+
return cfg().username || null;
|
|
77
|
+
}
|
|
78
|
+
function authHdr() {
|
|
79
|
+
const t = getToken();
|
|
80
|
+
if (!t) fatal("Not logged in. Run: ngx login");
|
|
81
|
+
return { Authorization: `Bearer ${t}`, "Content-Type": "application/json" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Output ───────────────────────────────────────────────────────────────────
|
|
85
|
+
const C = {
|
|
86
|
+
cyan: (s) => `\x1b[96m${s}\x1b[0m`,
|
|
87
|
+
green: (s) => `\x1b[92m${s}\x1b[0m`,
|
|
88
|
+
grey: (s) => `\x1b[90m${s}\x1b[0m`,
|
|
89
|
+
red: (s) => `\x1b[91m${s}\x1b[0m`,
|
|
90
|
+
yel: (s) => `\x1b[93m${s}\x1b[0m`,
|
|
91
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
92
|
+
};
|
|
93
|
+
function fatal(msg) {
|
|
94
|
+
console.error(C.red("✗ ") + msg);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
function ok(msg) {
|
|
98
|
+
console.log(C.green("✓ ") + msg);
|
|
99
|
+
}
|
|
100
|
+
function info(msg) {
|
|
101
|
+
console.log(C.grey(" ") + msg);
|
|
102
|
+
}
|
|
103
|
+
function step(msg) {
|
|
104
|
+
process.stdout.write(`\r${C.cyan("›")} ${msg} `);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Ignore logic ─────────────────────────────────────────────────────────────
|
|
108
|
+
function loadIgnorePatterns(folderPath) {
|
|
109
|
+
const pats = new Set(DEFAULT_IGNORE);
|
|
110
|
+
// Read .ngxignore
|
|
111
|
+
const ngxFile = path.join(folderPath, ".ngxignore");
|
|
112
|
+
if (fs.existsSync(ngxFile))
|
|
113
|
+
fs.readFileSync(ngxFile, "utf-8")
|
|
114
|
+
.split("\n")
|
|
115
|
+
.map((l) => l.trim())
|
|
116
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
117
|
+
.forEach((p) => pats.add(p));
|
|
118
|
+
// Read .gitignore
|
|
119
|
+
const gitFile = path.join(folderPath, ".gitignore");
|
|
120
|
+
if (fs.existsSync(gitFile))
|
|
121
|
+
fs.readFileSync(gitFile, "utf-8")
|
|
122
|
+
.split("\n")
|
|
123
|
+
.map((l) => l.trim())
|
|
124
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
125
|
+
.forEach((p) => pats.add(p));
|
|
126
|
+
return pats;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function matchesIgnore(name, patterns) {
|
|
130
|
+
for (const p of patterns) {
|
|
131
|
+
if (p === name) return true;
|
|
132
|
+
if (p.startsWith("*.") && name.endsWith(p.slice(1))) return true;
|
|
133
|
+
if (p.endsWith("*") && name.startsWith(p.slice(0, -1))) return true;
|
|
134
|
+
if (p.startsWith("*") && name.endsWith(p.slice(1))) return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Collect files from folder ────────────────────────────────────────────────
|
|
140
|
+
// Returns array of { path: "relative/path", content_b64: "..." }
|
|
141
|
+
function collectFiles(folderPath) {
|
|
142
|
+
const ignore = loadIgnorePatterns(folderPath);
|
|
143
|
+
const files = [];
|
|
144
|
+
|
|
145
|
+
function walk(dir, rel) {
|
|
146
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (matchesIgnore(entry.name, ignore)) continue;
|
|
149
|
+
const fullPath = path.join(dir, entry.name);
|
|
150
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
walk(fullPath, relPath);
|
|
153
|
+
} else if (entry.isFile()) {
|
|
154
|
+
const buf = fs.readFileSync(fullPath);
|
|
155
|
+
// Skip binary files > 50 MB (GitHub limit)
|
|
156
|
+
if (buf.length > 50 * 1024 * 1024) {
|
|
157
|
+
console.log(
|
|
158
|
+
C.yel(
|
|
159
|
+
` ⚠ Skipping large file: ${relPath} (${(buf.length / 1024 / 1024).toFixed(1)} MB)`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
files.push({ path: relPath, content_b64: buf.toString("base64") });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
walk(folderPath, "");
|
|
169
|
+
return files;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── LOGIN — GitHub OAuth ─────────────────────────────────────────────────────
|
|
173
|
+
async function doLogin() {
|
|
174
|
+
const existing = getToken();
|
|
175
|
+
if (existing) {
|
|
176
|
+
ok(`Already logged in as ${C.bold(getUsername())}`);
|
|
177
|
+
info("Run 'ngx logout' to switch accounts.");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`\n${C.bold("NGX GitHub Login")}`);
|
|
182
|
+
|
|
183
|
+
const loginUrl = `${getServer()}/auth/github`;
|
|
184
|
+
console.log(C.grey(`Opening: ${loginUrl}\n`));
|
|
185
|
+
|
|
186
|
+
// Try to open browser
|
|
187
|
+
try {
|
|
188
|
+
const { default: open } = await import("open").catch(() => ({
|
|
189
|
+
default: null,
|
|
190
|
+
}));
|
|
191
|
+
if (open) await open(loginUrl);
|
|
192
|
+
else console.log(C.yel(`Open this in your browser:\n ${loginUrl}\n`));
|
|
193
|
+
} catch {
|
|
194
|
+
console.log(C.yel(`Open this in your browser:\n ${loginUrl}\n`));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Spin local HTTP server to receive token
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const srv = http.createServer((req, res) => {
|
|
200
|
+
const CORS = {
|
|
201
|
+
"Access-Control-Allow-Origin": "*",
|
|
202
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
203
|
+
};
|
|
204
|
+
if (req.method === "OPTIONS") {
|
|
205
|
+
res.writeHead(204, CORS);
|
|
206
|
+
res.end();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (req.method === "POST" && req.url === "/token") {
|
|
210
|
+
let body = "";
|
|
211
|
+
req.on("data", (d) => (body += d));
|
|
212
|
+
req.on("end", () => {
|
|
213
|
+
res.writeHead(200, { "Content-Type": "application/json", ...CORS });
|
|
214
|
+
res.end(JSON.stringify({ ok: true }));
|
|
215
|
+
try {
|
|
216
|
+
const { token, username } = JSON.parse(body);
|
|
217
|
+
const c = cfg();
|
|
218
|
+
c.token = token;
|
|
219
|
+
c.username = username;
|
|
220
|
+
saveCfg(c);
|
|
221
|
+
srv.close();
|
|
222
|
+
console.log();
|
|
223
|
+
ok(`Logged in as ${C.bold(username)}`);
|
|
224
|
+
resolve();
|
|
225
|
+
} catch (e) {
|
|
226
|
+
reject(e);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
res.writeHead(404, CORS);
|
|
231
|
+
res.end();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
srv.on("error", (e) => {
|
|
236
|
+
if (e.code === "EADDRINUSE")
|
|
237
|
+
fatal(
|
|
238
|
+
`Port ${CB_PORT} is busy. Kill whatever is using it and try again.`,
|
|
239
|
+
);
|
|
240
|
+
reject(e);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
srv.listen(CB_PORT, () =>
|
|
244
|
+
info(`Waiting for GitHub callback (port ${CB_PORT})...`),
|
|
245
|
+
);
|
|
246
|
+
setTimeout(() => {
|
|
247
|
+
srv.close();
|
|
248
|
+
fatal("Login timed out (5 min).");
|
|
249
|
+
}, 300_000);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── PUSH — upload files to GitHub via server ────────────────────────────────
|
|
254
|
+
async function doPush(repoArg, opts = {}) {
|
|
255
|
+
const username = getUsername();
|
|
256
|
+
if (!username) fatal("Not logged in. Run: ngx login");
|
|
257
|
+
const server = getServer();
|
|
258
|
+
const folder = path.resolve(opts.path || ".");
|
|
259
|
+
if (!fs.existsSync(folder)) fatal(`Folder not found: ${folder}`);
|
|
260
|
+
|
|
261
|
+
const repoName = repoArg || path.basename(folder);
|
|
262
|
+
const isPrivate = opts.visibility !== "public";
|
|
263
|
+
const commitMsg = opts.message || `ngx push — ${new Date().toLocaleString()}`;
|
|
264
|
+
const branch = opts.branch || "main";
|
|
265
|
+
|
|
266
|
+
step(`Scanning ${C.bold(folder)}...`);
|
|
267
|
+
const files = collectFiles(folder);
|
|
268
|
+
if (!files.length) fatal("No files found to push (everything was ignored).");
|
|
269
|
+
process.stdout.write(`\n`);
|
|
270
|
+
|
|
271
|
+
// Show summary
|
|
272
|
+
const totalKB =
|
|
273
|
+
files.reduce((s, f) => s + Buffer.from(f.content_b64, "base64").length, 0) /
|
|
274
|
+
1024;
|
|
275
|
+
info(
|
|
276
|
+
`${files.length} files • ${totalKB.toFixed(1)} KB → ${C.bold(username + "/" + repoName)}`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
step("Pushing to GitHub...");
|
|
280
|
+
try {
|
|
281
|
+
const res = await axios.post(
|
|
282
|
+
`${server}/push`,
|
|
283
|
+
{
|
|
284
|
+
repoName,
|
|
285
|
+
files,
|
|
286
|
+
message: commitMsg,
|
|
287
|
+
branch,
|
|
288
|
+
createIfMissing: true,
|
|
289
|
+
private: isPrivate,
|
|
290
|
+
},
|
|
291
|
+
{ headers: authHdr(), maxBodyLength: Infinity, timeout: 120_000 },
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
process.stdout.write("\n");
|
|
295
|
+
ok(`Pushed ${C.bold(res.data.files)} files to ${C.bold(res.data.repo)}`);
|
|
296
|
+
info(`Branch : ${res.data.branch}`);
|
|
297
|
+
info(`Commit : ${res.data.commit}`);
|
|
298
|
+
info(`GitHub : ${C.cyan(res.data.url)}`);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
process.stdout.write("\n");
|
|
301
|
+
fatal(e.response?.data?.error || e.message);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── CLONE — clone repo from GitHub ──────────────────────────────────────────
|
|
306
|
+
// Uses GitHub API to fetch file tree + blob contents, writes to disk.
|
|
307
|
+
// No `git` binary needed.
|
|
308
|
+
async function doClone(target, destArg, opts = {}) {
|
|
309
|
+
if (!target || !target.includes("/"))
|
|
310
|
+
fatal("Usage: ngx clone owner/repo [dest]");
|
|
311
|
+
const [owner, repoName] = target.split("/");
|
|
312
|
+
const dest = path.resolve(destArg || repoName);
|
|
313
|
+
const server = getServer();
|
|
314
|
+
|
|
315
|
+
step(`Fetching file tree for ${C.bold(target)}...`);
|
|
316
|
+
let treeData;
|
|
317
|
+
try {
|
|
318
|
+
const res = await axios.get(`${server}/tree/${owner}/${repoName}`, {
|
|
319
|
+
headers: authHdr(),
|
|
320
|
+
});
|
|
321
|
+
treeData = res.data;
|
|
322
|
+
} catch (e) {
|
|
323
|
+
process.stdout.write("\n");
|
|
324
|
+
if (e.response?.status === 404)
|
|
325
|
+
fatal("Repo not found or you don't have access");
|
|
326
|
+
fatal(e.response?.data?.error || e.message);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const files = treeData.files || [];
|
|
330
|
+
const branch = treeData.branch;
|
|
331
|
+
process.stdout.write("\n");
|
|
332
|
+
info(`${files.length} files on branch ${branch}`);
|
|
333
|
+
|
|
334
|
+
// GitHub raw content URL for each file
|
|
335
|
+
const ghToken = null; // optional: could expose via /me route
|
|
336
|
+
const ghHeaders = { Accept: "application/vnd.github.raw+json", ...authHdr() };
|
|
337
|
+
// We'll download via the server proxy or directly from GitHub raw
|
|
338
|
+
const repoInfo = await axios.get(`${server}/repos/${owner}/${repoName}`, {
|
|
339
|
+
headers: authHdr(),
|
|
340
|
+
});
|
|
341
|
+
const cloneBase = `https://raw.githubusercontent.com/${owner}/${repoName}/${branch}`;
|
|
342
|
+
|
|
343
|
+
// For private repos we need the GitHub token — fetch it via server
|
|
344
|
+
let rawHeaders = {};
|
|
345
|
+
try {
|
|
346
|
+
// The server can expose a /me/ghtoken route for the logged-in user's own repos
|
|
347
|
+
const tk = await axios.get(`${server}/me/ghtoken`, { headers: authHdr() });
|
|
348
|
+
if (tk.data?.ghToken)
|
|
349
|
+
rawHeaders["Authorization"] = `Bearer ${tk.data.ghToken}`;
|
|
350
|
+
} catch {}
|
|
351
|
+
|
|
352
|
+
if (fs.existsSync(dest)) {
|
|
353
|
+
info(`Destination ./${path.basename(dest)}/ already exists — overwriting`);
|
|
354
|
+
} else {
|
|
355
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let done = 0;
|
|
359
|
+
for (const file of files) {
|
|
360
|
+
step(`Downloading ${done + 1}/${files.length} ${C.grey(file.path)}`);
|
|
361
|
+
try {
|
|
362
|
+
const raw = await axios.get(`${cloneBase}/${file.path}`, {
|
|
363
|
+
headers: rawHeaders,
|
|
364
|
+
responseType: "arraybuffer",
|
|
365
|
+
});
|
|
366
|
+
const outPath = path.join(dest, file.path);
|
|
367
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
368
|
+
fs.writeFileSync(outPath, raw.data);
|
|
369
|
+
done++;
|
|
370
|
+
} catch (e) {
|
|
371
|
+
process.stdout.write("\n");
|
|
372
|
+
info(
|
|
373
|
+
C.yel(
|
|
374
|
+
`Could not download: ${file.path} — ${e.response?.status || e.message}`,
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
process.stdout.write("\n");
|
|
380
|
+
ok(
|
|
381
|
+
`Cloned ${C.bold(target)} (${done}/${files.length} files) → ./${path.basename(dest)}/`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ─── REPOS ────────────────────────────────────────────────────────────────────
|
|
386
|
+
async function listRepos() {
|
|
387
|
+
if (!getToken()) fatal("Not logged in");
|
|
388
|
+
step("Fetching repos from GitHub...");
|
|
389
|
+
try {
|
|
390
|
+
const res = await axios.get(`${getServer()}/repos`, { headers: authHdr() });
|
|
391
|
+
process.stdout.write("\n");
|
|
392
|
+
const repos = res.data.repos || [];
|
|
393
|
+
if (!repos.length) {
|
|
394
|
+
info("No repos found.");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
console.log(`\n${C.bold(getUsername() + "'s GitHub repos:")}\n`);
|
|
398
|
+
repos.forEach((r) => {
|
|
399
|
+
const icon = r.private ? "🔒" : "🌐";
|
|
400
|
+
const lang = r.language ? C.grey(r.language.padEnd(14)) : " ".repeat(14);
|
|
401
|
+
const upd = r.pushed_at ? timeSince(r.pushed_at) : "—";
|
|
402
|
+
console.log(
|
|
403
|
+
` ${icon} ${C.cyan(r.name.padEnd(32))} ${lang} ${C.grey(upd)}`,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
console.log();
|
|
407
|
+
} catch (e) {
|
|
408
|
+
process.stdout.write("\n");
|
|
409
|
+
fatal(e.response?.data?.error || e.message);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── CREATE REPO ──────────────────────────────────────────────────────────────
|
|
414
|
+
async function createRepo(name, opts = {}) {
|
|
415
|
+
if (!name) fatal('Usage: ngx create <reponame> [--public] [--desc "..."]');
|
|
416
|
+
step(`Creating ${C.bold(name)} on GitHub...`);
|
|
417
|
+
try {
|
|
418
|
+
const res = await axios.post(
|
|
419
|
+
`${getServer()}/repos`,
|
|
420
|
+
{
|
|
421
|
+
name,
|
|
422
|
+
private: opts.visibility !== "public",
|
|
423
|
+
description: opts.desc || "",
|
|
424
|
+
},
|
|
425
|
+
{ headers: authHdr() },
|
|
426
|
+
);
|
|
427
|
+
process.stdout.write("\n");
|
|
428
|
+
ok(`Created ${C.bold(res.data.full_name)}`);
|
|
429
|
+
info(`GitHub : ${C.cyan(`https://github.com/${res.data.full_name}`)}`);
|
|
430
|
+
info(`Private: ${res.data.private}`);
|
|
431
|
+
} catch (e) {
|
|
432
|
+
process.stdout.write("\n");
|
|
433
|
+
fatal(e.response?.data?.error || e.message);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── DELETE ───────────────────────────────────────────────────────────────────
|
|
438
|
+
async function deleteRepo(name) {
|
|
439
|
+
if (!name) fatal("Usage: ngx delete <reponame>");
|
|
440
|
+
step(`Deleting ${C.bold(name)}...`);
|
|
441
|
+
try {
|
|
442
|
+
const res = await axios.delete(`${getServer()}/repos/${name}`, {
|
|
443
|
+
headers: authHdr(),
|
|
444
|
+
});
|
|
445
|
+
process.stdout.write("\n");
|
|
446
|
+
ok(res.data.message);
|
|
447
|
+
} catch (e) {
|
|
448
|
+
process.stdout.write("\n");
|
|
449
|
+
fatal(e.response?.data?.error || e.message);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ─── VISIBILITY ───────────────────────────────────────────────────────────────
|
|
454
|
+
async function setVisibility(name, vis) {
|
|
455
|
+
if (!name || !["public", "private"].includes(vis))
|
|
456
|
+
fatal("Usage: ngx visibility <repo> public|private");
|
|
457
|
+
try {
|
|
458
|
+
const res = await axios.patch(
|
|
459
|
+
`${getServer()}/repos/${name}/visibility`,
|
|
460
|
+
{ visibility: vis },
|
|
461
|
+
{ headers: authHdr() },
|
|
462
|
+
);
|
|
463
|
+
ok(res.data.message);
|
|
464
|
+
} catch (e) {
|
|
465
|
+
fatal(e.response?.data?.error || e.message);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ─── LOGS ─────────────────────────────────────────────────────────────────────
|
|
470
|
+
async function showLogs() {
|
|
471
|
+
if (!getToken()) fatal("Not logged in");
|
|
472
|
+
try {
|
|
473
|
+
const res = await axios.get(`${getServer()}/logs`, { headers: authHdr() });
|
|
474
|
+
const logs = res.data.logs || [];
|
|
475
|
+
if (!logs.length) {
|
|
476
|
+
info("No activity yet.");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
console.log();
|
|
480
|
+
logs.forEach((l) => {
|
|
481
|
+
const ts = new Date(l.ts).toLocaleString();
|
|
482
|
+
const det =
|
|
483
|
+
l.details && Object.keys(l.details).length
|
|
484
|
+
? C.grey(" " + JSON.stringify(l.details))
|
|
485
|
+
: "";
|
|
486
|
+
console.log(
|
|
487
|
+
` ${C.grey(ts)} ${C.cyan(l.action.padEnd(18))} ${l.repo || ""}${det}`,
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
console.log();
|
|
491
|
+
} catch (e) {
|
|
492
|
+
fatal(e.response?.data?.error || e.message);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ─── UTILS ────────────────────────────────────────────────────────────────────
|
|
497
|
+
function timeSince(d) {
|
|
498
|
+
const s = Math.floor((Date.now() - new Date(d)) / 1000);
|
|
499
|
+
if (s < 60) return "just now";
|
|
500
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
501
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
502
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function parseArgs(rawArgs) {
|
|
506
|
+
const flags = {};
|
|
507
|
+
const rest = [];
|
|
508
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
509
|
+
if (rawArgs[i].startsWith("--")) {
|
|
510
|
+
const key = rawArgs[i].slice(2);
|
|
511
|
+
const val =
|
|
512
|
+
rawArgs[i + 1] && !rawArgs[i + 1].startsWith("--")
|
|
513
|
+
? rawArgs[++i]
|
|
514
|
+
: true;
|
|
515
|
+
flags[key] = val;
|
|
516
|
+
} else {
|
|
517
|
+
rest.push(rawArgs[i]);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return { flags, rest };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
|
524
|
+
const [, , cmd, ...rawArgs] = process.argv;
|
|
525
|
+
const { flags, rest } = parseArgs(rawArgs || []);
|
|
526
|
+
|
|
527
|
+
(async () => {
|
|
528
|
+
switch (cmd) {
|
|
529
|
+
case "-v":
|
|
530
|
+
case "--version":
|
|
531
|
+
console.log(`ngx ${VERSION}`);
|
|
532
|
+
break;
|
|
533
|
+
|
|
534
|
+
case "login":
|
|
535
|
+
await doLogin();
|
|
536
|
+
break;
|
|
537
|
+
|
|
538
|
+
case "logout":
|
|
539
|
+
const c2 = cfg();
|
|
540
|
+
delete c2.token;
|
|
541
|
+
delete c2.username;
|
|
542
|
+
saveCfg(c2);
|
|
543
|
+
ok("Logged out");
|
|
544
|
+
break;
|
|
545
|
+
|
|
546
|
+
case "whoami":
|
|
547
|
+
const u = getUsername();
|
|
548
|
+
if (u) console.log(`Logged in as: ${C.bold(u)} (${getServer()})`);
|
|
549
|
+
else console.log("Not logged in");
|
|
550
|
+
break;
|
|
551
|
+
|
|
552
|
+
// ngx push [reponame] [--path <dir>] [--public] [--message "msg"] [--branch main]
|
|
553
|
+
case "push":
|
|
554
|
+
await doPush(rest[0], {
|
|
555
|
+
path: flags.path,
|
|
556
|
+
visibility: flags.public ? "public" : flags.visibility || "private",
|
|
557
|
+
message: flags.message || flags.m,
|
|
558
|
+
branch: flags.branch || flags.b,
|
|
559
|
+
});
|
|
560
|
+
break;
|
|
561
|
+
|
|
562
|
+
// ngx clone owner/repo [dest]
|
|
563
|
+
case "clone":
|
|
564
|
+
if (!rest[0]) fatal("Usage: ngx clone owner/repo [dest]");
|
|
565
|
+
await doClone(rest[0], rest[1], {});
|
|
566
|
+
break;
|
|
567
|
+
|
|
568
|
+
// ngx repos
|
|
569
|
+
case "repos":
|
|
570
|
+
await listRepos();
|
|
571
|
+
break;
|
|
572
|
+
|
|
573
|
+
// ngx create <name> [--public] [--desc "..."]
|
|
574
|
+
case "create":
|
|
575
|
+
await createRepo(rest[0], {
|
|
576
|
+
visibility: flags.public ? "public" : "private",
|
|
577
|
+
desc: flags.desc || flags.description || "",
|
|
578
|
+
});
|
|
579
|
+
break;
|
|
580
|
+
|
|
581
|
+
// ngx delete <name>
|
|
582
|
+
case "delete":
|
|
583
|
+
if (!rest[0]) fatal("Usage: ngx delete <reponame>");
|
|
584
|
+
await deleteRepo(rest[0]);
|
|
585
|
+
break;
|
|
586
|
+
|
|
587
|
+
// ngx visibility <repo> public|private
|
|
588
|
+
case "visibility":
|
|
589
|
+
await setVisibility(rest[0], rest[1]);
|
|
590
|
+
break;
|
|
591
|
+
|
|
592
|
+
// ngx logs
|
|
593
|
+
case "logs":
|
|
594
|
+
await showLogs();
|
|
595
|
+
break;
|
|
596
|
+
|
|
597
|
+
// ngx config server <url>
|
|
598
|
+
case "config":
|
|
599
|
+
if (rest[0] === "server" && rest[1]) {
|
|
600
|
+
const c3 = cfg();
|
|
601
|
+
c3.server = rest[1];
|
|
602
|
+
saveCfg(c3);
|
|
603
|
+
ok(`Server set to ${rest[1]}`);
|
|
604
|
+
} else console.log("Usage: ngx config server <url>");
|
|
605
|
+
break;
|
|
606
|
+
|
|
607
|
+
default:
|
|
608
|
+
console.log(`
|
|
609
|
+
${C.bold("NGX v" + VERSION)} — GitHub proxy CLI
|
|
610
|
+
|
|
611
|
+
${C.cyan("Auth:")}
|
|
612
|
+
ngx login GitHub OAuth (opens browser)
|
|
613
|
+
ngx logout Clear saved session
|
|
614
|
+
ngx whoami Show current user
|
|
615
|
+
|
|
616
|
+
${C.cyan("Push / Clone:")}
|
|
617
|
+
ngx push Push current folder to GitHub
|
|
618
|
+
ngx push my-repo Push with specific repo name
|
|
619
|
+
ngx push my-repo --public Push as public repo
|
|
620
|
+
ngx push my-repo --path ./src Push a subfolder
|
|
621
|
+
ngx push my-repo --message "feat: x" Custom commit message
|
|
622
|
+
ngx clone owner/repo Clone repo (downloads all files)
|
|
623
|
+
ngx clone owner/repo ./local-dir Clone into specific directory
|
|
624
|
+
|
|
625
|
+
${C.cyan("Repos:")}
|
|
626
|
+
ngx repos List your GitHub repos
|
|
627
|
+
ngx create <name> [--public] Create a new GitHub repo
|
|
628
|
+
ngx delete <name> Delete a GitHub repo
|
|
629
|
+
ngx visibility <repo> public|private Change repo visibility
|
|
630
|
+
|
|
631
|
+
${C.cyan("Logs:")}
|
|
632
|
+
ngx logs Your NGX activity log
|
|
633
|
+
|
|
634
|
+
${C.cyan("Config:")}
|
|
635
|
+
ngx config server <url> Point to a different NGX server
|
|
636
|
+
ngx -v Show version
|
|
637
|
+
|
|
638
|
+
${C.grey("Auto-ignored on push:")}
|
|
639
|
+
${C.grey(".env node_modules .git dist build *.key *.pem secrets")}
|
|
640
|
+
${C.grey("Add extras in .ngxignore or .gitignore")}
|
|
641
|
+
`);
|
|
642
|
+
}
|
|
643
|
+
})();
|
package/data/test.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
how are
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ngx-git",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "NGX - Minimal GitHub-like server",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ngx": "./cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node server.js",
|
|
11
|
+
"dev": "nodemon server.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"axios": "^1.6.0",
|
|
15
|
+
"cors": "^2.8.5",
|
|
16
|
+
"dotenv": "^16.0.3",
|
|
17
|
+
"express": "^4.18.2",
|
|
18
|
+
"jsonwebtoken": "^9.0.2",
|
|
19
|
+
"open": "^11.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"nodemon": "^3.0.1"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/readmi.txt
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# NGX v2.0 — Minimal GitHub-like Code Vault
|
|
2
|
+
|
|
3
|
+
A self-hosted, lightweight code repository server with JWT auth, repo tokens, public/private repos, activity logging, and a React UI.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🚀 Quick Start
|
|
8
|
+
|
|
9
|
+
### 1. Install dependencies
|
|
10
|
+
```bash
|
|
11
|
+
npm install
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### 2. Configure environment
|
|
15
|
+
```bash
|
|
16
|
+
cp .env.example .env
|
|
17
|
+
# Edit .env: set a strong JWT_SECRET
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 3. Start the server
|
|
21
|
+
```bash
|
|
22
|
+
npm start
|
|
23
|
+
# Server runs at http://localhost:3000
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 4. Open the UI
|
|
27
|
+
Open `ui.html` in your browser (or serve it via any static server).
|
|
28
|
+
|
|
29
|
+
### 5. Install CLI globally
|
|
30
|
+
```bash
|
|
31
|
+
npm link
|
|
32
|
+
# Now you can use `ngx` anywhere
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 📡 Server API
|
|
38
|
+
|
|
39
|
+
| Method | Route | Auth | Description |
|
|
40
|
+
|--------|-------|------|-------------|
|
|
41
|
+
| GET | `/status` | None | Server health check |
|
|
42
|
+
| POST | `/auth/register` | None | Create account |
|
|
43
|
+
| POST | `/auth/login` | None | Login, get JWT |
|
|
44
|
+
| GET | `/repos/:user` | Optional | List repos (private filtered) |
|
|
45
|
+
| GET | `/repos/:user/:repo` | Optional | Get repo info |
|
|
46
|
+
| POST | `/repos/:user/:repo` | JWT | Create repo |
|
|
47
|
+
| PATCH | `/repos/:user/:repo/visibility` | JWT | Toggle public/private |
|
|
48
|
+
| DELETE | `/repos/:user/:repo` | JWT | Delete repo |
|
|
49
|
+
| GET | `/repos/:user/:repo/token` | JWT | Get repo token |
|
|
50
|
+
| POST | `/repos/:user/:repo/token/regenerate` | JWT | Regenerate token |
|
|
51
|
+
| POST | `/push/:user/:repo` | JWT or Repo Token | Push zip |
|
|
52
|
+
| GET | `/clone/:user/:repo` | None (public) / JWT or Repo Token (private) | Download zip |
|
|
53
|
+
| GET | `/logs/:user` | JWT | Your activity log |
|
|
54
|
+
| GET | `/logs/:user/:repo` | JWT (private) / None (public) | Repo activity |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 🖥 CLI Commands
|
|
59
|
+
|
|
60
|
+
### Auth
|
|
61
|
+
```bash
|
|
62
|
+
ngx register <username> <password> # Register
|
|
63
|
+
ngx login <username> <password> # Login (saves JWT to ~/.ngxconfig)
|
|
64
|
+
ngx logout # Clear session
|
|
65
|
+
ngx whoami # Show current user
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Repo Management
|
|
69
|
+
```bash
|
|
70
|
+
ngx init <reponame> [public|private] # Create a repo (default: private)
|
|
71
|
+
ngx repos # List your repos
|
|
72
|
+
ngx delete <reponame> # Delete (asks confirmation)
|
|
73
|
+
ngx visibility <reponame> public # Make public
|
|
74
|
+
ngx visibility <reponame> private # Make private
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Push / Clone
|
|
78
|
+
```bash
|
|
79
|
+
# Push (using JWT — must be logged in)
|
|
80
|
+
ngx push ./my-project
|
|
81
|
+
|
|
82
|
+
# Push (using repo token — no login needed)
|
|
83
|
+
ngx push ./my-project --token <repo-token>
|
|
84
|
+
|
|
85
|
+
# Clone public repo (no auth)
|
|
86
|
+
ngx clone alice/my-project
|
|
87
|
+
|
|
88
|
+
# Clone private repo (JWT must be logged in as owner)
|
|
89
|
+
ngx clone alice/my-project
|
|
90
|
+
|
|
91
|
+
# Clone private repo (using repo token)
|
|
92
|
+
ngx clone alice/my-project --token <repo-token>
|
|
93
|
+
|
|
94
|
+
# Clone into specific folder
|
|
95
|
+
ngx clone alice/my-project ./local-copy
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> **Note:** `push` and `clone` use the folder/repo name automatically.
|
|
99
|
+
> `.ngxsafe`, `node_modules`, and `.git` are excluded from push.
|
|
100
|
+
|
|
101
|
+
### Tokens
|
|
102
|
+
```bash
|
|
103
|
+
ngx token show <reponame> # Show current repo token
|
|
104
|
+
ngx token regen <reponame> # Regenerate (old token immediately invalid)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Logs
|
|
108
|
+
```bash
|
|
109
|
+
ngx logs # Your last 100 actions
|
|
110
|
+
ngx logs <reponame> # Activity for a specific repo
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Server Config
|
|
114
|
+
```bash
|
|
115
|
+
ngx config server http://my-server.com:3000 # Point CLI to custom server
|
|
116
|
+
ngx -v # Show version
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 🔐 Auth System
|
|
122
|
+
|
|
123
|
+
### JWT (for UI and CLI login sessions)
|
|
124
|
+
- Expires in **7 days**
|
|
125
|
+
- Stored in `~/.ngxconfig` (CLI) or `sessionStorage` (UI)
|
|
126
|
+
- Required for: creating repos, deleting, changing visibility, viewing tokens
|
|
127
|
+
|
|
128
|
+
### Repo Token (for CI/CD, scripts, team sharing)
|
|
129
|
+
- A UUID generated when repo is created
|
|
130
|
+
- Does **not** expire unless regenerated
|
|
131
|
+
- Can be used with `--token` flag in CLI
|
|
132
|
+
- For **private repos**: needed by anyone who isn't the owner
|
|
133
|
+
- For **public repos**: not needed for clone; needed for push
|
|
134
|
+
|
|
135
|
+
### Public vs Private
|
|
136
|
+
| Operation | Public Repo | Private Repo |
|
|
137
|
+
|-----------|-------------|--------------|
|
|
138
|
+
| Clone | Anyone, no auth | Owner JWT or Repo Token |
|
|
139
|
+
| Push | Owner JWT or Repo Token | Owner JWT or Repo Token |
|
|
140
|
+
| View info | Anyone | Owner only |
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 📁 File Structure
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
ngx-server/
|
|
148
|
+
├── server.js # Express API server
|
|
149
|
+
├── cli.js # CLI tool
|
|
150
|
+
├── ui.html # React UI (single file)
|
|
151
|
+
├── package.json
|
|
152
|
+
├── .env # Your config (JWT_SECRET, PORT)
|
|
153
|
+
├── .env.example
|
|
154
|
+
├── data.json # Users + repos DB (auto-created)
|
|
155
|
+
├── activity.log # Activity log (auto-created, NDJSON)
|
|
156
|
+
├── repos/ # Stored zips (auto-created)
|
|
157
|
+
│ └── <username>/
|
|
158
|
+
│ └── <reponame>/
|
|
159
|
+
│ └── latest.zip
|
|
160
|
+
└── tmp/ # Upload temp dir (auto-created)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 🛡 Security Notes
|
|
166
|
+
|
|
167
|
+
1. **Change `JWT_SECRET`** in `.env` before deploying — use a long random string.
|
|
168
|
+
2. The `data.json` contains hashed passwords (bcrypt, 10 rounds). Never expose it publicly.
|
|
169
|
+
3. For production, put HTTPS in front (nginx/caddy recommended).
|
|
170
|
+
4. Consider adding rate limiting (e.g., `express-rate-limit`) for public deployments.
|
|
171
|
+
5. Repo tokens are UUIDs — treat them like passwords.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## 🔧 Dependencies
|
|
176
|
+
|
|
177
|
+
| Package | Purpose |
|
|
178
|
+
|---------|---------|
|
|
179
|
+
| express | HTTP server |
|
|
180
|
+
| multer | File upload handling |
|
|
181
|
+
| jsonwebtoken | JWT sign/verify |
|
|
182
|
+
| bcryptjs | Password hashing |
|
|
183
|
+
| uuid | Repo token generation |
|
|
184
|
+
| cors | Cross-origin requests |
|
|
185
|
+
| adm-zip | Zip/unzip for push/clone |
|
|
186
|
+
| axios | HTTP client (CLI) |
|
|
187
|
+
| form-data | Multipart form (CLI) |
|
|
188
|
+
| cross-spawn | Process spawning |
|
|
189
|
+
| dotenv | Environment config |
|