gent-cli 15.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.
package/README.md CHANGED
@@ -25,7 +25,7 @@ Beyond a faithful git-like workflow, Gent adds:
25
25
  - **`gent resolve`** — an interactive conflict resolver (ours / theirs / both / edit / AI).
26
26
  - **`gent summary`** — a repository health dashboard, plus **`gent log --graph`**.
27
27
  - **Optional AI** (`gent commit --ai`, `gent explain`, `gent summary --ai`, AI option in `gent resolve`) — off by default, enabled with `ANTHROPIC_API_KEY`.
28
- - **Genti, your terminal mascot** — a chunky pixel bot that *acts out* your workflow: it carries a file crate to the cloud on `gent push`, walks one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
28
+ - **Genti, your terminal mascot** — a mint one-eyed sky-jelly that *acts out* your workflow: it floats a file crate to the cloud on `gent push`, carries one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
29
29
 
30
30
  See [docs/COMMANDS.md](docs/COMMANDS.md) for the full reference and
31
31
  [docs/ALGORITHMS.md](docs/ALGORITHMS.md) for how the engines work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "15.0.0",
3
+ "version": "20.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": {
@@ -8,14 +8,17 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js && node tests/offline-e2e.js",
12
- "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js",
11
+ "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js && node tests/offline-e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
12
+ "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js",
13
13
  "test:e2e": "node tests/offline-e2e.js",
14
14
  "test:remote:e2e": "node tests/remote-e2e.js",
15
15
  "demo": "bash demo.sh",
16
16
  "link": "npm link",
17
17
  "unlink": "npm unlink",
18
- "prepublishOnly": "echo 'Ready to publish gent-cli!'"
18
+ "prepublishOnly": "echo 'Ready to publish gent-cli!'",
19
+ "test:canonical": "node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
20
+ "test:independence": "node tests/git-compat/independence.js",
21
+ "test:interop": "node --test tests/interop/canonical.test.js"
19
22
  },
