gent-cli 14.0.0 → 20.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.
@@ -1,19 +1,24 @@
1
1
  /**
2
2
  * Share Command - Print a shareable link to current branch/commit.
3
3
  *
4
- * gent share → link to current HEAD on current branch
5
- * gent share --branch <name> → link to a branch's tip
6
- * gent share --commit <hash> → link to a specific commit
4
+ * gent share → link to the repository
5
+ * gent share --branch <name> → same link, branch reported as context
6
+ * gent share --commit <hash> → same link, commit validated and reported
7
7
  *
8
- * Like `gent web --print` but always commit-scoped by default handy for
9
- * Slack/PR descriptions.
8
+ * Like `gent web --print` handy for Slack/PR descriptions. stdout carries
9
+ * only the URL so `gent share | pbcopy` gives a clean link; all context goes
10
+ * to stderr.
11
+ *
12
+ * NOTE: the web app has no branch or commit page today, so every link resolves
13
+ * to the repository page; the branch/commit is reported as context only.
14
+ * See utils/web-urls.js.
10
15
  */
11
16
 
12
17
  const path = require('path');
13
18
  const chalk = require('chalk');
14
19
  const { getGentPath, readJSON } = require('../utils/fileSystem');
15
20
  const { CONFIG_FILE, COMMITS_FILE, parseRemoteUrl } = require('../utils/constants');
16
- const userConfig = require('../utils/user-config');
21
+ const { getWebBaseUrl, buildRepoLink, BRANCH_COMMIT_UNSUPPORTED } = require('../utils/web-urls');
17
22
 
