gent-cli 8.0.0 → 9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "8.0.0",
3
+ "version": "9.1.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": {
@@ -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
  }
@@ -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 (client-side, no /clone/ endpoint):
14
+ * ALGORITHM:
15
15
  * 1. Parse URL to get owner_id + repo_name
16
- * 2. GET repo details name, description, default_branch
17
- * 3. GET branches list all branch names + SHAs
18
- * 4. GET commits list all commits
19
- * 5. For each commit, fetch tree + blobs
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, decodeRemoteBlobContent } = require('../utils/hash-engine');
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
- // 1. Get repo details
69
- spinner.text = 'Fetching repository info...';
70
- const repoDetail = await apiClient.get(
71
- buildRepoUrl(API_ENDPOINTS.REPO_DETAIL, repoInfo)
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 = repoDetail.name || repoInfo.repo_name;
75
- const defaultBranch = repoDetail.default_branch || 'main';
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
- // 5. For each commit, fetch tree and blobs
129
- const localCommits = [];
92
+ // Store blob objects (base64) into the local object store.
93
+ spinner.text = 'Storing objects...';
130
94
  let objectCount = 0;
131
-
132
- for (let i = 0; i < remoteCommits.length; i++) {
133
- const commit = remoteCommits[i];
134
- spinner.text = `Fetching objects (${i + 1}/${remoteCommits.length})...`;
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
- // Build branches map
187
- const branches = {};
188
- for (const b of remoteBranches) {
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
- const repoData = {
106
+ await writeJSON(path.join(gentPath, COMMITS_FILE), {
209
107
  commits: localCommits,
210
108
  branches,
211
109
  currentBranch: defaultBranch,
212
- tags: tagsMap
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: repoDetail.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, entry.name || entry.path);
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 {
@@ -89,6 +89,9 @@ async function commit(options) {
89
89
  }
90
90
  }
91
91
 
92
+ // ponytail: accounts with no name set fall back to email as identity
93
+ if (!authorName && authorEmail) authorName = authorEmail;
94
+
92
95
  if (!authorName || !authorEmail) {
93
96
  spinner.stop();
94
97
  console.error(chalk.red('Author identity unknown'));
@@ -91,9 +91,10 @@ async function explain(ref, options = {}) {
91
91
  );
92
92
  void stagedTree;
93
93
  } else {
94
- const targetHash = ref
95
- ? (commits.find(c => c.hash === ref || c.hash.startsWith(ref)) || {}).hash
96
- : repository.branches[repository.currentBranch];
94
+ const headHash = repository.branches[repository.currentBranch];
95
+ const targetHash = (!ref || ref === 'HEAD')
96
+ ? headHash
97
+ : (commits.find(c => c.hash === ref || c.hash.startsWith(ref)) || {}).hash;
97
98
  const commit = targetHash ? commitMap.get(targetHash) : null;
98
99
  if (!commit) {
99
100
  console.log(chalk.yellow(ref ? `Commit '${ref}' not found` : 'No commits yet'));
@@ -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 EXPECTATIONS:
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;
@@ -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 (client-side, no /pull/ endpoint):
15
- * 1. GET .../branches/{branch}/get remote branch head SHA
16
- * 2. GET .../commits/ list all remote commits
17
- * 3. Diff local vs remote commits, find new ones
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, objectExists, readBlob, readBlobAsString, decodeRemoteBlobContent } = require('../utils/hash-engine');
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. Get remote branch info to find remote HEAD
77
- spinner.text = `Fetching branch info for ${branch}...`;
78
- let remoteHead;
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 branchInfo = await apiClient.get(
81
- buildRepoUrl(API_ENDPOINTS.REPO_BRANCH_DETAIL, { ...repoInfo, branch_name: branch })
82
- );
83
- remoteHead = branchInfo.commit_sha;
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
- if (!remoteHead || remoteHead === localHead) {
93
- spinner.succeed(chalk.green('Already up-to-date'));
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 (newRemoteCommits.length === 0) {
108
- // We have all commits but pointer is different — update ref
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
- // 4. For each new commit, fetch tree and blobs
117
- spinner.text = `Fetching ${newRemoteCommits.length} new commit(s) + objects...`;
118
-
119
- const fetchedCommits = [];
120
- for (const commit of newRemoteCommits) {
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
- // 5. Add new commits to local store (dedup)
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
- // 6. Merge strategy
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 {
@@ -95,9 +95,15 @@ async function push(remoteName, branchName, options) {
95
95
 
96
96
  // Determine which commits to push (since last pushed ref)
97
97
  config.remoteRefs = config.remoteRefs || {};
98
- const lastPushed = config.remoteRefs[`${remote}/${branch}`] || null;
98
+ // Everything reachable from any already-pushed ref of this remote is the
99
+ // boundary — a merge can pull in commits from a branch that was never
100
+ // pushed, so we can't bound by this branch's ref alone.
101
+ const remoteHave = Object.entries(config.remoteRefs)
102
+ .filter(([name]) => name.startsWith(`${remote}/`))
103
+ .map(([, sha]) => sha)
104
+ .filter(Boolean);
99
105
  const commits = repository.commits || [];
100
- const commitsToPush = getCommitsSince(commits, localHead, lastPushed);
106
+ const commitsToPush = getCommitsSince(commits, localHead, remoteHave);
101
107
 
102
108
  if (commitsToPush.length === 0) {
103
109
  spinner.succeed(chalk.green('Everything up-to-date'));
@@ -178,17 +184,39 @@ async function push(remoteName, branchName, options) {
178
184
  }
179
185
  }
180
186
 
181
- // Build commits for the pack
187
+ // Build commits for the pack. author_email must satisfy the backend's
188
+ // EmailField(required=True); fall back to the logged-in user's email so
189
+ // merge/legacy commits with a blank email don't 400 the whole push.
190
+ const fallbackEmail = (await authStorage.getUser())?.email || '';
182
191
  const packCommits = commitsToPush.map(c => ({
183
192
  sha: c.hash,
184
193
  message: c.message,
185
194
  tree_sha: c.treeHash || '',
186
195
  parent_shas: [c.parent, c.mergeParent].filter(Boolean),
187
- author_name: typeof c.author === 'object' ? (c.author.name || 'Unknown') : (c.author || 'Unknown'),
188
- author_email: typeof c.author === 'object' ? (c.author.email || '') : '',
196
+ author_name: (typeof c.author === 'object' ? c.author.name : c.author) || 'Unknown',
197
+ author_email: (typeof c.author === 'object' ? c.author.email : '') || fallbackEmail,
189
198
  committed_at: c.timestamp || new Date().toISOString()
190
199
  }));
191
200
 
201
+ // Only send tags whose target commit will exist on the remote after this
202
+ // push (in this pack, or reachable from an already-pushed remote ref).
203
+ // The backend validates every tag's commit and atomically 400s the whole
204
+ // push for any tag pointing at a commit it doesn't have.
205
+ const pushableShas = new Set(commitsToPush.map(c => c.hash));
206
+ const commitByHash = new Map(commits.map(c => [c.hash, c]));
207
+ for (const [refName, refHead] of Object.entries(config.remoteRefs || {})) {
208
+ if (!refName.startsWith(`${remote}/`)) continue;
209
+ let cur = refHead;
210
+ while (cur && !pushableShas.has(cur)) {
211
+ pushableShas.add(cur);
212
+ cur = commitByHash.get(cur)?.parent || null;
213
+ }
214
+ }
215
+ const tagsToPush = {};
216
+ for (const [name, tag] of Object.entries(repository.tags || {})) {
217
+ if (tag && tag.hash && pushableShas.has(tag.hash)) tagsToPush[name] = tag;
218
+ }
219
+
192
220
  // Build push payload matching PushPackRequest schema
193
221
  const payload = {
194
222
  pack: {
@@ -200,7 +228,7 @@ async function push(remoteName, branchName, options) {
200
228
  name: branch,
201
229
  commit_sha: localHead
202
230
  }],
203
- tags: repository.tags || {}
231
+ tags: tagsToPush
204
232
  };
205
233
 
206
234
  // Send to backend
@@ -224,7 +252,7 @@ async function push(remoteName, branchName, options) {
224
252
  } else if (error.response?.status === 401) {
225
253
  console.error(chalk.red('Authentication failed — run "gent login"'));
226
254
  } else if (error.response?.status === 403) {
227
- console.error(chalk.red('Permission denied — only repo owner can push'));
255
+ console.error(chalk.red(error.response.data?.error || error.response.data?.detail || 'Permission denied — you need write access to this repository'));
228
256
  } else if (error.response?.data) {
229
257
  console.error(chalk.red(JSON.stringify(error.response.data, null, 2)));
230
258
  } else {
@@ -235,25 +263,44 @@ async function push(remoteName, branchName, options) {
235
263
  }
236
264
 
237
265
  /**
238
- * Get commits from tip back to (but excluding) stopHash.
266
+ * Get commits reachable from tip (following BOTH parents, so merges are
267
+ * covered) that the remote doesn't already have. Post-order → parents precede
268
+ * children in the returned array.
239
269
  * @param {Array} allCommits
240
270
  * @param {String} tipHash
241
- * @param {String|null} stopHash
271
+ * @param {String[]} remoteHave - shas the remote already has (its ref tips)
242
272
  * @returns {Array}
243
273
  */
244
- function getCommitsSince(allCommits, tipHash, stopHash) {
274
+ function getCommitsSince(allCommits, tipHash, remoteHave) {
245
275
  const commitMap = new Map(allCommits.map(c => [c.hash, c]));
246
- const result = [];
247
- let current = tipHash;
248
276
 
249
- while (current && current !== stopHash) {
250
- const commit = commitMap.get(current);
251
- if (!commit) break;
252
- result.push(commit);
253
- current = commit.parent;
277
+ // Boundary = every commit reachable from a remote ref via both parents.
278
+ const have = new Set();
279
+ const stack = [...(remoteHave || [])].filter(Boolean);
280
+ while (stack.length) {
281
+ const sha = stack.pop();
282
+ if (!sha || have.has(sha)) continue;
283
+ const c = commitMap.get(sha);
284
+ if (!c) continue; // not local → treat as already-remote boundary
285
+ have.add(sha);
286
+ if (c.parent) stack.push(c.parent);
287
+ if (c.mergeParent) stack.push(c.mergeParent);
254
288
  }
255
289
 
256
- return result.reverse(); // oldest first
290
+ const result = [];
291
+ const visited = new Set();
292
+ const visit = (sha) => {
293
+ if (!sha || visited.has(sha) || have.has(sha)) return;
294
+ const c = commitMap.get(sha);
295
+ if (!c) return;
296
+ visited.add(sha);
297
+ visit(c.parent);
298
+ visit(c.mergeParent);
299
+ result.push(c); // after both parents → oldest first
300
+ };
301
+ visit(tipHash);
302
+
303
+ return result;
257
304
  }
258
305
 
259
306
  module.exports = push;
@@ -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.owner_name, r.owner_email]
44
+ const haystack = [r.name, r.description, r.owner_email]
45
45
  .filter(Boolean).join(' ').toLowerCase();
46
46
  return haystack.includes(q);
47
47
  });
@@ -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 EXPECTATIONS:
20
- * GET /api/repos/:id/commits/:hash/ returns commit object with tree
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
  */
@@ -18,9 +18,9 @@
18
18
  * Annotated tag = includes tagger info, message, timestamp.
19
19
  *
20
20
  * BACKEND EXPECTATIONS:
21
- * POST /api/repos/:id/tags/ { name, hash, message, annotated }
22
- * GET /api/repos/:id/tags/
23
- * DELETE /api/repos/:id/tags/:name/
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
- console.log(chalk.gray(` ⚠ Remote sync skipped (tag may already exist remotely)`));
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
- const { access } = response;
142
-
143
- // Update only access token
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) {
@@ -116,10 +116,11 @@ apiClient.interceptors.response.use(
116
116
  { refresh: refreshToken }
117
117
  );
118
118
 
119
- const { access } = response.data;
120
-
121
- // Update stored access token
122
- await authStorage.updateAccessToken(access);
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 only the access token (used after refresh)
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 updateAccessToken(newAccessToken) {
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
  };
@@ -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/',