20
23
  "keywords": [
21
24
  "cli",
@@ -0,0 +1,242 @@
1
+ /** Canonical command adapters. Legacy repositories retain their v12 handlers
2
+ * until migration ships; no canonical command falls back to a legacy writer.
3
+ */
4
+ const fs = require('fs').promises;
5
+ const path = require('path');
6
+ const repository = require('../utils/repository');
7
+ const ops = require('../utils/gent-ops');
8
+ const merge = require('../utils/merge-ops');
9
+ const stash = require('../utils/stash-ops');
10
+ const journal = require('../utils/canonical-journal');
11
+ const worktree = require('../utils/worktree');
12
+ const { GitIndex } = require('../utils/git-index');
13
+ const { Lock } = require('../utils/lockfile');
14
+ const { AttributesMatcher, looksBinary } = require('../utils/attributes');
15
+ const { formatUnifiedDiff } = require('../utils/diff-engine');
16
+
17
+ async function locatedCanonical() {
18
+ let found;
19
+ try { found = await repository.findGitdir(); } catch (error) {
20
+ if (error.code === 'GENT_NOT_A_REPOSITORY') return null;
21
+ throw error;
22
+ }
23
+ if (await repository.isLegacyRepository(found.commondir)) return null;
24
+ return repository.open();
25
+ }
26
+
27
+ function route(name, legacy) {
28
+ const handler = async (...args) => {
29
+ try {
30
+ for (let dir = process.cwd(); ; dir = path.dirname(dir)) {
31
+ if (await fs.access(path.join(dir, '.gent-migration.json')).then(() => true, () => false)) throw new Error('interrupted migration; use gent migrate --continue or --abort');
32
+ if (path.dirname(dir) === dir) break;
33
+ }
34
+ if (name === 'init') {
35
+ const options = args[0] || {};
36
+ if (!options.objectFormat) {
37
+ const existing = await locatedCanonical();
38
+ if (!existing) return legacy(...args);
39
+ console.log(`Repository already initialized: ${existing.gitdir}`);
40
+ return;
41
+ }
42
+ if (options.objectFormat !== 'sha256') throw new Error('only --object-format=sha256 is supported');
43
+ if (options.remote) throw new Error('canonical remote creation is not implemented yet');
44
+ const result = await repository.init(process.cwd());
45
+ console.log(`Initialized SHA-256 repository: ${result.gitdir}`);
46
+ return;
47
+ }
48
+ // CLI-global settings remain available inside canonical repositories.
49
+ if (name === 'config' && args[1]?.[0] && !/^(user\.|core\.|remote\.|branch\.)/i.test(args[1][0])) {
50
+ return legacy(...args);
51
+ }
52
+ const repo = await locatedCanonical();
53
+ if (!repo) return legacy(...args);
54
+ if (!handlers[name]) throw new Error(`gent ${name} is not implemented for canonical repositories yet`);
55
+ const readOnly = ['status', 'log', 'show', 'diff', 'summary'].includes(name);
56
+ let lock;
57
+ try {
58
+ if (!readOnly) {
59
+ lock = await Lock.acquire(path.join(repo.gentWorktreeMetaDir, 'operation'));
60
+ await repo.assertNoExternalOperation(`gent ${name}`);
61
+ if (!(name === 'checkout' && args[1]?.abort)) await worktree.assertNoPendingCheckout(repo, `gent ${name}`);
62
+ }
63
+ const checkpointed = ['commit', 'checkout', 'reset', 'merge'].includes(name) && !args[1]?.abort && !args[0]?.abort;
64
+ const checkpoint = checkpointed ? await journal.begin(repo, name) : null;
65
+ const result = await handlers[name](repo, ...args);
66
+ if (checkpoint) await journal.finish(repo, checkpoint);
67
+ return result;
68
+ } finally { if (lock) await lock.release(); }
69
+ } catch (error) {
70
+ console.error(`Error: ${error.message}`);
71
+ process.exitCode = 1;
72
+ }
73
+ };
74
+ if (legacy.redo) handler.redo = route('redo', legacy.redo);
75
+ return handler;
76
+ }
77
+
78
+ const transport = require('../utils/smart-http');
79
+ const handlers = {
80
+ async remote(repo, sub, args = []) {
81
+ const [name = 'origin', url] = args;
82
+ transport.nameCheck(name);
83
+ if (sub === 'add' || sub === 'set-url') {
84
+ if (!url) throw new Error('provide a remote URL');
85
+ if (sub === 'add' && repo.config.get(`remote.${name}.url`)) throw new Error('remote already exists');
86
+ repo.localConfig.set(`remote.${name}.url`, transport.remoteUrl(url));
87
+ repo.localConfig.set(`remote.${name}.fetch`, `+refs/heads/*:refs/remotes/${name}/*`);
88
+ await repo.localConfig.save();
89
+ } else if (sub === 'remove') {
90
+ repo.localConfig.unset(`remote.${name}.url`);
91
+ repo.localConfig.unset(`remote.${name}.fetch`);
92
+ await repo.localConfig.save();
93
+ } else if (!sub) console.log(transport.configured(repo, name));
94
+ else throw new Error('use remote add|set-url|remove');
95
+ },
96
+ async fetch(repo, remote = 'origin') { await transport.fetch(repo, remote); console.log(`Fetched ${remote}`); },
97
+ async push(repo, remote = 'origin', branch, options = {}) {
98
+ await transport.push(repo, remote, branch, options); console.log('Push complete');
99
+ },
100
+ async pull(repo, remote = 'origin', branch) {
101
+ branch ||= (await repo.refs.head()).branch;
102
+ if (!branch) throw new Error('specify a branch from detached HEAD');
103
+ await transport.fetch(repo, remote);
104
+ const result = await merge.merge(repo, `refs/remotes/${remote}/${branch}`);
105
+ console.log(result.status);
106
+ if (result.status === 'conflicts') process.exitCode = 1;
107
+ },
108
+ async undo(repo, options = {}) {
109
+ if (options.list) { for (const item of (await journal.read(repo)).undo.slice().reverse()) console.log(item.name); }
110
+ else console.log(`Undid ${await journal.restore(repo)}`);
111
+ },
112
+ async redo(repo) { console.log(`Redid ${await journal.restore(repo, true)}`); },
113
+ async status(repo) {
114
+ const state = await ops.status(repo);
115
+ console.log(`On ${state.head.branch || 'detached HEAD'}`);
116
+ for (const item of state.staged) console.log(`staged ${item.status}: ${item.path}`);
117
+ for (const item of state.unstaged) console.log(`unstaged ${item.status}: ${item.path}`);
118
+ for (const name of state.conflicted) console.log(`conflict: ${name}`);
119
+ for (const name of state.untracked) console.log(`untracked: ${name}`);
120
+ if (!state.staged.length && !state.unstaged.length && !state.conflicted.length && !state.untracked.length) console.log('Working tree clean');
121
+ },
122
+ async add(repo, files, options) {
123
+ const result = await ops.addPaths(repo, files, options);
124
+ console.log(`Staged ${result.staged.length} change(s), ${result.removed.length} deletion(s)`);
125
+ },
126
+ async rm(repo, files, options) { await ops.removePaths(repo, files, options); },
127
+ async commit(repo, options) {
128
+ if (options.ai) throw new Error('AI commit messages are not connected to canonical repositories yet');
129
+ if (!options.message) throw new Error('provide a commit message with -m');
130
+ if (options.all) {
131
+ const index = await GitIndex.read(repo.indexPath);
132
+ const files = [...new Set(index.entries.map(e => path.join(repo.worktree, e.path)))];
133
+ if (files.length) await ops.addPaths(repo, files);
134
+ }
135
+ const result = await ops.createCommit(repo, options);
136
+ console.log(`[${result.branch || 'detached'} ${result.oid.slice(0, 12)}] ${options.message}`);
137
+ },
138
+ async branch(repo, name, options) {
139
+ if (options.delete) await ops.deleteBranch(repo, options.delete);
140
+ else if (name) await ops.createBranch(repo, name);
141
+ else for (const branch of await ops.listBranches(repo)) console.log(`${branch.current ? '*' : ' '} ${branch.name} ${branch.oid.slice(0, 12)}`);
142
+ },
143
+ async checkout(repo, target, options) {
144
+ if (options.abort) { await worktree.abortCheckout(repo); console.log('Checkout restored'); return; }
145
+ const result = await ops.checkout(repo, target, options);
146
+ console.log(`Switched to ${result.branch || result.oid}`);
147
+ },
148
+ async reset(repo, files, options) {
149
+ if (options.hard) await ops.reset(repo, 'hard', options.hard === true ? 'HEAD' : options.hard);
150
+ else if (options.soft) await ops.reset(repo, 'soft', options.soft === true ? 'HEAD' : options.soft);
151
+ else if (files.length) await ops.unstagePaths(repo, files);
152
+ else await ops.reset(repo, 'mixed', 'HEAD');
153
+ },
154
+ async merge(repo, branch, options) {
155
+ if (options.abort) return merge.abortMerge(repo);
156
+ if (options.continue) return merge.concludeMerge(repo, options.message);
157
+ const result = await merge.merge(repo, branch, options);
158
+ console.log(result.status);
159
+ if (result.status === 'conflicts') {
160
+ console.log('Resolve files, stage with gent add, then gent merge --continue or gent commit -m <message>.');
161
+ process.exitCode = 1;
162
+ }
163
+ },
164
+ async resolve(repo) {
165
+ const index = await GitIndex.read(repo.indexPath);
166
+ for (const name of index.conflicts().keys()) console.log(name);
167
+ console.log('Edit conflicted files, then gent add <path> and gent merge --continue.');
168
+ },
169
+ async stash(repo, sub = 'push', options = {}) {
170
+ const position = Number(options.index || 0);
171
+ if (!Number.isInteger(position) || position < 0) throw new Error('stash index must be a nonnegative integer');
172
+ if (sub === 'list') { for (const item of await stash.list(repo)) console.log(`stash@{${item.index}}: ${item.message}`); }
173
+ else if (sub === 'push' || sub === 'save') console.log((await stash.push(repo, options)).message);
174
+ else if (['apply', 'pop', 'drop'].includes(sub)) await stash[sub](repo, position, options);
175
+ else throw new Error(`unknown stash operation '${sub}'`);
176
+ },
177
+ async tag(repo, name, options) {
178
+ if (options.delete) await ops.deleteTag(repo, options.delete);
179
+ else if (name) await ops.createTag(repo, name, options);
180
+ else for (const item of await ops.listTags(repo)) console.log(item.name);
181
+ },
182
+ async log(repo, options) {
183
+ if (options.graph || options.stat) throw new Error('canonical log --graph/--stat formatting is not implemented yet; use log --oneline');
184
+ for (const commit of await ops.walkHistory(repo, { max: Number(options.number) })) {
185
+ console.log(`${commit.oid.slice(0, 12)} ${commit.message.toString().split('\n')[0]}`);
186
+ }
187
+ },
188
+ async show(repo, ref = 'HEAD', options = {}) {
189
+ const oid = await ops.peelToCommit(repo, ref);
190
+ const commit = await repo.objects.readCommit(oid);
191
+ console.log(`commit ${oid}\nParents: ${commit.parents.join(' ')}\n${commit.message.toString()}`);
192
+ if (options.patch !== false) {
193
+ const before = commit.parents.length ? await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(commit.parents[0])).tree) : new Map();
194
+ await printTreeDiff(repo, before, await worktree.readTreeRecursive(repo, commit.tree));
195
+ }
196
+ },
197
+ async diff(repo, files, options) {
198
+ const index = await GitIndex.read(repo.indexPath);
199
+ if (index.hasConflicts()) throw new Error('resolve conflicts before requesting a canonical diff');
200
+ const staged = new Map(index.staged().map(e => [e.path, e]));
201
+ const head = await repo.refs.head();
202
+ const before = options.staged ? await worktree.readTreeRecursive(repo, head.oid ? (await repo.objects.readCommit(head.oid)).tree : null) : staged;
203
+ const after = options.staged ? staged : new Map();
204
+ if (!options.staged) {
205
+ const attrs = new AttributesMatcher(repo);
206
+ for (const [name, entry] of staged) {
207
+ const snapshot = await worktree.snapshotPath(repo, name);
208
+ if (snapshot) after.set(name, { ...entry, mode: snapshot.mode, bytes: snapshot.mode === 0o120000 ? Buffer.from(snapshot.content, 'base64') : await attrs.toIndex(name, Buffer.from(snapshot.content, 'base64')) });
209
+ }
210
+ }
211
+ await printTreeDiff(repo, before, after, files, options.stat);
212
+ },
213
+ async summary(repo, options = {}) {
214
+ if (options.ai) throw new Error('AI summary is not connected to canonical repositories yet');
215
+ console.log(`${(await ops.walkHistory(repo)).length} commits, ${(await ops.listBranches(repo)).length} branches`);
216
+ await handlers.status(repo);
217
+ },
218
+ async config(repo, sub = 'list', args = []) {
219
+ const [key, value] = args;
220
+ if (sub === 'get') console.log(repo.config.get(key, ''));
221
+ else if (sub === 'set') {
222
+ if (!key || value === undefined) throw new Error('usage: gent config set <key> <value>');
223
+ if (!/^user\.(name|email)$/i.test(key)) throw new Error('canonical config writes currently support user.name and user.email only');
224
+ repo.localConfig.set(key, value); await repo.localConfig.save();
225
+ } else throw new Error('use gent config get <key> or gent config set user.name/user.email <value>');
226
+ }
227
+ };
228
+
229
+ async function printTreeDiff(repo, before, after, files = [], stat = false) {
230
+ const wanted = files.map(name => repo.relativePath(path.resolve(name)));
231
+ for (const name of new Set([...before.keys(), ...after.keys()])) {
232
+ if (wanted.length && !wanted.some(p => name === p || name.startsWith(p + '/'))) continue;
233
+ const a = before.get(name), b = after.get(name);
234
+ const read = item => item ? (item.bytes || repo.objects.readBlob(item.oid)) : Buffer.alloc(0);
235
+ const oldBytes = await read(a), newBytes = await read(b);
236
+ if (oldBytes.equals(newBytes) && a?.mode === b?.mode) continue;
237
+ if (stat || looksBinary(oldBytes) || looksBinary(newBytes)) console.log(`${name}: ${oldBytes.length} -> ${newBytes.length} bytes`);
238
+ else console.log(formatUnifiedDiff(name, oldBytes.toString(), newBytes.toString()));
239
+ }
240
+ }
241
+
242
+ module.exports = { route };
@@ -28,7 +28,6 @@ const ora = require('ora');
28
28
  const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