18
23
  async function share(options = {}) {
19
24
  try {
@@ -33,22 +38,42 @@ async function share(options = {}) {
33
38
  process.exit(1);
34
39
  }
35
40
 
36
- const { value: baseUrl } = await userConfig.getResolved('api.base_url');
37
- const webHost = baseUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
38
- const repoBase = `${webHost}/${info.owner_id}/${info.repo_name}`;
41
+ const baseUrl = await getWebBaseUrl();
42
+ const { url, unsupported } = buildRepoLink(baseUrl, info, {
43
+ branch: options.branch,
44
+ commit: options.commit,
45
+ });
46
+
47
+ // A --commit the repo doesn't know about is a typo, not a link: refuse
48
+ // rather than echo a fabricated reference into a PR description.
49
+ if (options.commit) {
50
+ const known = (repository.commits || [])
51
+ .some((c) => c && typeof c.hash === 'string' && c.hash.startsWith(options.commit));
52
+ if (!known) {
53
+ console.error(chalk.red(`Unknown commit '${options.commit}' in this repository.`));
54
+ process.exit(1);
55
+ }
56
+ }
57
+
58
+ // Only warn when the user actually asked for a link we can't build.
59
+ if (unsupported) {
60
+ console.error(chalk.gray(BRANCH_COMMIT_UNSUPPORTED));
61
+ }
62
+
63
+ // stdout carries the URL and nothing else, so `gent share | pbcopy`
64
+ // yields a clean link. All human context goes to stderr.
65
+ console.log(url);
39
66
 
40
67
  if (options.commit) {
41
- console.log(`${repoBase}/commit/${options.commit}`);
68
+ console.error(chalk.gray(`(commit ${String(options.commit).slice(0, 7)})`));
42
69
  return;
43
70
  }
44
71
  const branch = options.branch || repository.currentBranch;
45
- const tip = repository.branches[branch];
72
+ const tip = (repository.branches || {})[branch];
46
73
  if (tip) {
47
- console.log(`${repoBase}/commit/${tip}`);
48
- console.log(chalk.gray(`(${branch} @ ${tip.slice(0, 7)})`));
74
+ console.error(chalk.gray(`(${branch} @ ${tip.slice(0, 7)})`));
49
75
  } else {
50
- console.log(`${repoBase}/tree/${encodeURIComponent(branch)}`);
51
- console.log(chalk.gray(`(${branch} has no commits yet)`));
76
+ console.error(chalk.gray(`(${branch} has no commits yet)`));
52
77
  }
53
78
  } catch (error) {
54
79
  if (error.code === 'ENOENT' && error.message.includes('.gent')) {
@@ -2,19 +2,23 @@
2
2
  * Web Command - Open the current repo (or a specific commit/branch) in browser.
3
3
  *
4
4
  * gent web → open repo page
5
- * gent web --branch <name> → open a specific branch
6
- * gent web --commit <hash> → open a specific commit
5
+ * gent web --branch <name> → repo page (branch context only — see NOTE)
6
+ * gent web --commit <hash> → repo page (commit context only — see NOTE)
7
7
  * gent web --print → don't launch, just print the URL
8
8
  *
9
- * Builds the URL from the configured api.base_url and the remote's owner_id/repo_name.
9
+ * Builds the URL from the configured web.base_url (NOT api.base_url the web
10
+ * app is a separate deployment) and the remote's owner_id/repo_name.
11
+ *
12
+ * NOTE: the web app has no branch or commit page today, so --branch/--commit
13
+ * warn and fall back to the repository page. See utils/web-urls.js.
10
14
  */
11
15
 
12
16
  const path = require('path');
13
- const { exec } = require('child_process');
17
+ const { execFile } = require('child_process');
14
18
  const chalk = require('chalk');
15
19
  const { getGentPath, readJSON } = require('../utils/fileSystem');
16
20
  const { CONFIG_FILE, parseRemoteUrl } = require('../utils/constants');
17
- const userConfig = require('../utils/user-config');
21
+ const { getWebBaseUrl, buildRepoLink, BRANCH_COMMIT_UNSUPPORTED } = require('../utils/web-urls');
18
22
 
19
23
  async function web(options = {}) {
20
24
  try {
@@ -32,13 +36,16 @@ async function web(options = {}) {
32
36
  process.exit(1);
33
37
  }
34
38
 
35
- const { value: baseUrl } = await userConfig.getResolved('api.base_url');
36
- // Strip /api suffix if present so we get the web host
37
- const webHost = baseUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
39
+ const baseUrl = await getWebBaseUrl();
40
+ const { url, unsupported } = buildRepoLink(baseUrl, info, {
41
+ branch: options.branch,
42
+ commit: options.commit,
43
+ });
38
44
 
39
- let url = `${webHost}/${info.owner_id}/${info.repo_name}`;
40
- if (options.branch) url += `/tree/${encodeURIComponent(options.branch)}`;
41
- if (options.commit) url += `/commit/${encodeURIComponent(options.commit)}`;
45
+ if (unsupported) {
46
+ console.error(chalk.yellow(`Note: --${unsupported} is not supported yet.`));
47
+ console.error(chalk.gray(BRANCH_COMMIT_UNSUPPORTED));
48
+ }
42
49
 
43
50
  if (options.print) {
44
51
  console.log(url);
@@ -57,11 +64,19 @@ async function web(options = {}) {
57
64
  }
58
65
  }
59
66
 
67
+ /**
68
+ * Hand the URL to the platform's browser launcher.
69
+ *
70
+ * Uses execFile, NOT exec: the URL is passed as its own argv entry so no shell
71
+ * ever parses it. Interpolating it into a shell string made a `"` in
72
+ * web.base_url a command-injection sink, and broke any legitimate URL
73
+ * containing & or $.
74
+ */
60
75
  function openInBrowser(url) {
61
- const cmd = process.platform === 'darwin' ? 'open'
62
- : process.platform === 'win32' ? 'start ""'
63
- : 'xdg-open';
64
- exec(`${cmd} "${url}"`, (err) => {
76
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
77
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
78
+ : ['xdg-open', [url]];
79
+ execFile(cmd, args, (err) => {
65
80
  if (err) {
66
81
  console.error(chalk.yellow('Could not auto-open. URL:'));
67
82
  console.log(url);
package/src/index.js CHANGED
@@ -30,33 +30,34 @@ const { program } = require('commander');
30
30
  const chalk = require('chalk');
31
31
  const packageJson = require('../package.json');
32
32
  const interactive = require('./utils/interactive');
33
+ const { route } = require('./commands/canonical');
33
34
 
34
35
  // Import core commands
35
- const autoCommand = require('./commands/auto');
36
- const initCommand = require('./commands/init');
36
+ const autoCommand = route('auto', require('./commands/auto'));
37
+ const initCommand = route('init', require('./commands/init'));
37
38
  const cloneCommand = require('./commands/clone');
38
- const statusCommand = require('./commands/status');
39
- const addCommand = require('./commands/add');
40
- const rmCommand = require('./commands/rm');
41
- const resetCommand = require('./commands/reset');
42
- const diffCommand = require('./commands/diff');
43
- const commitCommand = require('./commands/commit');
44
- const logCommand = require('./commands/log');
45
- const showCommand = require('./commands/show');
46
- const tagCommand = require('./commands/tag');
47
- const branchCommand = require('./commands/branch');
48
- const checkoutCommand = require('./commands/checkout');
49
- const mergeCommand = require('./commands/merge');
50
- const stashCommand = require('./commands/stash');
51
- const remoteCommand = require('./commands/remote');
52
- const pushCommand = require('./commands/push');
53
- const pullCommand = require('./commands/pull');
39
+ const statusCommand = route('status', require('./commands/status'));
40
+ const addCommand = route('add', require('./commands/add'));
41
+ const rmCommand = route('rm', require('./commands/rm'));
42
+ const resetCommand = route('reset', require('./commands/reset'));
43
+ const diffCommand = route('diff', require('./commands/diff'));
44
+ const commitCommand = route('commit', require('./commands/commit'));
45
+ const logCommand = route('log', require('./commands/log'));
46
+ const showCommand = route('show', require('./commands/show'));
47
+ const tagCommand = route('tag', require('./commands/tag'));
48
+ const branchCommand = route('branch', require('./commands/branch'));
49
+ const checkoutCommand = route('checkout', require('./commands/checkout'));
50
+ const mergeCommand = route('merge', require('./commands/merge'));
51
+ const stashCommand = route('stash', require('./commands/stash'));
52
+ const remoteCommand = route('remote', require('./commands/remote'));
53
+ const pushCommand = route('push', require('./commands/push'));
54
+ const pullCommand = route('pull', require('./commands/pull'));
54
55
  const reposCommand = require('./commands/repos');
55
56
  const membersCommand = require('./commands/members');
56
- const undoCommand = require('./commands/undo');
57
- const resolveCommand = require('./commands/resolve');
58
- const summaryCommand = require('./commands/summary');
59
- const explainCommand = require('./commands/explain');
57
+ const undoCommand = route('undo', require('./commands/undo'));
58
+ const resolveCommand = route('resolve', require('./commands/resolve'));
59
+ const summaryCommand = route('summary', require('./commands/summary'));
60
+ const explainCommand = route('explain', require('./commands/explain'));
60
61
 
61
62
  // Import auth commands
62
63
  const registerCommand = require('./commands/register');
@@ -66,16 +67,16 @@ const whoamiCommand = require('./commands/whoami');
66
67
  const passwordCommand = require('./commands/password');
67
68
 
68
69
  // Import new gent-platform commands
69
- const configCommand = require('./commands/config');
70
+ const configCommand = route('config', require('./commands/config'));
70
71
  const doctorCommand = require('./commands/doctor');
71
72
  const setupCommand = require('./commands/setup');
72
73
  const aiCommand = require('./commands/ai');
73
- const askCommand = require('./commands/ask');
74
- const reviewCommand = require('./commands/review');
75
- const docsCommand = require('./commands/docs');
76
- const changelogCommand = require('./commands/changelog');
74
+ const askCommand = route('ask', require('./commands/ask'));
75
+ const reviewCommand = route('review', require('./commands/review'));
76
+ const docsCommand = route('docs', require('./commands/docs'));
77
+ const changelogCommand = route('changelog', require('./commands/changelog'));
77
78
  const webCommand = require('./commands/web');
78
- const shareCommand = require('./commands/share');
79
+ const shareCommand = route('share', require('./commands/share'));
79
80
  const searchCommand = require('./commands/search');
80
81
  const templateCommand = require('./commands/template');
81
82
  const petCommand = require('./commands/pet');
@@ -97,9 +98,21 @@ program
97
98
  .command('init')
98
99
  .description('Initialize a new gent repository')
99
100
  .option('-y, --yes', 'Skip prompts and use defaults')
101
+ .option('--object-format <format>', 'Opt into the canonical SHA-256 engine')
100
102
  .option('--remote [name]', 'Create a remote repository on the backend')
101
103
  .action(initCommand);
102
104
 
105
+ program
106
+ .command('migrate')
107
+ .description('Migrate an offline legacy repository with a verified backup')
108
+ .option('--dry-run', 'Inspect and validate without changing repository state')
109
+ .option('--continue', 'Complete an interrupted migration')
110
+ .option('--abort', 'Roll back an interrupted migration if no newer state exists')
111
+ .action(async options => {
112
+ try { console.log(JSON.stringify(await require('./utils/migrate').migrate(process.cwd(), options), null, 2)); }
113
+ catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 1; }
114
+ });
115
+
103
116
  program
104
117
  .command('clone [url] [directory]')
105
118
  .description('Clone a remote repository')
@@ -111,6 +124,11 @@ program
111
124
  console.error(chalk.red('error: missing url — usage: gent clone <url> [directory]'));
112
125
  process.exit(1);
113
126
  }
127
+ if (/^https?:/.test(url) && /\.git\/?$/.test(url)) {
128
+ try { console.log(`Cloned to ${await require('./utils/smart-http').clone(url, directory)}`); }
129
+ catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 1; }
130
+ return;
131
+ }
114
132
  return cloneCommand(url, directory, options);
115
133
  });
116
134
 
@@ -152,8 +170,8 @@ program
152
170
  program
153
171
  .command('reset [files...]')
154
172
  .description('Unstage files or reset HEAD to a commit')
155
- .option('--hard <hash>', 'Reset HEAD and working tree to commit')
156
- .option('--soft <hash>', 'Reset HEAD but keep staging')
173
+ .option('--hard [hash]', 'Reset HEAD and working tree to commit')
174
+ .option('--soft [hash]', 'Reset HEAD but keep staging')
157
175
  .action(resetCommand);
158
176
 
159
177
  program
@@ -221,13 +239,15 @@ program
221
239
  .command('checkout [branch]')
222
240
  .description('Switch branches or restore working tree files')
223
241
  .option('-b, --create', 'Create a new branch')
242
+ .option('--abort', 'Recover an interrupted canonical checkout')
243
+ .option('-f, --force', 'Discard local changes when switching')
224
244
  .action(async (branch, options) => {
225
- if (!branch && interactive.isInteractive()) {
245
+ if (!branch && !options.abort && interactive.isInteractive()) {
226
246
  const picked = await interactive.promptCheckout();
227
247
  branch = picked.branch;
228
248
  options.create = picked.create; // picker decides: existing branch ⇒ switch, not create
229
249
  }
230
- if (!branch) {
250
+ if (!branch && !options.abort) {
231
251
  console.error(chalk.red('error: missing branch — usage: gent checkout <branch>'));
232
252
  process.exit(1);
233
253
  }
@@ -238,8 +258,10 @@ program
238
258
  .command('merge [branch]')
239
259
  .description('Merge a branch into the current branch (3-way smart merge)')
240
260
  .option('-m, --message <message>', 'Merge commit message')
261
+ .option('--continue', 'Finish a resolved canonical merge')
262
+ .option('--abort', 'Abort a canonical merge')
241
263
  .action(async (branch, options) => {
242
- if (!branch && interactive.isInteractive()) {
264
+ if (!branch && !options.continue && !options.abort && interactive.isInteractive()) {
243
265
  const picked = await interactive.promptMerge();
244
266
  if (!picked.branch) {
245
267
  // In a repo with only one branch → nothing to merge; otherwise
@@ -252,7 +274,7 @@ program
252
274
  branch = picked.branch;
253
275
  }
254
276
  }
255
- if (!branch && !interactive.isInteractive()) {
277
+ if (!branch && !options.continue && !options.abort && !interactive.isInteractive()) {
256
278
  console.error(chalk.red('error: missing branch — usage: gent merge <branch>'));
257
279
  process.exit(1);
258
280
  }
@@ -268,6 +290,9 @@ program
268
290
  .command('stash [subcommand]')
269
291
  .description('Stash working tree changes (pop|list|drop|apply)')
270
292
  .option('-m, --message <message>', 'Stash message')
293
+ .option('--restore-index', 'Restore canonical stash staged state')
294
+ .option('--keep-index', 'Keep staged changes when stashing')
295
+ .option('-u, --include-untracked', 'Include untracked files')
271
296
  .option('-i, --index <index>', 'Stash index for pop/apply/drop')
272
297
  .action(stashCommand);
273
298
 
@@ -311,6 +336,11 @@ program
311
336
  .option('-f, --force', 'Force push (overwrite remote)')
312
337
  .action(pushCommand);
313
338
 
339
+ program
340
+ .command('fetch [remote]')
341
+ .description('Fetch canonical remote branches and tags')
342
+ .action(route('fetch', async () => { throw new Error('fetch requires a canonical repository'); }));
343
+
314
344
  program
315
345
  .command('pull [remote] [branch]')
316
346
  .description('Pull and merge remote commits')
@@ -373,17 +403,17 @@ program
373
403
 
374
404
  program
375
405
  .command('web')
376
- .description('Open the current repo (or a branch/commit) on the gent web app')
377
- .option('--branch <name>', 'Open a specific branch')
378
- .option('--commit <hash>', 'Open a specific commit')
406
+ .description('Open the current repo on the gent web app')
407
+ .option('--branch <name>', 'Branch context (opens the repo page — no branch page yet)')
408
+ .option('--commit <hash>', 'Commit context (opens the repo page — no commit page yet)')
379
409
  .option('--print', 'Print the URL instead of launching a browser')
380
410
  .action(webCommand);
381
411
 
382
412
  program
383
413
  .command('share')
384
- .description('Print a shareable link to current branch tip (or --branch/--commit)')
385
- .option('--branch <name>', 'Link to a specific branch')
386
- .option('--commit <hash>', 'Link to a specific commit')
414
+ .description('Print a shareable link to the current repository')
415
+ .option('--branch <name>', 'Branch context (link resolves to the repo page)')
416
+ .option('--commit <hash>', 'Commit context (link resolves to the repo page)')
387
417
  .action(shareCommand);
388
418
 
389
419
  program
@@ -80,8 +80,13 @@ apiClient.interceptors.response.use(
80
80
  async (error) => {
81
81
  const originalRequest = error.config;
82
82
 
83
- // If error is 401 and we haven't tried to refresh yet
83
+ // A public request made without credentials should keep its original
84
+ // 401 response. Commands such as `gent clone` use that response to
85
+ // explain that a private repository needs sign-in.
84
86
  if (error.response?.status === 401 && !originalRequest._retry) {
87
+ const refreshToken = await authStorage.getRefreshToken();
88
+ if (!refreshToken) return Promise.reject(error);
89
+
85
90
  if (isRefreshing) {
86
91
  // If already refreshing, queue this request
87
92
  return new Promise((resolve, reject) => {
@@ -100,14 +105,6 @@ apiClient.interceptors.response.use(
100
105
  isRefreshing = true;
101
106
 
102
107
  try {
103
- const refreshToken = await authStorage.getRefreshToken();
104
-
105
- if (!refreshToken) {
106
- // No refresh token, user needs to login again
107
- await authStorage.clearAuth();
108
- throw new Error('Session expired. Please login again.');
109
- }
110
-
111
108
  // Call refresh endpoint (raw axios — bypasses our interceptor
112
109
  // intentionally so a 401 here doesn't loop back into refresh).
113
110
  const baseUrl = await resolveBaseUrl();