repohub-cli 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/repohub.js +52 -0
- package/package.json +28 -4
- package/src/authStore.js +34 -0
- package/src/commands/add.js +31 -0
- package/src/commands/commit.js +38 -0
- package/src/commands/init.js +20 -0
- package/src/commands/login.js +40 -0
- package/src/commands/logout.js +7 -0
- package/src/commands/pull.js +103 -0
- package/src/commands/push.js +88 -0
- package/src/commands/revert.js +32 -0
- package/src/config.js +11 -0
- package/src/http.js +29 -0
- package/src/prompt.js +66 -0
- package/src/ui.js +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Soumya Mandal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# repohub-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line client for **RepoHub**, a GitHub-style platform. Version-control a folder on your laptop and sync its commits with your RepoHub account.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
repohub login → init → add → commit → push
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Requires Node.js 18 or newer.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g repohub-cli
|
|
15
|
+
repohub --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or run it without installing: `npx repohub-cli --help`
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
1. Create an account and a repository on the RepoHub website. Remember the repository name.
|
|
23
|
+
2. Make a folder **with exactly the same name** and open it in a terminal:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mkdir my-project && cd my-project
|
|
27
|
+
|
|
28
|
+
repohub login # asks for email and password (the password is hidden)
|
|
29
|
+
repohub init
|
|
30
|
+
repohub add index.js README.md # one or more files
|
|
31
|
+
repohub commit "first commit"
|
|
32
|
+
repohub push
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Open the repository on the website and look at the **Commits** and **Files** tabs.
|
|
36
|
+
|
|
37
|
+
On another computer:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
mkdir my-project && cd my-project
|
|
41
|
+
repohub login
|
|
42
|
+
repohub pull # downloads the commits
|
|
43
|
+
repohub revert <commitId> # copies that commit's files into the folder
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | What it does |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `repohub login` | Logs in and saves a token in `~/.repohub/config.json` |
|
|
51
|
+
| `repohub logout` | Deletes the saved token |
|
|
52
|
+
| `repohub init` | Creates the hidden `.apnaGit` folder in the current directory |
|
|
53
|
+
| `repohub add <files...>` | Copies files into the staging area |
|
|
54
|
+
| `repohub commit "<message>"` | Saves a snapshot of everything in the staging area |
|
|
55
|
+
| `repohub push` | Uploads your commits (only to repositories you own) |
|
|
56
|
+
| `repohub pull` | Downloads commits you don't have yet (your repos, or public ones) |
|
|
57
|
+
| `repohub revert <commitId>` | Copies the commit's files back into the current folder |
|
|
58
|
+
|
|
59
|
+
### Options
|
|
60
|
+
|
|
61
|
+
- `repohub login --server <url>`: use a different RepoHub backend.
|
|
62
|
+
- `REPOHUB_API_URL`: same, as an environment variable.
|
|
63
|
+
- `--email` / `--password` can be passed to `login` for scripts. Prefer the prompt: a password typed on the command line stays in your shell history.
|
|
64
|
+
|
|
65
|
+
## Good to know
|
|
66
|
+
|
|
67
|
+
- **The folder name must equal the repository name.** That is how the CLI knows where to push.
|
|
68
|
+
- Files are added **flat** (only the file name is kept, not its folder path). Folders cannot be added.
|
|
69
|
+
- Staged files stay staged: every commit is a snapshot of *all* files you have added so far. Run `repohub add` again after editing a file.
|
|
70
|
+
- `push` sends every local commit each time; commits already on the server are simply overwritten with the same data.
|
|
71
|
+
- A commit can hold roughly 7 MB of files.
|
|
72
|
+
- `revert` overwrites files with the same name and never deletes anything.
|
|
73
|
+
- Your login token expires after one day; run `repohub login` again when asked.
|
|
74
|
+
- If the server has been idle it can take up to a minute to wake up. The CLI waits.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/bin/repohub.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const yargs = require("yargs");
|
|
3
|
+
const { hideBin } = require("yargs/helpers");
|
|
4
|
+
|
|
5
|
+
const { login } = require("../src/commands/login");
|
|
6
|
+
const { logout } = require("../src/commands/logout");
|
|
7
|
+
const { init } = require("../src/commands/init");
|
|
8
|
+
const { add } = require("../src/commands/add");
|
|
9
|
+
const { commit } = require("../src/commands/commit");
|
|
10
|
+
const { push } = require("../src/commands/push");
|
|
11
|
+
const { pull } = require("../src/commands/pull");
|
|
12
|
+
const { revert } = require("../src/commands/revert");
|
|
13
|
+
|
|
14
|
+
yargs(hideBin(process.argv))
|
|
15
|
+
.scriptName("repohub")
|
|
16
|
+
.command(
|
|
17
|
+
"login",
|
|
18
|
+
"Log in to RepoHub (asks for email and password if not given)",
|
|
19
|
+
(y) =>
|
|
20
|
+
y
|
|
21
|
+
.option("email", { type: "string", describe: "Your account email" })
|
|
22
|
+
.option("password", { type: "string", describe: "Your password (prefer the hidden prompt)" })
|
|
23
|
+
.option("server", { type: "string", describe: "Backend URL, if not the default" }),
|
|
24
|
+
(argv) => login(argv)
|
|
25
|
+
)
|
|
26
|
+
.command("logout", "Forget the saved login", {}, () => logout())
|
|
27
|
+
.command("init", "Start tracking the current folder", {}, () => init())
|
|
28
|
+
.command(
|
|
29
|
+
"add <files..>",
|
|
30
|
+
"Add files to the staging area",
|
|
31
|
+
(y) => y.positional("files", { describe: "Files to add", type: "string" }),
|
|
32
|
+
(argv) => add(argv.files)
|
|
33
|
+
)
|
|
34
|
+
.command(
|
|
35
|
+
"commit <message>",
|
|
36
|
+
"Save a snapshot of the staged files",
|
|
37
|
+
(y) => y.positional("message", { describe: "Commit message", type: "string" }),
|
|
38
|
+
(argv) => commit(argv.message)
|
|
39
|
+
)
|
|
40
|
+
.command("push", "Upload your commits to RepoHub", {}, () => push())
|
|
41
|
+
.command("pull", "Download commits you don't have yet", {}, () => pull())
|
|
42
|
+
.command(
|
|
43
|
+
"revert <commitId>",
|
|
44
|
+
"Copy a commit's files back into this folder",
|
|
45
|
+
(y) => y.positional("commitId", { describe: "Commit ID", type: "string" }),
|
|
46
|
+
(argv) => revert(argv.commitId)
|
|
47
|
+
)
|
|
48
|
+
.demandCommand(1, "Run `repohub <command>`. Try `repohub --help`.")
|
|
49
|
+
.strict()
|
|
50
|
+
.help()
|
|
51
|
+
.version()
|
|
52
|
+
.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "repohub-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for RepoHub: init, add, commit, push, pull and revert",
|
|
5
|
+
"bin": {
|
|
6
|
+
"repohub": "bin/repohub.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepublishOnly": "node scripts/check-server-url.js"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"yargs": "^17.7.3"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"cli",
|
|
25
|
+
"version-control",
|
|
26
|
+
"git",
|
|
27
|
+
"repohub"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
package/src/authStore.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
const dir = () => path.join(os.homedir(), ".repohub");
|
|
6
|
+
const file = () => path.join(dir(), "config.json");
|
|
7
|
+
|
|
8
|
+
// Saved by `repohub login`: { token, userId, email, apiUrl }
|
|
9
|
+
function saveAuth(data) {
|
|
10
|
+
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
11
|
+
fs.writeFileSync(file(), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
12
|
+
try {
|
|
13
|
+
fs.chmodSync(file(), 0o600); // only you can read your token (no effect on Windows)
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getAuth() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(file(), "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clearAuth() {
|
|
26
|
+
try {
|
|
27
|
+
fs.unlinkSync(file());
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { saveAuth, getAuth, clearAuth };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const fs = require("fs").promises;
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { REPO_DIR } = require("../config");
|
|
4
|
+
const { fail } = require("../ui");
|
|
5
|
+
|
|
6
|
+
async function add(filePaths) {
|
|
7
|
+
const stagingPath = path.resolve(process.cwd(), REPO_DIR, "staging");
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await fs.mkdir(stagingPath, { recursive: true });
|
|
11
|
+
|
|
12
|
+
for (const filePath of filePaths) {
|
|
13
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
14
|
+
if (!stat) {
|
|
15
|
+
fail(`File not found: ${filePath}`);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (!stat.isFile()) {
|
|
19
|
+
fail(`Not a file (folders are not supported): ${filePath}`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const fileName = path.basename(filePath);
|
|
23
|
+
await fs.copyFile(filePath, path.join(stagingPath, fileName));
|
|
24
|
+
console.log(`File ${fileName} added to the staging area!`);
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
fail(`Error adding file: ${err.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { add };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const fs = require("fs").promises;
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const { REPO_DIR } = require("../config");
|
|
5
|
+
const { fail } = require("../ui");
|
|
6
|
+
|
|
7
|
+
async function commit(message) {
|
|
8
|
+
const repoPath = path.resolve(process.cwd(), REPO_DIR);
|
|
9
|
+
const stagedPath = path.join(repoPath, "staging");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
// Look at the staging area first, so a failed commit leaves nothing behind
|
|
13
|
+
const entries = await fs.readdir(stagedPath, { withFileTypes: true }).catch(() => []);
|
|
14
|
+
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
15
|
+
if (files.length === 0) {
|
|
16
|
+
return fail("Nothing staged. Run `repohub add <file>` first.");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const commitID = crypto.randomUUID();
|
|
20
|
+
const commitDir = path.join(repoPath, "commits", commitID);
|
|
21
|
+
await fs.mkdir(commitDir, { recursive: true });
|
|
22
|
+
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
await fs.copyFile(path.join(stagedPath, file), path.join(commitDir, file));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await fs.writeFile(
|
|
28
|
+
path.join(commitDir, "commit.json"),
|
|
29
|
+
JSON.stringify({ message, date: new Date().toISOString() })
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
console.log(`Commit ${commitID} created with message: ${message}`);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
fail(`Error committing files: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { commit };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const fs = require("fs").promises;
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { REPO_DIR } = require("../config");
|
|
4
|
+
const { fail } = require("../ui");
|
|
5
|
+
|
|
6
|
+
async function init() {
|
|
7
|
+
const repoPath = path.resolve(process.cwd(), REPO_DIR);
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const existed = await fs.access(repoPath).then(() => true, () => false);
|
|
11
|
+
await fs.mkdir(path.join(repoPath, "commits"), { recursive: true });
|
|
12
|
+
|
|
13
|
+
console.log(existed ? "Repository already initialised." : "Repository initialised!");
|
|
14
|
+
console.log(`Reminder: this folder must be named exactly like your repository on RepoHub ("${path.basename(process.cwd())}").`);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
fail(`Error initialising repository: ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { init };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const { DEFAULT_API_URL } = require("../config");
|
|
2
|
+
const { saveAuth } = require("../authStore");
|
|
3
|
+
const { request, networkErrorMessage } = require("../http");
|
|
4
|
+
const { ask, closePrompts } = require("../prompt");
|
|
5
|
+
const { fail } = require("../ui");
|
|
6
|
+
|
|
7
|
+
async function login({ email, password, server }) {
|
|
8
|
+
const apiUrl = (server || process.env.REPOHUB_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
9
|
+
|
|
10
|
+
if (/YOUR-BACKEND/.test(apiUrl)) {
|
|
11
|
+
return fail("No server configured. Use: repohub login --server https://your-backend.example.com");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Ask for whatever was not given as a flag (the password is never echoed)
|
|
15
|
+
try {
|
|
16
|
+
if (!email) email = (await ask("Email: ")).trim();
|
|
17
|
+
if (!password) password = await ask("Password: ", { hidden: true });
|
|
18
|
+
} finally {
|
|
19
|
+
closePrompts();
|
|
20
|
+
}
|
|
21
|
+
if (!email || !password) return fail("Email and password are required.");
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const res = await request(`${apiUrl}/login`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ email, password }),
|
|
28
|
+
});
|
|
29
|
+
const data = await res.json().catch(() => ({}));
|
|
30
|
+
|
|
31
|
+
if (!res.ok) return fail(data.message || "Login failed!");
|
|
32
|
+
|
|
33
|
+
saveAuth({ token: data.token, userId: data.userId, email, apiUrl });
|
|
34
|
+
console.log(`Logged in as ${email} (${apiUrl})`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
fail(networkErrorMessage(err));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { login };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const fs = require("fs").promises;
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { REPO_DIR } = require("../config");
|
|
4
|
+
const { getAuth } = require("../authStore");
|
|
5
|
+
const { request, networkErrorMessage } = require("../http");
|
|
6
|
+
const { fail } = require("../ui");
|
|
7
|
+
|
|
8
|
+
const COMMIT_ID = /^[A-Za-z0-9-]{8,64}$/;
|
|
9
|
+
|
|
10
|
+
// Never trust names coming from the network: no folders, no "." / ".."
|
|
11
|
+
function isSafeName(name) {
|
|
12
|
+
return (
|
|
13
|
+
typeof name === "string" &&
|
|
14
|
+
name.length > 0 &&
|
|
15
|
+
!/[\\/]/.test(name) &&
|
|
16
|
+
name !== "." &&
|
|
17
|
+
name !== ".."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function pull() {
|
|
22
|
+
const auth = getAuth();
|
|
23
|
+
if (!auth) {
|
|
24
|
+
return fail("You are not logged in. Run: repohub login");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Folder name = repository name on the website
|
|
28
|
+
const repoName = path.basename(process.cwd());
|
|
29
|
+
const commitsPath = path.resolve(process.cwd(), REPO_DIR, "commits");
|
|
30
|
+
const headers = { Authorization: `Bearer ${auth.token}` };
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// 1. Find the repository on the server
|
|
34
|
+
const repoRes = await request(
|
|
35
|
+
`${auth.apiUrl}/repo/name/${encodeURIComponent(repoName)}`,
|
|
36
|
+
{ headers }
|
|
37
|
+
);
|
|
38
|
+
if (repoRes.status === 401) {
|
|
39
|
+
return fail("Session expired. Run `repohub login` again.");
|
|
40
|
+
}
|
|
41
|
+
if (repoRes.status === 404) {
|
|
42
|
+
return fail(`No repository named "${repoName}" on RepoHub.`);
|
|
43
|
+
}
|
|
44
|
+
if (!repoRes.ok) {
|
|
45
|
+
const body = await repoRes.json().catch(() => ({}));
|
|
46
|
+
return fail(`Could not find the repository: ${body.error || repoRes.status}`);
|
|
47
|
+
}
|
|
48
|
+
const repo = await repoRes.json();
|
|
49
|
+
|
|
50
|
+
// 2. List the commits that exist on the server
|
|
51
|
+
const listRes = await request(`${auth.apiUrl}/repo/${repo._id}/commits`, { headers });
|
|
52
|
+
if (!listRes.ok) {
|
|
53
|
+
const body = await listRes.json().catch(() => ({}));
|
|
54
|
+
return fail(`Could not list commits: ${body.error || listRes.status}`);
|
|
55
|
+
}
|
|
56
|
+
const commits = await listRes.json();
|
|
57
|
+
if (commits.length === 0) {
|
|
58
|
+
console.log("Nothing to pull. There are no commits on the server yet.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await fs.mkdir(commitsPath, { recursive: true });
|
|
63
|
+
|
|
64
|
+
// 3. Download every commit we don't already have (commits never change)
|
|
65
|
+
let pulled = 0;
|
|
66
|
+
let skipped = 0;
|
|
67
|
+
for (const { commitId } of commits) {
|
|
68
|
+
if (!COMMIT_ID.test(commitId)) continue;
|
|
69
|
+
|
|
70
|
+
const commitDir = path.join(commitsPath, commitId);
|
|
71
|
+
const exists = await fs.access(commitDir).then(() => true, () => false);
|
|
72
|
+
if (exists) {
|
|
73
|
+
skipped++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const res = await request(`${auth.apiUrl}/repo/${repo._id}/commits/${commitId}`, { headers });
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const err = await res.json().catch(() => ({}));
|
|
80
|
+
console.error(`Commit ${commitId} failed: ${err.error || res.status}`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const { files } = await res.json();
|
|
85
|
+
|
|
86
|
+
await fs.mkdir(commitDir, { recursive: true });
|
|
87
|
+
for (const f of files) {
|
|
88
|
+
if (!isSafeName(f.name)) {
|
|
89
|
+
console.error(`Skipped unsafe file name: ${f.name}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
await fs.writeFile(path.join(commitDir, f.name), Buffer.from(f.content, "base64"));
|
|
93
|
+
}
|
|
94
|
+
pulled++;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log(`Pulled ${pulled} new commit(s), ${skipped} already up to date.`);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
fail(networkErrorMessage(err));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { pull };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const fs = require("fs").promises;
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { REPO_DIR } = require("../config");
|
|
4
|
+
const { getAuth } = require("../authStore");
|
|
5
|
+
const { request, networkErrorMessage } = require("../http");
|
|
6
|
+
const { fail } = require("../ui");
|
|
7
|
+
|
|
8
|
+
async function push() {
|
|
9
|
+
const auth = getAuth();
|
|
10
|
+
if (!auth) {
|
|
11
|
+
return fail("You are not logged in. Run: repohub login");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Folder name = repository name on the website
|
|
15
|
+
const repoName = path.basename(process.cwd());
|
|
16
|
+
const commitsPath = path.resolve(process.cwd(), REPO_DIR, "commits");
|
|
17
|
+
const headers = {
|
|
18
|
+
Authorization: `Bearer ${auth.token}`,
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// 1. Find the repository on the server
|
|
24
|
+
const repoRes = await request(
|
|
25
|
+
`${auth.apiUrl}/repo/name/${encodeURIComponent(repoName)}`,
|
|
26
|
+
{ headers }
|
|
27
|
+
);
|
|
28
|
+
if (repoRes.status === 401) {
|
|
29
|
+
return fail("Session expired. Run `repohub login` again.");
|
|
30
|
+
}
|
|
31
|
+
if (repoRes.status === 404) {
|
|
32
|
+
return fail(`No repository named "${repoName}" on RepoHub. Create it on the website with exactly this name.`);
|
|
33
|
+
}
|
|
34
|
+
if (!repoRes.ok) {
|
|
35
|
+
const body = await repoRes.json().catch(() => ({}));
|
|
36
|
+
return fail(`Could not find the repository: ${body.error || repoRes.status}`);
|
|
37
|
+
}
|
|
38
|
+
const repo = await repoRes.json();
|
|
39
|
+
if (String(repo.owner?._id) !== String(auth.userId)) {
|
|
40
|
+
return fail(`"${repoName}" belongs to someone else. You can only push to your own repositories.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Read local commits
|
|
44
|
+
let commitDirs;
|
|
45
|
+
try {
|
|
46
|
+
commitDirs = await fs.readdir(commitsPath);
|
|
47
|
+
} catch {
|
|
48
|
+
return fail("Nothing to push. Run `repohub init`, `repohub add` and `repohub commit` first.");
|
|
49
|
+
}
|
|
50
|
+
if (commitDirs.length === 0) {
|
|
51
|
+
return fail("No commits to push.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Send each commit to the backend
|
|
55
|
+
let pushed = 0;
|
|
56
|
+
for (const commitId of commitDirs) {
|
|
57
|
+
const commitPath = path.join(commitsPath, commitId);
|
|
58
|
+
const entries = await fs.readdir(commitPath, { withFileTypes: true });
|
|
59
|
+
|
|
60
|
+
const files = [];
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isFile()) continue;
|
|
63
|
+
const buf = await fs.readFile(path.join(commitPath, entry.name));
|
|
64
|
+
files.push({ name: entry.name, content: buf.toString("base64") });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const res = await request(`${auth.apiUrl}/repo/${repo._id}/commits/${commitId}`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers,
|
|
70
|
+
body: JSON.stringify({ files }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (res.ok) {
|
|
74
|
+
pushed++;
|
|
75
|
+
} else {
|
|
76
|
+
const err = await res.json().catch(() => ({}));
|
|
77
|
+
console.error(`Commit ${commitId} failed: ${err.error || res.status}`);
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`Pushed ${pushed} of ${commitDirs.length} commit(s) to "${repoName}".`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
fail(networkErrorMessage(err));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { push };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const fs = require("fs").promises;
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { REPO_DIR } = require("../config");
|
|
4
|
+
const { fail } = require("../ui");
|
|
5
|
+
|
|
6
|
+
const COMMIT_ID = /^[A-Za-z0-9-]{8,64}$/;
|
|
7
|
+
|
|
8
|
+
async function revert(commitId) {
|
|
9
|
+
if (!COMMIT_ID.test(commitId)) {
|
|
10
|
+
return fail("Invalid commit ID.");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const commitDir = path.resolve(process.cwd(), REPO_DIR, "commits", commitId);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const entries = await fs.readdir(commitDir, { withFileTypes: true }).catch(() => null);
|
|
17
|
+
if (!entries) {
|
|
18
|
+
return fail(`Commit ${commitId} not found in this folder. Try \`repohub pull\` first.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (!entry.isFile() || entry.name === "commit.json") continue; // commit.json is metadata
|
|
23
|
+
await fs.copyFile(path.join(commitDir, entry.name), path.resolve(process.cwd(), entry.name));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.log(`Commit ${commitId} reverted successfully!`);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
fail(`Unable to revert: ${err.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { revert };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The backend the CLI talks to unless the user passes --server
|
|
2
|
+
// (or sets REPOHUB_API_URL). Replace with your deployed backend URL before publishing.
|
|
3
|
+
const DEFAULT_API_URL = "https://repohub-backend-gakq.onrender.com";
|
|
4
|
+
|
|
5
|
+
// Free hosting can take ~30-60 s to wake up, so be patient before giving up.
|
|
6
|
+
const TIMEOUT_MS = Number(process.env.REPOHUB_TIMEOUT_MS) || 90000;
|
|
7
|
+
|
|
8
|
+
// Hidden folder created inside a project by `repohub init`
|
|
9
|
+
const REPO_DIR = ".apnaGit";
|
|
10
|
+
|
|
11
|
+
module.exports = { DEFAULT_API_URL, TIMEOUT_MS, REPO_DIR };
|
package/src/http.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const { TIMEOUT_MS } = require("./config");
|
|
2
|
+
|
|
3
|
+
// fetch() with a timeout, so the CLI never hangs forever
|
|
4
|
+
function request(url, options = {}) {
|
|
5
|
+
return fetch(url, { ...options, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Turn a low-level network error into a sentence a person can act on
|
|
9
|
+
function networkErrorMessage(err) {
|
|
10
|
+
if (err.name === "TimeoutError") {
|
|
11
|
+
return "The server took too long to respond. If it was idle it may still be waking up, try again in a minute.";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The real reason is hidden inside err.cause (sometimes inside an AggregateError)
|
|
15
|
+
const cause = err.cause;
|
|
16
|
+
const reason =
|
|
17
|
+
(cause && (cause.code || (cause.errors && cause.errors[0] && cause.errors[0].code))) ||
|
|
18
|
+
err.message;
|
|
19
|
+
|
|
20
|
+
let hint = "";
|
|
21
|
+
if (reason === "ENOTFOUND" || reason === "EAI_AGAIN") {
|
|
22
|
+
hint = " Check your internet connection and the server address.";
|
|
23
|
+
} else if (reason === "ECONNREFUSED") {
|
|
24
|
+
hint = " The server is not running or the address is wrong.";
|
|
25
|
+
}
|
|
26
|
+
return `Could not reach the server (${reason}).${hint}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { request, networkErrorMessage };
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
|
|
3
|
+
// ---- piped / non-interactive input: read one line at a time ----
|
|
4
|
+
let rl;
|
|
5
|
+
let lines;
|
|
6
|
+
function nextLine() {
|
|
7
|
+
if (!rl) {
|
|
8
|
+
rl = readline.createInterface({ input: process.stdin });
|
|
9
|
+
lines = rl[Symbol.asyncIterator]();
|
|
10
|
+
}
|
|
11
|
+
return lines.next().then((r) => (r.done ? "" : r.value));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ---- real terminal: read characters ourselves so a password is never echoed ----
|
|
15
|
+
function readFromTerminal(question, hidden) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const stdin = process.stdin;
|
|
18
|
+
// Switch to raw mode BEFORE showing the prompt, so nothing typed
|
|
19
|
+
// right away can be echoed by the terminal.
|
|
20
|
+
stdin.setRawMode(true);
|
|
21
|
+
stdin.resume();
|
|
22
|
+
stdin.setEncoding("utf8");
|
|
23
|
+
process.stdout.write(question);
|
|
24
|
+
|
|
25
|
+
let input = "";
|
|
26
|
+
const finish = (value) => {
|
|
27
|
+
stdin.removeListener("data", onData);
|
|
28
|
+
stdin.setRawMode(false);
|
|
29
|
+
stdin.pause();
|
|
30
|
+
process.stdout.write("\n");
|
|
31
|
+
resolve(value);
|
|
32
|
+
};
|
|
33
|
+
const onData = (chunk) => {
|
|
34
|
+
for (const ch of chunk) {
|
|
35
|
+
if (ch === "\r" || ch === "\n") return finish(input);
|
|
36
|
+
if (ch === "\u0003") { // Ctrl+C
|
|
37
|
+
stdin.setRawMode(false);
|
|
38
|
+
process.stdout.write("\n");
|
|
39
|
+
process.exit(130);
|
|
40
|
+
}
|
|
41
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
42
|
+
if (input.length > 0) {
|
|
43
|
+
input = input.slice(0, -1);
|
|
44
|
+
if (!hidden) process.stdout.write("\b \b");
|
|
45
|
+
}
|
|
46
|
+
} else if (ch >= " ") {
|
|
47
|
+
input += ch;
|
|
48
|
+
if (!hidden) process.stdout.write(ch);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
stdin.on("data", onData);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ask(question, { hidden = false } = {}) {
|
|
57
|
+
if (process.stdin.isTTY) return readFromTerminal(question, hidden);
|
|
58
|
+
process.stdout.write(question);
|
|
59
|
+
return nextLine();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function closePrompts() {
|
|
63
|
+
if (rl) rl.close();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { ask, closePrompts };
|