29
29
  const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
30
30
  const apiClient = require('../utils/api-client');
31
- const authStorage = require('../utils/auth-storage');
32
31
  const { storeBlob, readBlob } = require('../utils/hash-engine');
33
32
 
34
33
  /**
@@ -46,14 +45,6 @@ async function clone(url, directory, options) {
46
45
  const spinner = ora(`Cloning from ${url}...`).start();
47
46
 
48
47
  try {
49
- // Check auth
50
- const isAuth = await authStorage.isAuthenticated();
51
- if (!isAuth) {
52
- spinner.fail(chalk.red('Not authenticated'));
53
- console.log(chalk.yellow('Run "gent login" first'));
54
- return;
55
- }
56
-
57
48
  // Parse URL
58
49
  const repoInfo = parseRemoteUrl(url);
59
50
  if (!repoInfo) {
@@ -183,10 +174,8 @@ async function clone(url, directory, options) {
183
174
  spinner.fail(chalk.red('Clone failed'));
184
175
  if (error.response?.status === 404) {
185
176
  console.error(chalk.red('Repository not found'));
186
- } else if (error.response?.status === 401) {
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'));
177
+ } else if (error.response?.status === 401 || error.response?.status === 403) {
178
+ console.error(chalk.red('This repository is private or you do not have access to it. Run "gent login" to clone private repositories you can access.'));
190
179
  } else if (error.response?.data) {
191
180
  console.error(chalk.red(JSON.stringify(error.response.data)));
192
181
  } else {
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Pet Command — "Genti", the gent mascot.
3
3
  *
4
- * A chunky pixel-block creature that lives in your terminal and *acts out*
4
+ * A mint one-eyed sky-jelly that lives in your terminal and *acts out*
5
5
  * gent workflows:
6
6
  *
7
7
  * gent pet → idle: Genti breathes, blinks, waves, drops a tip
8
- * gent pet push → walks a file crate to the cloud, over and over
8
+ * gent pet push → floats a file crate to the cloud, over and over
9
9
  * gent pet pull → carries a crate back from the cloud
10
10
  * gent pet merge → stands between two branches and "thinks" them together
11
11
  * gent pet auth → little sign-in scene
@@ -25,10 +25,10 @@ const FRAME_MS = 90;
25
25
 
26
26
  // ── Palette ──────────────────────────────────────────────────────────────
27
27
  const C = {
28
- body: chalk.hex('#c97b5a'), // Genti's orange skin
29
- shade: chalk.hex('#9c5a3f'), // bottom shading
30
- eye: chalk.hex('#15110f'), // dark eye holes
31
- mouth: chalk.hex('#5a2f22'),
28
+ body: chalk.hex('#55e6c1'), // Genti's mint glow
29
+ shade: chalk.hex('#168f86'), // lower-body depth
30
+ eye: chalk.hex('#7c3aed'), // single violet eye
31
+ mouth: chalk.hex('#34205f'),
32
32
  crate: chalk.hex('#e0b64d'), // file crate edges
33
33
  crateIn:chalk.hex('#b98a1f'), // crate fill
34
34
  cloud: chalk.hex('#d6def0'), // remote / cloud
@@ -115,49 +115,27 @@ class Canvas {
115
115
  }
116
116
 
117
117
  // ── Mascot sprite ────────────────────────────────────────────────────────
118
- // Body is a 12-wide block with a 1-col margin (transparent) each side.
118
+ // Wide fins, one eye, and ribbon tentacles give Genti a sky-jelly silhouette.
119
119
  function mascot({ blink = false, mouth = '_', armUp = false } = {}) {
120
- const W = 12;
121
- const rows = [];
122
- rows[0] = ' ' + '#'.repeat(W) + ' ';
123
- rows[1] = ' ' + '#'.repeat(W) + ' ';
124
- rows[2] = ' ' + '#'.repeat(W) + ' '; // eyes row
125
- rows[3] = ' ' + '#'.repeat(W) + ' ';
126
- rows[4] = ' ' + '#'.repeat(W) + ' '; // mouth row
127
- rows[5] = ' ' + '@'.repeat(W) + ' '; // shaded chin
128
-
129
- const grid = rows.map(r => r.split(''));
130
- const eye = blink ? '#' : 'O';
131
- // eyes at body cols 3-4 and 8-9 → +1 for margin
132
- [3, 4].forEach(c => grid[2][c + 1] = eye);
133
- [8, 9].forEach(c => grid[2][c + 1] = eye);
134
- // mouth at cols 5-8 (row4)
135
- for (let c = 5; c <= 8; c++) grid[4][c + 1] = mouth;
136
- // ears (stick out at row2)
137
- grid[2][0] = '#';
138
- grid[2][W + 1] = '#';
139
-
140
- let body = grid.map(r => r.join(''));
141
-
142
- // arm (raised wave) sits to the right of the head on row1
143
- if (armUp) {
144
- const r1 = body[1].split('');
145
- r1[W + 1] = '#';
146
- body[1] = r1.join('');
147
- body.unshift(' #'); // tiny raised hand
148
- } else {
149
- body.unshift(' ');
150
- }
151
- return body; // 7 rows (incl. leading arm/space row), 14 wide
120
+ const eye = blink ? '##' : 'OO';
121
+ const smile = mouth.repeat(4);
122
+ return [
123
+ armUp ? '# #### ' : ' #### ',
124
+ armUp ? ' ## ######## ' : ' ########## ',
125
+ ' ############ ',
126
+ '##############',
127
+ ` ####${eye}#### `,
128
+ ` ##${smile}## `,
129
+ ' @@@@@@ ',
130
+ ];
152
131
  }
153
132
 
154
- // Legs are separate so they can shuffle while the body glides.
133
+ // Ribbon tentacles trail independently so the sky-jelly appears to float.
155
134
  function legs(step) {
156
- // step: 'stand' | 'a' | 'b'
157
135
  const map = {
158
- stand: ' ## ## ',
159
- a: ' ## # ',
160
- b: ' # ## ',
136
+ stand: ' @ @ @ ',
137
+ a: ' @ @ @ ',
138
+ b: ' @ @ @ ',
161
139
  };
162
140
  return [map[step] || map.stand];
163
141
  }
@@ -419,7 +397,7 @@ function runLoop(sceneName, {
419
397
 
420
398
  const footerLine = () => footer
421
399
  ? chalk.gray(' ') +
422
- (loop ? chalk.gray('Ctrl+C to cancel') : C.body('Genti')) +
400
+ (loop ? chalk.gray('Ctrl+C to cancel') : C.body('Genti the sky-jelly')) +
423
401
  chalk.gray(' · more scenes: ') + C.say('gent pet push|pull|merge')
424
402
  : '';
425
403
 
@@ -235,7 +235,7 @@ async function push(remoteName, branchName, options) {
235
235
  // Send to backend — Genti carries the crate to the cloud while we wait.
236
236
  const pushUrl = buildRepoUrl(API_ENDPOINTS.REPO_PUSH, repoInfo);
237
237
  spinner.stop();
238
- const response = await pet.during('push', () => apiClient.post(pushUrl, payload));
238
+ const response = await pet.during('push', () => apiClient.post(pushUrl, payload, { timeout: 120000 }));
239
239
 
240
240
  // Update remote ref
241
241
  config.remoteRefs[`${remote}/${branch}`] = localHead;
@@ -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);