gent-cli 8.0.0 → 9.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/package.json +1 -1
- package/src/commands/branch.js +4 -0
- package/src/commands/clone.js +31 -129
- package/src/commands/log.js +1 -2
- package/src/commands/members.js +134 -0
- package/src/commands/password.js +134 -0
- package/src/commands/pull.js +34 -93
- package/src/commands/push.js +27 -5
- package/src/commands/search.js +1 -1
- package/src/commands/show.js +3 -2
- package/src/commands/tag.js +13 -4
- package/src/index.js +16 -2
- package/src/services/auth-service.js +3 -4
- package/src/utils/api-client.js +5 -4
- package/src/utils/auth-storage.js +15 -3
- package/src/utils/constants.js +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/commands/branch.js
CHANGED
|
@@ -148,6 +148,8 @@ async function syncBranchCreate(name, commitSha, gentPath) {
|
|
|
148
148
|
// Non-fatal: branch created locally even if remote sync fails
|
|
149
149
|
if (error.response?.status === 400) {
|
|
150
150
|
console.log(chalk.gray(` ⚠ Remote sync skipped (branch may already exist remotely)`));
|
|
151
|
+
} else if (error.response?.status === 403) {
|
|
152
|
+
console.log(chalk.yellow(` ⚠ Remote sync skipped — no write access to this repository`));
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
}
|
|
@@ -173,6 +175,8 @@ async function syncBranchDelete(name, gentPath) {
|
|
|
173
175
|
} catch (error) {
|
|
174
176
|
if (error.response?.status === 400) {
|
|
175
177
|
console.log(chalk.gray(` ⚠ Cannot delete default branch on remote`));
|
|
178
|
+
} else if (error.response?.status === 403) {
|
|
179
|
+
console.log(chalk.yellow(` ⚠ Remote delete skipped — no write access to this repository`));
|
|
176
180
|
} else if (error.response?.status === 404) {
|
|
177
181
|
// Branch didn't exist remotely, that's fine
|
|
178
182
|
}
|
package/src/commands/clone.js
CHANGED
|
@@ -11,15 +11,12 @@
|
|
|
11
11
|
* gent clone <url> → Clone into folder named after repo
|
|
12
12
|
* gent clone <url> <directory> → Clone into specific directory
|
|
13
13
|
*
|
|
14
|
-
* ALGORITHM
|
|
14
|
+
* ALGORITHM:
|
|
15
15
|
* 1. Parse URL to get owner_id + repo_name
|
|
16
|
-
* 2. GET
|
|
17
|
-
* 3.
|
|
18
|
-
* 4.
|
|
19
|
-
* 5.
|
|
20
|
-
* 6. Create .gent/ directory structure
|
|
21
|
-
* 7. Store all objects locally
|
|
22
|
-
* 8. Checkout HEAD (restore working tree from latest commit)
|
|
16
|
+
* 2. GET /clone/ → full snapshot (commits, base64 objects, branches, tags)
|
|
17
|
+
* 3. Create .gent/ structure + store objects locally
|
|
18
|
+
* 4. Write commits.json / config / HEAD / staging
|
|
19
|
+
* 5. Checkout the default branch's tree
|
|
23
20
|
*
|
|
24
21
|
* ============================================================================
|
|
25
22
|
*/
|
|
@@ -32,7 +29,7 @@ const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
|
|
|
32
29
|
const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
|
|
33
30
|
const apiClient = require('../utils/api-client');
|
|
34
31
|
const authStorage = require('../utils/auth-storage');
|
|
35
|
-
const { storeBlob, readBlob
|
|
32
|
+
const { storeBlob, readBlob } = require('../utils/hash-engine');
|
|
36
33
|
|
|
37
34
|
/**
|
|
38
35
|
* Clone remote repository
|
|
@@ -65,14 +62,14 @@ async function clone(url, directory, options) {
|
|
|
65
62
|
return;
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
//
|
|
69
|
-
spinner.text = 'Fetching repository
|
|
70
|
-
const
|
|
71
|
-
buildRepoUrl(API_ENDPOINTS.
|
|
65
|
+
// Fetch the full repository snapshot in one call.
|
|
66
|
+
spinner.text = 'Fetching repository...';
|
|
67
|
+
const payload = await apiClient.get(
|
|
68
|
+
buildRepoUrl(API_ENDPOINTS.REPO_CLONE, repoInfo)
|
|
72
69
|
);
|
|
73
70
|
|
|
74
|
-
const repoName =
|
|
75
|
-
const defaultBranch =
|
|
71
|
+
const repoName = payload.name || repoInfo.repo_name;
|
|
72
|
+
const defaultBranch = payload.currentBranch || 'main';
|
|
76
73
|
const targetDir = directory || repoName;
|
|
77
74
|
const targetPath = path.resolve(process.cwd(), targetDir);
|
|
78
75
|
|
|
@@ -84,39 +81,6 @@ async function clone(url, directory, options) {
|
|
|
84
81
|
}
|
|
85
82
|
}
|
|
86
83
|
|
|
87
|
-
// 2. Get branches
|
|
88
|
-
spinner.text = 'Fetching branches...';
|
|
89
|
-
let remoteBranches = [];
|
|
90
|
-
try {
|
|
91
|
-
remoteBranches = await apiClient.get(
|
|
92
|
-
buildRepoUrl(API_ENDPOINTS.REPO_BRANCHES, repoInfo)
|
|
93
|
-
);
|
|
94
|
-
} catch {
|
|
95
|
-
// No branches yet
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 3. Get all commits
|
|
99
|
-
spinner.text = 'Fetching commits...';
|
|
100
|
-
let remoteCommits = [];
|
|
101
|
-
try {
|
|
102
|
-
remoteCommits = await apiClient.get(
|
|
103
|
-
buildRepoUrl(API_ENDPOINTS.REPO_COMMITS, repoInfo)
|
|
104
|
-
);
|
|
105
|
-
} catch {
|
|
106
|
-
// No commits yet
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// 4. Get tags
|
|
110
|
-
spinner.text = 'Fetching tags...';
|
|
111
|
-
let remoteTags = [];
|
|
112
|
-
try {
|
|
113
|
-
remoteTags = await apiClient.get(
|
|
114
|
-
buildRepoUrl(API_ENDPOINTS.REPO_TAGS, repoInfo)
|
|
115
|
-
);
|
|
116
|
-
} catch {
|
|
117
|
-
// No tags
|
|
118
|
-
}
|
|
119
|
-
|
|
120
84
|
// Create directory structure
|
|
121
85
|
spinner.text = 'Setting up repository...';
|
|
122
86
|
const gentPath = path.join(targetPath, GENT_DIR);
|
|
@@ -125,100 +89,33 @@ async function clone(url, directory, options) {
|
|
|
125
89
|
await ensureDir(path.join(gentPath, 'refs', 'heads'));
|
|
126
90
|
await ensureDir(path.join(gentPath, 'refs', 'tags'));
|
|
127
91
|
|
|
128
|
-
//
|
|
129
|
-
|
|
92
|
+
// Store blob objects (base64) into the local object store.
|
|
93
|
+
spinner.text = 'Storing objects...';
|
|
130
94
|
let objectCount = 0;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let treeEntries = [];
|
|
137
|
-
if (commit.tree_sha) {
|
|
138
|
-
try {
|
|
139
|
-
const tree = await apiClient.get(
|
|
140
|
-
buildRepoUrl(API_ENDPOINTS.REPO_TREE_DETAIL, { ...repoInfo, sha: commit.tree_sha })
|
|
141
|
-
);
|
|
142
|
-
treeEntries = tree.entries || [];
|
|
143
|
-
} catch {
|
|
144
|
-
// Tree not available
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Fetch and store blobs
|
|
149
|
-
for (const entry of treeEntries) {
|
|
150
|
-
if (entry.type === 'blob' && entry.sha) {
|
|
151
|
-
try {
|
|
152
|
-
const blob = await apiClient.get(
|
|
153
|
-
buildRepoUrl(API_ENDPOINTS.REPO_BLOB_DETAIL, { ...repoInfo, sha: entry.sha })
|
|
154
|
-
);
|
|
155
|
-
if (blob.content) {
|
|
156
|
-
const buf = decodeRemoteBlobContent(blob.content, entry.sha);
|
|
157
|
-
await storeBlob(gentPath, buf);
|
|
158
|
-
objectCount++;
|
|
159
|
-
}
|
|
160
|
-
} catch {
|
|
161
|
-
// Blob fetch failed
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Convert to local commit format
|
|
167
|
-
localCommits.push({
|
|
168
|
-
hash: commit.sha,
|
|
169
|
-
message: commit.message,
|
|
170
|
-
author: { name: commit.author_name, email: commit.author_email },
|
|
171
|
-
timestamp: commit.committed_at,
|
|
172
|
-
parent: commit.parent_shas && commit.parent_shas[0] || null,
|
|
173
|
-
mergeParent: commit.parent_shas && commit.parent_shas[1] || null,
|
|
174
|
-
treeHash: commit.tree_sha,
|
|
175
|
-
tree: treeEntries.map(e => ({
|
|
176
|
-
mode: e.mode || '100644',
|
|
177
|
-
name: e.name,
|
|
178
|
-
hash: e.sha,
|
|
179
|
-
type: e.type || 'blob'
|
|
180
|
-
})),
|
|
181
|
-
files: treeEntries.map(e => ({ path: e.name, hash: e.sha })),
|
|
182
|
-
stats: {}
|
|
183
|
-
});
|
|
95
|
+
for (const obj of payload.objects || []) {
|
|
96
|
+
if (obj.type !== 'blob' || typeof obj.data !== 'string') continue;
|
|
97
|
+
await storeBlob(gentPath, Buffer.from(obj.data, 'base64'));
|
|
98
|
+
objectCount++;
|
|
184
99
|
}
|
|
185
100
|
|
|
186
|
-
|
|
187
|
-
const branches = {};
|
|
188
|
-
|
|
189
|
-
branches[b.name] = b.commit_sha;
|
|
190
|
-
}
|
|
191
|
-
if (!branches[defaultBranch]) {
|
|
192
|
-
branches[defaultBranch] = null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Build tags map
|
|
196
|
-
const tagsMap = {};
|
|
197
|
-
for (const t of remoteTags) {
|
|
198
|
-
tagsMap[t.name] = {
|
|
199
|
-
hash: t.commit_sha,
|
|
200
|
-
message: t.message || '',
|
|
201
|
-
annotated: t.annotated || false,
|
|
202
|
-
tagger: { name: t.tagger_name || '', email: t.tagger_email || '' },
|
|
203
|
-
timestamp: t.created_at
|
|
204
|
-
};
|
|
205
|
-
}
|
|
101
|
+
const localCommits = payload.commits || [];
|
|
102
|
+
const branches = payload.branches || {};
|
|
103
|
+
if (!(defaultBranch in branches)) branches[defaultBranch] = null;
|
|
206
104
|
|
|
207
105
|
// Write commits.json
|
|
208
|
-
|
|
106
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), {
|
|
209
107
|
commits: localCommits,
|
|
210
108
|
branches,
|
|
211
109
|
currentBranch: defaultBranch,
|
|
212
|
-
tags:
|
|
213
|
-
};
|
|
214
|
-
await writeJSON(path.join(gentPath, COMMITS_FILE), repoData);
|
|
110
|
+
tags: payload.tags || {}
|
|
111
|
+
});
|
|
215
112
|
|
|
216
113
|
// Write config with remote
|
|
217
114
|
const config = {
|
|
218
115
|
user: { name: '', email: '' },
|
|
219
116
|
repository: {
|
|
220
117
|
name: repoName,
|
|
221
|
-
description:
|
|
118
|
+
description: payload.description || '',
|
|
222
119
|
created: new Date().toISOString()
|
|
223
120
|
},
|
|
224
121
|
remotes: {
|
|
@@ -256,12 +153,15 @@ async function clone(url, directory, options) {
|
|
|
256
153
|
spinner.text = 'Checking out files...';
|
|
257
154
|
let fileCount = 0;
|
|
258
155
|
for (const entry of tree) {
|
|
156
|
+
if (entry.type && entry.type !== 'blob') continue;
|
|
157
|
+
const relPath = entry.name || entry.path;
|
|
158
|
+
if (!relPath || !entry.hash) continue;
|
|
259
159
|
try {
|
|
260
160
|
// Write the raw Buffer — not a UTF-8 string. Decoding a
|
|
261
161
|
// binary blob (PNG, PDF, etc.) as UTF-8 would replace
|
|
262
162
|
// non-utf-8 bytes with U+FFFD, silently corrupting it.
|
|
263
163
|
const buf = await readBlob(gentPath, entry.hash);
|
|
264
|
-
const fullPath = path.join(targetPath,
|
|
164
|
+
const fullPath = path.join(targetPath, relPath);
|
|
265
165
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
266
166
|
await fs.writeFile(fullPath, buf);
|
|
267
167
|
fileCount++;
|
|
@@ -285,6 +185,8 @@ async function clone(url, directory, options) {
|
|
|
285
185
|
console.error(chalk.red('Repository not found'));
|
|
286
186
|
} else if (error.response?.status === 401) {
|
|
287
187
|
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
188
|
+
} else if (error.response?.status === 403) {
|
|
189
|
+
console.error(chalk.red('Access denied — you do not have permission to clone this repository'));
|
|
288
190
|
} else if (error.response?.data) {
|
|
289
191
|
console.error(chalk.red(JSON.stringify(error.response.data)));
|
|
290
192
|
} else {
|
package/src/commands/log.js
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
* Reads commits.json, filters by branch HEAD → parent chain, displays
|
|
17
17
|
* in reverse chronological order.
|
|
18
18
|
*
|
|
19
|
-
* BACKEND
|
|
20
|
-
* GET /api/repos/:id/commits/?branch=main&limit=10
|
|
19
|
+
* BACKEND: none — fully local. Reads commits.json; makes no HTTP request.
|
|
21
20
|
*
|
|
22
21
|
* ============================================================================
|
|
23
22
|
*/
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Members Command - Manage repository collaborators (owner-only for add/remove).
|
|
3
|
+
*
|
|
4
|
+
* USAGE:
|
|
5
|
+
* gent members → list owner + members with roles
|
|
6
|
+
* gent members add <email> → add a collaborator (default role: write)
|
|
7
|
+
* gent members add <email> --role read
|
|
8
|
+
* gent members remove <email> → remove a collaborator
|
|
9
|
+
*
|
|
10
|
+
* BACKEND:
|
|
11
|
+
* GET /api/repos/:owner_id/:repo_name/members/ → [{ user_id, email, role, created_at }]
|
|
12
|
+
* POST /api/repos/:owner_id/:repo_name/members/ { email, role: 'write'|'read' }
|
|
13
|
+
* DELETE /api/repos/:owner_id/:repo_name/members/:user_id/
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const chalk = require('chalk');
|
|
17
|
+
const ora = require('ora');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { readJSON, getGentPath } = require('../utils/fileSystem');
|
|
20
|
+
const { CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
|
|
21
|
+
const apiClient = require('../utils/api-client');
|
|
22
|
+
const authStorage = require('../utils/auth-storage');
|
|
23
|
+
|
|
24
|
+
const VALID_ROLES = ['write', 'read'];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the origin remote's { owner_id, repo_name } for the current repo.
|
|
28
|
+
*/
|
|
29
|
+
async function resolveRepoInfo() {
|
|
30
|
+
const gentPath = await getGentPath();
|
|
31
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
32
|
+
const remoteConfig = config.remotes && config.remotes.origin;
|
|
33
|
+
if (!remoteConfig) {
|
|
34
|
+
throw new Error("No 'origin' remote. Use \"gent remote add origin <url>\" first.");
|
|
35
|
+
}
|
|
36
|
+
const repoInfo = parseRemoteUrl(remoteConfig.url);
|
|
37
|
+
if (!repoInfo) {
|
|
38
|
+
throw new Error('Invalid origin remote URL. Expected /api/repos/{owner_id}/{repo_name}');
|
|
39
|
+
}
|
|
40
|
+
return repoInfo;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function members(action, target, options = {}) {
|
|
44
|
+
try {
|
|
45
|
+
if (!(await authStorage.isAuthenticated())) {
|
|
46
|
+
console.error(chalk.red('Not authenticated'));
|
|
47
|
+
console.log(chalk.yellow('Run "gent login" first'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const repoInfo = await resolveRepoInfo();
|
|
52
|
+
|
|
53
|
+
if (!action || action === 'list') {
|
|
54
|
+
await listMembers(repoInfo);
|
|
55
|
+
} else if (action === 'add') {
|
|
56
|
+
await addMember(repoInfo, target, options);
|
|
57
|
+
} else if (action === 'remove' || action === 'rm') {
|
|
58
|
+
await removeMember(repoInfo, target);
|
|
59
|
+
} else {
|
|
60
|
+
console.error(chalk.red(`Unknown action '${action}'`));
|
|
61
|
+
console.log(chalk.yellow('Usage: gent members [list | add <email> | remove <email>]'));
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
handleError(error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function listMembers(repoInfo) {
|
|
69
|
+
const spinner = ora('Fetching members...').start();
|
|
70
|
+
const data = await apiClient.get(buildRepoUrl(API_ENDPOINTS.REPO_MEMBERS, repoInfo));
|
|
71
|
+
spinner.stop();
|
|
72
|
+
|
|
73
|
+
console.log(chalk.bold.cyan('\nRepository members:\n'));
|
|
74
|
+
for (const m of data || []) {
|
|
75
|
+
const role = m.role === 'owner'
|
|
76
|
+
? chalk.magenta('owner')
|
|
77
|
+
: m.role === 'write' ? chalk.green('write') : chalk.gray('read');
|
|
78
|
+
console.log(` ${chalk.white.bold(m.email)} [${role}] ${chalk.gray(`#${m.user_id}`)}`);
|
|
79
|
+
}
|
|
80
|
+
console.log();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function addMember(repoInfo, email, options) {
|
|
84
|
+
if (!email) {
|
|
85
|
+
console.error(chalk.red('Usage: gent members add <email> [--role write|read]'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const role = (options.role || 'write').toLowerCase();
|
|
89
|
+
if (!VALID_ROLES.includes(role)) {
|
|
90
|
+
console.error(chalk.red(`Invalid role '${role}'. Use one of: ${VALID_ROLES.join(', ')}`));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const spinner = ora(`Adding ${email} as ${role}...`).start();
|
|
95
|
+
await apiClient.post(buildRepoUrl(API_ENDPOINTS.REPO_MEMBERS, repoInfo), { email, role });
|
|
96
|
+
spinner.succeed(chalk.green(`Added ${email} (${role})`));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function removeMember(repoInfo, email) {
|
|
100
|
+
if (!email) {
|
|
101
|
+
console.error(chalk.red('Usage: gent members remove <email>'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const spinner = ora(`Removing ${email}...`).start();
|
|
106
|
+
// The remove endpoint keys on user_id, so resolve it from the member list.
|
|
107
|
+
const list = await apiClient.get(buildRepoUrl(API_ENDPOINTS.REPO_MEMBERS, repoInfo));
|
|
108
|
+
const member = (list || []).find(m => m.email === email && m.role !== 'owner');
|
|
109
|
+
if (!member) {
|
|
110
|
+
spinner.fail(chalk.red(`${email} is not a member of this repository`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await apiClient.delete(
|
|
115
|
+
buildRepoUrl(API_ENDPOINTS.REPO_MEMBER_DETAIL, { ...repoInfo, user_id: member.user_id })
|
|
116
|
+
);
|
|
117
|
+
spinner.succeed(chalk.green(`Removed ${email}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function handleError(error) {
|
|
121
|
+
if (error.response?.status === 401) {
|
|
122
|
+
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
123
|
+
} else if (error.response?.status === 403) {
|
|
124
|
+
console.error(chalk.red(error.response.data?.error || 'Only the repository owner can manage members'));
|
|
125
|
+
} else if (error.response?.data) {
|
|
126
|
+
const d = error.response.data;
|
|
127
|
+
console.error(chalk.red(d.error || (typeof d === 'object' ? JSON.stringify(d) : d)));
|
|
128
|
+
} else {
|
|
129
|
+
console.error(chalk.red('Error:'), error.message);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = members;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Command - Change or reset your account password.
|
|
3
|
+
*
|
|
4
|
+
* USAGE:
|
|
5
|
+
* gent password change → change password (prompts current + new)
|
|
6
|
+
* gent password reset [email] → email yourself a reset link
|
|
7
|
+
* gent password reset-confirm → finish a reset with uid + token from the email
|
|
8
|
+
*
|
|
9
|
+
* BACKEND:
|
|
10
|
+
* POST /api/auth/password/change/ { current_password, new_password, new_password_confirm }
|
|
11
|
+
* POST /api/auth/password/reset/ { email }
|
|
12
|
+
* POST /api/auth/password/reset/confirm/ { uid, token, new_password, new_password_confirm }
|
|
13
|
+
*
|
|
14
|
+
* Note: changing/resetting the password blacklists all refresh tokens, so
|
|
15
|
+
* `change` re-logs you in with the new password to keep the session alive.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const chalk = require('chalk');
|
|
19
|
+
const inquirer = require('inquirer');
|
|
20
|
+
const ora = require('ora');
|
|
21
|
+
const { API_ENDPOINTS } = require('../utils/constants');
|
|
22
|
+
const apiClient = require('../utils/api-client');
|
|
23
|
+
const authStorage = require('../utils/auth-storage');
|
|
24
|
+
const authService = require('../services/auth-service');
|
|
25
|
+
|
|
26
|
+
async function password(action, options = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const act = action || 'change';
|
|
29
|
+
if (act === 'change') {
|
|
30
|
+
await changePassword();
|
|
31
|
+
} else if (act === 'reset') {
|
|
32
|
+
await requestReset(options);
|
|
33
|
+
} else if (act === 'reset-confirm') {
|
|
34
|
+
await confirmReset();
|
|
35
|
+
} else {
|
|
36
|
+
console.error(chalk.red(`Unknown action '${action}'`));
|
|
37
|
+
console.log(chalk.yellow('Usage: gent password [change | reset [email] | reset-confirm]'));
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
handleError(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function changePassword() {
|
|
45
|
+
if (!(await authStorage.isAuthenticated())) {
|
|
46
|
+
console.error(chalk.red('Not authenticated'));
|
|
47
|
+
console.log(chalk.yellow('Run "gent login" first'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const user = await authStorage.getUser();
|
|
51
|
+
|
|
52
|
+
const answers = await inquirer.prompt([
|
|
53
|
+
{ type: 'password', name: 'current', message: 'Current password:', mask: '*' },
|
|
54
|
+
{
|
|
55
|
+
type: 'password', name: 'next', message: 'New password:', mask: '*',
|
|
56
|
+
validate: (v) => v.length >= 8 || 'Password must be at least 8 characters long'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'password', name: 'confirm', message: 'Confirm new password:', mask: '*',
|
|
60
|
+
validate: (v, a) => v === a.next || 'Passwords do not match'
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const spinner = ora('Changing password...').start();
|
|
65
|
+
await apiClient.post(API_ENDPOINTS.PASSWORD_CHANGE, {
|
|
66
|
+
current_password: answers.current,
|
|
67
|
+
new_password: answers.next,
|
|
68
|
+
new_password_confirm: answers.confirm,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The backend blacklisted our refresh token; re-login to refresh the session.
|
|
72
|
+
try {
|
|
73
|
+
if (user?.email) await authService.login(user.email, answers.next);
|
|
74
|
+
spinner.succeed(chalk.green('Password changed'));
|
|
75
|
+
} catch {
|
|
76
|
+
await authStorage.clearAuth();
|
|
77
|
+
spinner.succeed(chalk.green('Password changed'));
|
|
78
|
+
console.log(chalk.yellow('Please run "gent login" again with your new password.'));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function requestReset(options) {
|
|
83
|
+
let email = options.email;
|
|
84
|
+
if (!email) {
|
|
85
|
+
({ email } = await inquirer.prompt([{ type: 'input', name: 'email', message: 'Account email:' }]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const spinner = ora('Requesting password reset...').start();
|
|
89
|
+
const res = await apiClient.post(API_ENDPOINTS.PASSWORD_RESET, { email });
|
|
90
|
+
spinner.succeed(chalk.green(res.message || 'If that account exists, a reset link has been sent.'));
|
|
91
|
+
console.log(chalk.gray('Open the link in your email, then run "gent password reset-confirm".'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function confirmReset() {
|
|
95
|
+
const a = await inquirer.prompt([
|
|
96
|
+
{ type: 'input', name: 'uid', message: 'uid (from reset link):' },
|
|
97
|
+
{ type: 'input', name: 'token', message: 'token (from reset link):' },
|
|
98
|
+
{
|
|
99
|
+
type: 'password', name: 'next', message: 'New password:', mask: '*',
|
|
100
|
+
validate: (v) => v.length >= 8 || 'Password must be at least 8 characters long'
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'password', name: 'confirm', message: 'Confirm new password:', mask: '*',
|
|
104
|
+
validate: (v, ans) => v === ans.next || 'Passwords do not match'
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const spinner = ora('Resetting password...').start();
|
|
109
|
+
const res = await apiClient.post(API_ENDPOINTS.PASSWORD_RESET_CONFIRM, {
|
|
110
|
+
uid: a.uid,
|
|
111
|
+
token: a.token,
|
|
112
|
+
new_password: a.next,
|
|
113
|
+
new_password_confirm: a.confirm,
|
|
114
|
+
});
|
|
115
|
+
spinner.succeed(chalk.green(res.message || 'Password reset successfully'));
|
|
116
|
+
console.log(chalk.gray('Run "gent login" with your new password.'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleError(error) {
|
|
120
|
+
const data = error.response?.data;
|
|
121
|
+
if (error.response?.status === 401 && data?.current_password) {
|
|
122
|
+
console.error(chalk.red('Current password is incorrect'));
|
|
123
|
+
} else if (data) {
|
|
124
|
+
// DRF returns { field: [messages] } or { error/detail: message }.
|
|
125
|
+
const msg = data.error || data.detail
|
|
126
|
+
|| (typeof data === 'object' ? Object.values(data).flat().join(', ') : data);
|
|
127
|
+
console.error(chalk.red(msg || 'Request failed'));
|
|
128
|
+
} else {
|
|
129
|
+
console.error(chalk.red('Error:'), error.message);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = password;
|
package/src/commands/pull.js
CHANGED
|
@@ -11,13 +11,10 @@
|
|
|
11
11
|
* gent pull → Pull from origin/current-branch
|
|
12
12
|
* gent pull <remote> <branch> → Pull specific remote/branch
|
|
13
13
|
*
|
|
14
|
-
* ALGORITHM
|
|
15
|
-
* 1. GET .../
|
|
16
|
-
* 2.
|
|
17
|
-
* 3.
|
|
18
|
-
* 4. For each new commit, fetch tree + blobs via individual endpoints
|
|
19
|
-
* 5. Store objects locally
|
|
20
|
-
* 6. If diverged: run 3-way merge. If fast-forward: advance pointer
|
|
14
|
+
* ALGORITHM:
|
|
15
|
+
* 1. GET .../pull/?branch=&since= → { commits, objects (base64), head } in one call
|
|
16
|
+
* 2. Store objects locally, add new commits to the local store
|
|
17
|
+
* 3. If diverged: run 3-way merge. If fast-forward: advance the pointer
|
|
21
18
|
*
|
|
22
19
|
* ============================================================================
|
|
23
20
|
*/
|
|
@@ -30,7 +27,7 @@ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
|
|
|
30
27
|
const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
|
|
31
28
|
const apiClient = require('../utils/api-client');
|
|
32
29
|
const authStorage = require('../utils/auth-storage');
|
|
33
|
-
const { storeBlob,
|
|
30
|
+
const { storeBlob, readBlob } = require('../utils/hash-engine');
|
|
34
31
|
const { findMergeBase, mergeTreeEntries } = require('../utils/merge-engine');
|
|
35
32
|
const { generateCommitHash } = require('../utils/helpers');
|
|
36
33
|
|
|
@@ -73,104 +70,48 @@ async function pull(remoteName, branchName, options) {
|
|
|
73
70
|
const branch = branchName || repository.currentBranch;
|
|
74
71
|
const localHead = repository.branches[branch] || null;
|
|
75
72
|
|
|
76
|
-
// 1.
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
// 1. Fetch commits + objects for this branch in a single call. `since`
|
|
74
|
+
// lets the server send only what we don't have on a fast-forward.
|
|
75
|
+
spinner.text = `Fetching updates for ${branch}...`;
|
|
76
|
+
let pullData;
|
|
79
77
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
const pullUrl = buildRepoUrl(API_ENDPOINTS.REPO_PULL, repoInfo);
|
|
79
|
+
const query = localHead
|
|
80
|
+
? `?branch=${encodeURIComponent(branch)}&since=${encodeURIComponent(localHead)}`
|
|
81
|
+
: `?branch=${encodeURIComponent(branch)}`;
|
|
82
|
+
pullData = await apiClient.get(pullUrl + query);
|
|
84
83
|
} catch (error) {
|
|
85
84
|
if (error.response?.status === 404) {
|
|
86
85
|
spinner.succeed(chalk.green('Remote branch not found — nothing to pull'));
|
|
87
86
|
return;
|
|
88
87
|
}
|
|
88
|
+
if (error.response?.status === 403) {
|
|
89
|
+
spinner.fail(chalk.red('Access denied — you are not a member of this private repository'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
89
92
|
throw error;
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 2. Fetch all remote commits
|
|
98
|
-
spinner.text = `Fetching commits...`;
|
|
99
|
-
const remoteCommits = await apiClient.get(
|
|
100
|
-
buildRepoUrl(API_ENDPOINTS.REPO_COMMITS, repoInfo)
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
// 3. Find commits we don't have locally
|
|
104
|
-
const localCommitSet = new Set((repository.commits || []).map(c => c.hash || c.sha));
|
|
105
|
-
const newRemoteCommits = remoteCommits.filter(c => !localCommitSet.has(c.sha));
|
|
95
|
+
const remoteHead = pullData.head;
|
|
96
|
+
config.remoteRefs = config.remoteRefs || {};
|
|
106
97
|
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
config.remoteRefs = config.remoteRefs || {};
|
|
110
|
-
config.remoteRefs[`${remote}/${branch}`] = remoteHead;
|
|
98
|
+
if (!remoteHead || remoteHead === localHead) {
|
|
99
|
+
if (remoteHead) config.remoteRefs[`${remote}/${branch}`] = remoteHead;
|
|
111
100
|
await writeJSON(path.join(gentPath, CONFIG_FILE), config);
|
|
112
101
|
spinner.succeed(chalk.green('Already up-to-date'));
|
|
113
102
|
return;
|
|
114
103
|
}
|
|
115
104
|
|
|
116
|
-
//
|
|
117
|
-
spinner.text =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// Fetch tree for this commit
|
|
122
|
-
let treeEntries = [];
|
|
123
|
-
if (commit.tree_sha) {
|
|
124
|
-
try {
|
|
125
|
-
const tree = await apiClient.get(
|
|
126
|
-
buildRepoUrl(API_ENDPOINTS.REPO_TREE_DETAIL, { ...repoInfo, sha: commit.tree_sha })
|
|
127
|
-
);
|
|
128
|
-
treeEntries = tree.entries || [];
|
|
129
|
-
} catch {
|
|
130
|
-
// Tree may not be available
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Fetch and store blobs
|
|
135
|
-
for (const entry of treeEntries) {
|
|
136
|
-
if (entry.type === 'blob' && entry.sha) {
|
|
137
|
-
if (!(await objectExists(gentPath, entry.sha))) {
|
|
138
|
-
try {
|
|
139
|
-
const blob = await apiClient.get(
|
|
140
|
-
buildRepoUrl(API_ENDPOINTS.REPO_BLOB_DETAIL, { ...repoInfo, sha: entry.sha })
|
|
141
|
-
);
|
|
142
|
-
if (blob.content) {
|
|
143
|
-
const buf = decodeRemoteBlobContent(blob.content, entry.sha);
|
|
144
|
-
await storeBlob(gentPath, buf);
|
|
145
|
-
}
|
|
146
|
-
} catch {
|
|
147
|
-
// Blob fetch failed, continue
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Convert remote commit format to local format
|
|
154
|
-
fetchedCommits.push({
|
|
155
|
-
hash: commit.sha,
|
|
156
|
-
message: commit.message,
|
|
157
|
-
author: { name: commit.author_name, email: commit.author_email },
|
|
158
|
-
timestamp: commit.committed_at,
|
|
159
|
-
parent: commit.parent_shas && commit.parent_shas[0] || null,
|
|
160
|
-
mergeParent: commit.parent_shas && commit.parent_shas[1] || null,
|
|
161
|
-
treeHash: commit.tree_sha,
|
|
162
|
-
tree: treeEntries.map(e => ({
|
|
163
|
-
mode: e.mode || '100644',
|
|
164
|
-
name: e.name,
|
|
165
|
-
hash: e.sha,
|
|
166
|
-
type: e.type || 'blob'
|
|
167
|
-
})),
|
|
168
|
-
files: treeEntries.map(e => ({ path: e.name, hash: e.sha })),
|
|
169
|
-
stats: {}
|
|
170
|
-
});
|
|
105
|
+
// 2. Store blob objects (base64) into the local object store.
|
|
106
|
+
spinner.text = 'Storing objects...';
|
|
107
|
+
for (const obj of pullData.objects || []) {
|
|
108
|
+
if (obj.type !== 'blob' || typeof obj.data !== 'string') continue;
|
|
109
|
+
await storeBlob(gentPath, Buffer.from(obj.data, 'base64'));
|
|
171
110
|
}
|
|
172
111
|
|
|
173
|
-
//
|
|
112
|
+
// 3. Add new remote commits to the local store (dedup by hash). The
|
|
113
|
+
// server returns them in the CLI's native commit shape already.
|
|
114
|
+
const fetchedCommits = pullData.commits || [];
|
|
174
115
|
let newCount = 0;
|
|
175
116
|
const commitSet = new Set((repository.commits || []).map(c => c.hash));
|
|
176
117
|
for (const commit of fetchedCommits) {
|
|
@@ -182,9 +123,7 @@ async function pull(remoteName, branchName, options) {
|
|
|
182
123
|
}
|
|
183
124
|
}
|
|
184
125
|
|
|
185
|
-
//
|
|
186
|
-
config.remoteRefs = config.remoteRefs || {};
|
|
187
|
-
|
|
126
|
+
// 4. Merge strategy
|
|
188
127
|
if (!localHead || isAncestor(repository.commits, localHead, remoteHead)) {
|
|
189
128
|
// Fast-forward
|
|
190
129
|
const previousTree = localHead ? getCommitTree(repository.commits, localHead) : [];
|
|
@@ -224,7 +163,7 @@ async function pull(remoteName, branchName, options) {
|
|
|
224
163
|
const mergeCommit = {
|
|
225
164
|
hash: generateCommitHash(),
|
|
226
165
|
message: `Merge remote-tracking branch '${remote}/${branch}'`,
|
|
227
|
-
author: (repository.commits.find(c => c.hash === localHead) || {}).author || { name: 'Unknown', email: '' },
|
|
166
|
+
author: (repository.commits.find(c => c.hash === localHead) || {}).author || { name: 'Unknown', email: (await authStorage.getUser())?.email || '' },
|
|
228
167
|
timestamp: new Date().toISOString(),
|
|
229
168
|
parent: localHead,
|
|
230
169
|
mergeParent: remoteHead,
|
|
@@ -259,6 +198,8 @@ async function pull(remoteName, branchName, options) {
|
|
|
259
198
|
spinner.fail(chalk.red('Pull failed'));
|
|
260
199
|
if (error.response?.status === 401) {
|
|
261
200
|
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
201
|
+
} else if (error.response?.status === 403) {
|
|
202
|
+
console.error(chalk.red('Access denied — you are not a member of this private repository'));
|
|
262
203
|
} else if (error.response?.data) {
|
|
263
204
|
console.error(chalk.red(JSON.stringify(error.response.data)));
|
|
264
205
|
} else {
|
package/src/commands/push.js
CHANGED
|
@@ -178,17 +178,39 @@ async function push(remoteName, branchName, options) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
// Build commits for the pack
|
|
181
|
+
// Build commits for the pack. author_email must satisfy the backend's
|
|
182
|
+
// EmailField(required=True); fall back to the logged-in user's email so
|
|
183
|
+
// merge/legacy commits with a blank email don't 400 the whole push.
|
|
184
|
+
const fallbackEmail = (await authStorage.getUser())?.email || '';
|
|
182
185
|
const packCommits = commitsToPush.map(c => ({
|
|
183
186
|
sha: c.hash,
|
|
184
187
|
message: c.message,
|
|
185
188
|
tree_sha: c.treeHash || '',
|
|
186
189
|
parent_shas: [c.parent, c.mergeParent].filter(Boolean),
|
|
187
|
-
author_name: typeof c.author === 'object' ?
|
|
188
|
-
author_email: typeof c.author === 'object' ?
|
|
190
|
+
author_name: (typeof c.author === 'object' ? c.author.name : c.author) || 'Unknown',
|
|
191
|
+
author_email: (typeof c.author === 'object' ? c.author.email : '') || fallbackEmail,
|
|
189
192
|
committed_at: c.timestamp || new Date().toISOString()
|
|
190
193
|
}));
|
|
191
194
|
|
|
195
|
+
// Only send tags whose target commit will exist on the remote after this
|
|
196
|
+
// push (in this pack, or reachable from an already-pushed remote ref).
|
|
197
|
+
// The backend validates every tag's commit and atomically 400s the whole
|
|
198
|
+
// push for any tag pointing at a commit it doesn't have.
|
|
199
|
+
const pushableShas = new Set(commitsToPush.map(c => c.hash));
|
|
200
|
+
const commitByHash = new Map(commits.map(c => [c.hash, c]));
|
|
201
|
+
for (const [refName, refHead] of Object.entries(config.remoteRefs || {})) {
|
|
202
|
+
if (!refName.startsWith(`${remote}/`)) continue;
|
|
203
|
+
let cur = refHead;
|
|
204
|
+
while (cur && !pushableShas.has(cur)) {
|
|
205
|
+
pushableShas.add(cur);
|
|
206
|
+
cur = commitByHash.get(cur)?.parent || null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const tagsToPush = {};
|
|
210
|
+
for (const [name, tag] of Object.entries(repository.tags || {})) {
|
|
211
|
+
if (tag && tag.hash && pushableShas.has(tag.hash)) tagsToPush[name] = tag;
|
|
212
|
+
}
|
|
213
|
+
|
|
192
214
|
// Build push payload matching PushPackRequest schema
|
|
193
215
|
const payload = {
|
|
194
216
|
pack: {
|
|
@@ -200,7 +222,7 @@ async function push(remoteName, branchName, options) {
|
|
|
200
222
|
name: branch,
|
|
201
223
|
commit_sha: localHead
|
|
202
224
|
}],
|
|
203
|
-
tags:
|
|
225
|
+
tags: tagsToPush
|
|
204
226
|
};
|
|
205
227
|
|
|
206
228
|
// Send to backend
|
|
@@ -224,7 +246,7 @@ async function push(remoteName, branchName, options) {
|
|
|
224
246
|
} else if (error.response?.status === 401) {
|
|
225
247
|
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
226
248
|
} else if (error.response?.status === 403) {
|
|
227
|
-
console.error(chalk.red('Permission denied —
|
|
249
|
+
console.error(chalk.red(error.response.data?.error || error.response.data?.detail || 'Permission denied — you need write access to this repository'));
|
|
228
250
|
} else if (error.response?.data) {
|
|
229
251
|
console.error(chalk.red(JSON.stringify(error.response.data, null, 2)));
|
|
230
252
|
} else {
|
package/src/commands/search.js
CHANGED
|
@@ -41,7 +41,7 @@ async function search(query, options = {}) {
|
|
|
41
41
|
const filtered = repos.filter(r => {
|
|
42
42
|
if (options.mine && myId && r.owner_id !== myId) return false;
|
|
43
43
|
if (!q) return true;
|
|
44
|
-
const haystack = [r.name, r.description, r.
|
|
44
|
+
const haystack = [r.name, r.description, r.owner_email]
|
|
45
45
|
.filter(Boolean).join(' ').toLowerCase();
|
|
46
46
|
return haystack.includes(q);
|
|
47
47
|
});
|
package/src/commands/show.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* Retrieves commit from commits.json, reads blob content from object store,
|
|
17
17
|
* computes diff against parent commit's tree, and displays unified diff.
|
|
18
18
|
*
|
|
19
|
-
* BACKEND
|
|
20
|
-
*
|
|
19
|
+
* BACKEND: none — fully local. Reads commits.json + the local object store.
|
|
20
|
+
* (Remote commit_detail returns { sha, tree_sha, parent_shas[], author_name,
|
|
21
|
+
* author_email, committed_at, message } — tree_sha is a bare string, no embedded tree.)
|
|
21
22
|
*
|
|
22
23
|
* ============================================================================
|
|
23
24
|
*/
|
package/src/commands/tag.js
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* Annotated tag = includes tagger info, message, timestamp.
|
|
19
19
|
*
|
|
20
20
|
* BACKEND EXPECTATIONS:
|
|
21
|
-
* POST
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* POST /api/repos/:owner_id/:repo_name/tags/create/ { name, commit_sha, message, annotated, tagger_name, tagger_email }
|
|
22
|
+
* DELETE /api/repos/:owner_id/:repo_name/tags/:name/
|
|
23
|
+
* (tag list is local-only — the CLI never GETs /tags/)
|
|
24
24
|
*
|
|
25
25
|
* ============================================================================
|
|
26
26
|
*/
|
|
@@ -181,8 +181,15 @@ async function syncTagCreate(name, tagObj, taggerName, taggerEmail, gentPath) {
|
|
|
181
181
|
await apiClient.post(url, payload);
|
|
182
182
|
console.log(chalk.gray(` ↑ Synced to remote`));
|
|
183
183
|
} catch (error) {
|
|
184
|
+
// Duplicates now upsert (200) on the backend, so a 400 is a real failure
|
|
185
|
+
// — most often the tagged commit hasn't been pushed yet.
|
|
184
186
|
if (error.response?.status === 400) {
|
|
185
|
-
|
|
187
|
+
const data = error.response.data;
|
|
188
|
+
const msg = (data && data.error) || (typeof data === 'object' ? JSON.stringify(data) : data) || 'Bad request';
|
|
189
|
+
console.log(chalk.yellow(` ⚠ Remote sync failed: ${msg}`));
|
|
190
|
+
console.log(chalk.gray(` (has the tagged commit been pushed to the remote?)`));
|
|
191
|
+
} else if (error.response?.status === 403) {
|
|
192
|
+
console.log(chalk.yellow(` ⚠ Remote sync failed: ${error.response.data?.error || 'no write access to repository'}`));
|
|
186
193
|
}
|
|
187
194
|
}
|
|
188
195
|
}
|
|
@@ -208,6 +215,8 @@ async function syncTagDelete(name, gentPath) {
|
|
|
208
215
|
} catch (error) {
|
|
209
216
|
if (error.response?.status === 404) {
|
|
210
217
|
// Tag didn't exist remotely
|
|
218
|
+
} else if (error.response?.status === 403) {
|
|
219
|
+
console.log(chalk.yellow(` ⚠ Remote delete failed: ${error.response.data?.error || 'no write access to repository'}`));
|
|
211
220
|
}
|
|
212
221
|
}
|
|
213
222
|
}
|
package/src/index.js
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* Branching: branch, checkout, merge, resolve, stash
|
|
15
15
|
* Safety: undo, redo
|
|
16
16
|
* Insight: summary, ask, review, docs, changelog
|
|
17
|
-
* Remote: remote, repos, push, pull, search, web, share
|
|
18
|
-
* Auth: register, login, logout, whoami
|
|
17
|
+
* Remote: remote, repos, members, push, pull, search, web, share
|
|
18
|
+
* Auth: register, login, logout, whoami, password
|
|
19
19
|
* AI: ai (status|test|models)
|
|
20
20
|
* Templates: template (list|use)
|
|
21
21
|
*
|
|
@@ -50,6 +50,7 @@ const remoteCommand = require('./commands/remote');
|
|
|
50
50
|
const pushCommand = require('./commands/push');
|
|
51
51
|
const pullCommand = require('./commands/pull');
|
|
52
52
|
const reposCommand = require('./commands/repos');
|
|
53
|
+
const membersCommand = require('./commands/members');
|
|
53
54
|
const undoCommand = require('./commands/undo');
|
|
54
55
|
const resolveCommand = require('./commands/resolve');
|
|
55
56
|
const summaryCommand = require('./commands/summary');
|
|
@@ -60,6 +61,7 @@ const registerCommand = require('./commands/register');
|
|
|
60
61
|
const loginCommand = require('./commands/login');
|
|
61
62
|
const logoutCommand = require('./commands/logout');
|
|
62
63
|
const whoamiCommand = require('./commands/whoami');
|
|
64
|
+
const passwordCommand = require('./commands/password');
|
|
63
65
|
|
|
64
66
|
// Import new gent-platform commands
|
|
65
67
|
const configCommand = require('./commands/config');
|
|
@@ -241,6 +243,12 @@ program
|
|
|
241
243
|
.option('--default-branch <name>', 'Default branch name (with --create)')
|
|
242
244
|
.action(reposCommand);
|
|
243
245
|
|
|
246
|
+
program
|
|
247
|
+
.command('members [action] [email]')
|
|
248
|
+
.description('Manage repo collaborators (list | add <email> | remove <email>)')
|
|
249
|
+
.option('--role <role>', 'Role when adding a member: write or read', 'write')
|
|
250
|
+
.action(membersCommand);
|
|
251
|
+
|
|
244
252
|
program
|
|
245
253
|
.command('push [remote] [branch]')
|
|
246
254
|
.description('Push local commits to remote')
|
|
@@ -358,6 +366,12 @@ program
|
|
|
358
366
|
.description('Display current user information')
|
|
359
367
|
.action(whoamiCommand);
|
|
360
368
|
|
|
369
|
+
program
|
|
370
|
+
.command('password [action]')
|
|
371
|
+
.description('Change or reset your password (change | reset [email] | reset-confirm)')
|
|
372
|
+
.option('-e, --email <email>', 'Account email (for reset)')
|
|
373
|
+
.action(passwordCommand);
|
|
374
|
+
|
|
361
375
|
// Help command
|
|
362
376
|
program
|
|
363
377
|
.command('help [command]')
|
|
@@ -138,10 +138,9 @@ async function refreshToken() {
|
|
|
138
138
|
refresh: refreshToken
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
await authStorage.updateAccessToken(access);
|
|
141
|
+
// Backend rotates refresh tokens, so persist the new one too.
|
|
142
|
+
const { access, refresh } = response;
|
|
143
|
+
await authStorage.updateTokens(access, refresh);
|
|
145
144
|
|
|
146
145
|
return access;
|
|
147
146
|
} catch (error) {
|
package/src/utils/api-client.js
CHANGED
|
@@ -116,10 +116,11 @@ apiClient.interceptors.response.use(
|
|
|
116
116
|
{ refresh: refreshToken }
|
|
117
117
|
);
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
119
|
+
// Backend rotates refresh tokens (ROTATE_REFRESH_TOKENS +
|
|
120
|
+
// BLACKLIST_AFTER_ROTATION); persist the new refresh or the
|
|
121
|
+
// next silent refresh sends a blacklisted token and 401s.
|
|
122
|
+
const { access, refresh } = response.data;
|
|
123
|
+
await authStorage.updateTokens(access, refresh);
|
|
123
124
|
|
|
124
125
|
// Update authorization header
|
|
125
126
|
originalRequest.headers.Authorization = `Bearer ${access}`;
|
|
@@ -136,10 +136,13 @@ async function clearAuth() {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
|
-
* Update
|
|
139
|
+
* Update stored tokens after a refresh. The backend rotates refresh tokens
|
|
140
|
+
* (ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION), so the new refresh token
|
|
141
|
+
* MUST be persisted or the next refresh sends a blacklisted token and 401s.
|
|
140
142
|
* @param {string} newAccessToken - New access token
|
|
143
|
+
* @param {string} [newRefreshToken] - New (rotated) refresh token, if returned
|
|
141
144
|
*/
|
|
142
|
-
async function
|
|
145
|
+
async function updateTokens(newAccessToken, newRefreshToken) {
|
|
143
146
|
const authData = await readAuthData();
|
|
144
147
|
|
|
145
148
|
if (!authData) {
|
|
@@ -147,6 +150,9 @@ async function updateAccessToken(newAccessToken) {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
authData.accessToken = newAccessToken;
|
|
153
|
+
if (newRefreshToken) {
|
|
154
|
+
authData.refreshToken = newRefreshToken;
|
|
155
|
+
}
|
|
150
156
|
authData.timestamp = new Date().toISOString();
|
|
151
157
|
|
|
152
158
|
const authFilePath = getAuthFilePath();
|
|
@@ -158,6 +164,11 @@ async function updateAccessToken(newAccessToken) {
|
|
|
158
164
|
await fs.writeFile(authFilePath, JSON.stringify({ data: encryptedData }), 'utf8');
|
|
159
165
|
}
|
|
160
166
|
|
|
167
|
+
// Back-compat alias: same as updateTokens with no rotated refresh.
|
|
168
|
+
async function updateAccessToken(newAccessToken) {
|
|
169
|
+
return updateTokens(newAccessToken);
|
|
170
|
+
}
|
|
171
|
+
|
|
161
172
|
module.exports = {
|
|
162
173
|
saveTokens,
|
|
163
174
|
getAccessToken,
|
|
@@ -165,5 +176,6 @@ module.exports = {
|
|
|
165
176
|
getUser,
|
|
166
177
|
isAuthenticated,
|
|
167
178
|
clearAuth,
|
|
168
|
-
updateAccessToken
|
|
179
|
+
updateAccessToken,
|
|
180
|
+
updateTokens
|
|
169
181
|
};
|
package/src/utils/constants.js
CHANGED
|
@@ -23,6 +23,9 @@ module.exports = {
|
|
|
23
23
|
LOGOUT: '/api/auth/logout/',
|
|
24
24
|
REFRESH: '/api/auth/token/refresh/',
|
|
25
25
|
PROFILE: '/api/auth/profile/',
|
|
26
|
+
PASSWORD_CHANGE: '/api/auth/password/change/',
|
|
27
|
+
PASSWORD_RESET: '/api/auth/password/reset/',
|
|
28
|
+
PASSWORD_RESET_CONFIRM: '/api/auth/password/reset/confirm/',
|
|
26
29
|
|
|
27
30
|
// Repository management
|
|
28
31
|
REPOS: '/api/repos/',
|
|
@@ -30,9 +33,13 @@ module.exports = {
|
|
|
30
33
|
// Template: /api/repos/{owner_id}/{repo_name}/
|
|
31
34
|
REPO_DETAIL: '/api/repos/{owner_id}/{repo_name}/',
|
|
32
35
|
REPO_DELETE: '/api/repos/{owner_id}/{repo_name}/delete/',
|
|
36
|
+
REPO_MEMBERS: '/api/repos/{owner_id}/{repo_name}/members/',
|
|
37
|
+
REPO_MEMBER_DETAIL: '/api/repos/{owner_id}/{repo_name}/members/{user_id}/',
|
|
33
38
|
|
|
34
|
-
// Push
|
|
39
|
+
// Push / Pull / Clone
|
|
35
40
|
REPO_PUSH: '/api/repos/{owner_id}/{repo_name}/push/',
|
|
41
|
+
REPO_PULL: '/api/repos/{owner_id}/{repo_name}/pull/',
|
|
42
|
+
REPO_CLONE: '/api/repos/{owner_id}/{repo_name}/clone/',
|
|
36
43
|
|
|
37
44
|
// Branches
|
|
38
45
|
REPO_BRANCHES: '/api/repos/{owner_id}/{repo_name}/branches/',
|