gent-cli 15.0.0 → 21.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 +1 -1
- package/package.json +7 -4
- package/src/commands/canonical.js +246 -0
- package/src/commands/clone.js +2 -13
- package/src/commands/pet.js +23 -45
- package/src/commands/push.js +1 -1
- package/src/commands/share.js +40 -15
- package/src/commands/web.js +30 -15
- package/src/index.js +70 -40
- package/src/utils/api-client.js +6 -9
- package/src/utils/attributes.js +280 -0
- package/src/utils/canonical-journal.js +122 -0
- package/src/utils/feature-support.js +92 -0
- package/src/utils/feature-support.json +210 -0
- package/src/utils/gent-ops.js +836 -0
- package/src/utils/git-config.js +606 -0
- package/src/utils/git-index.js +581 -0
- package/src/utils/git-objects.js +537 -0
- package/src/utils/ignore.js +365 -0
- package/src/utils/lockfile.js +219 -0
- package/src/utils/merge-engine.js +10 -7
- package/src/utils/merge-ops.js +283 -0
- package/src/utils/migrate.js +257 -0
- package/src/utils/object-store.js +332 -36
- package/src/utils/packfile.js +812 -0
- package/src/utils/refs.js +595 -0
- package/src/utils/repository.js +533 -0
- package/src/utils/smart-http.js +195 -0
- package/src/utils/stash-ops.js +355 -0
- package/src/utils/user-config.js +10 -0
- package/src/utils/web-urls.js +125 -0
- package/src/utils/worktree.js +676 -0
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
|
|
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": "
|
|
3
|
+
"version": "21.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,246 @@
|
|
|
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
|
+
if (result.status === 'conflicts') {
|
|
159
|
+
for (const conflict of result.conflicts) {
|
|
160
|
+
if (conflict.kind === 'content') console.log(`Auto-merging ${conflict.path}`);
|
|
161
|
+
console.log(`CONFLICT (${conflict.kind}): Merge conflict in ${conflict.path}`);
|
|
162
|
+
}
|
|
163
|
+
console.log('Automatic merge failed; fix conflicts and then commit the result.');
|
|
164
|
+
console.log('Resolve files, stage with gent add, then gent merge --continue or gent commit -m <message>.');
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
} else console.log(result.status);
|
|
167
|
+
},
|
|
168
|
+
async resolve(repo) {
|
|
169
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
170
|
+
for (const name of index.conflicts().keys()) console.log(name);
|
|
171
|
+
console.log('Edit conflicted files, then gent add <path> and gent merge --continue.');
|
|
172
|
+
},
|
|
173
|
+
async stash(repo, sub = 'push', options = {}) {
|
|
174
|
+
const position = Number(options.index || 0);
|
|
175
|
+
if (!Number.isInteger(position) || position < 0) throw new Error('stash index must be a nonnegative integer');
|
|
176
|
+
if (sub === 'list') { for (const item of await stash.list(repo)) console.log(`stash@{${item.index}}: ${item.message}`); }
|
|
177
|
+
else if (sub === 'push' || sub === 'save') console.log((await stash.push(repo, options)).message);
|
|
178
|
+
else if (['apply', 'pop', 'drop'].includes(sub)) await stash[sub](repo, position, options);
|
|
179
|
+
else throw new Error(`unknown stash operation '${sub}'`);
|
|
180
|
+
},
|
|
181
|
+
async tag(repo, name, options) {
|
|
182
|
+
if (options.delete) await ops.deleteTag(repo, options.delete);
|
|
183
|
+
else if (name) await ops.createTag(repo, name, options);
|
|
184
|
+
else for (const item of await ops.listTags(repo)) console.log(item.name);
|
|
185
|
+
},
|
|
186
|
+
async log(repo, options) {
|
|
187
|
+
if (options.graph || options.stat) throw new Error('canonical log --graph/--stat formatting is not implemented yet; use log --oneline');
|
|
188
|
+
for (const commit of await ops.walkHistory(repo, { max: Number(options.number) })) {
|
|
189
|
+
console.log(`${commit.oid.slice(0, 12)} ${commit.message.toString().split('\n')[0]}`);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
async show(repo, ref = 'HEAD', options = {}) {
|
|
193
|
+
const oid = await ops.peelToCommit(repo, ref);
|
|
194
|
+
const commit = await repo.objects.readCommit(oid);
|
|
195
|
+
console.log(`commit ${oid}\nParents: ${commit.parents.join(' ')}\n${commit.message.toString()}`);
|
|
196
|
+
if (options.patch !== false) {
|
|
197
|
+
const before = commit.parents.length ? await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(commit.parents[0])).tree) : new Map();
|
|
198
|
+
await printTreeDiff(repo, before, await worktree.readTreeRecursive(repo, commit.tree));
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
async diff(repo, files, options) {
|
|
202
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
203
|
+
if (index.hasConflicts()) throw new Error('resolve conflicts before requesting a canonical diff');
|
|
204
|
+
const staged = new Map(index.staged().map(e => [e.path, e]));
|
|
205
|
+
const head = await repo.refs.head();
|
|
206
|
+
const before = options.staged ? await worktree.readTreeRecursive(repo, head.oid ? (await repo.objects.readCommit(head.oid)).tree : null) : staged;
|
|
207
|
+
const after = options.staged ? staged : new Map();
|
|
208
|
+
if (!options.staged) {
|
|
209
|
+
const attrs = new AttributesMatcher(repo);
|
|
210
|
+
for (const [name, entry] of staged) {
|
|
211
|
+
const snapshot = await worktree.snapshotPath(repo, name);
|
|
212
|
+
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')) });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
await printTreeDiff(repo, before, after, files, options.stat);
|
|
216
|
+
},
|
|
217
|
+
async summary(repo, options = {}) {
|
|
218
|
+
if (options.ai) throw new Error('AI summary is not connected to canonical repositories yet');
|
|
219
|
+
console.log(`${(await ops.walkHistory(repo)).length} commits, ${(await ops.listBranches(repo)).length} branches`);
|
|
220
|
+
await handlers.status(repo);
|
|
221
|
+
},
|
|
222
|
+
async config(repo, sub = 'list', args = []) {
|
|
223
|
+
const [key, value] = args;
|
|
224
|
+
if (sub === 'get') console.log(repo.config.get(key, ''));
|
|
225
|
+
else if (sub === 'set') {
|
|
226
|
+
if (!key || value === undefined) throw new Error('usage: gent config set <key> <value>');
|
|
227
|
+
if (!/^user\.(name|email)$/i.test(key)) throw new Error('canonical config writes currently support user.name and user.email only');
|
|
228
|
+
repo.localConfig.set(key, value); await repo.localConfig.save();
|
|
229
|
+
} else throw new Error('use gent config get <key> or gent config set user.name/user.email <value>');
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
async function printTreeDiff(repo, before, after, files = [], stat = false) {
|
|
234
|
+
const wanted = files.map(name => repo.relativePath(path.resolve(name)));
|
|
235
|
+
for (const name of new Set([...before.keys(), ...after.keys()])) {
|
|
236
|
+
if (wanted.length && !wanted.some(p => name === p || name.startsWith(p + '/'))) continue;
|
|
237
|
+
const a = before.get(name), b = after.get(name);
|
|
238
|
+
const read = item => item ? (item.bytes || repo.objects.readBlob(item.oid)) : Buffer.alloc(0);
|
|
239
|
+
const oldBytes = await read(a), newBytes = await read(b);
|
|
240
|
+
if (oldBytes.equals(newBytes) && a?.mode === b?.mode) continue;
|
|
241
|
+
if (stat || looksBinary(oldBytes) || looksBinary(newBytes)) console.log(`${name}: ${oldBytes.length} -> ${newBytes.length} bytes`);
|
|
242
|
+
else console.log(formatUnifiedDiff(name, oldBytes.toString(), newBytes.toString()));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = { route };
|
package/src/commands/clone.js
CHANGED
|
@@ -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('
|
|
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 {
|
package/src/commands/pet.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pet Command — "Genti", the gent mascot.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
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 →
|
|
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('#
|
|
29
|
-
shade: chalk.hex('#
|
|
30
|
-
eye: chalk.hex('#
|
|
31
|
-
mouth: chalk.hex('#
|
|
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
|
-
//
|
|
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
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
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
|
|
package/src/commands/push.js
CHANGED
|
@@ -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;
|
package/src/commands/share.js
CHANGED
|
@@ -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
|
|
5
|
-
* gent share --branch <name> → link
|
|
6
|
-
* gent share --commit <hash> → link
|
|
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`
|
|
9
|
-
*
|
|
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
|
|
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
|
|
37
|
-
const
|
|
38
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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')) {
|
package/src/commands/web.js
CHANGED
|
@@ -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> →
|
|
6
|
-
* gent web --commit <hash> →
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const baseUrl = await getWebBaseUrl();
|
|
40
|
+
const { url, unsupported } = buildRepoLink(baseUrl, info, {
|
|
41
|
+
branch: options.branch,
|
|
42
|
+
commit: options.commit,
|
|
43
|
+
});
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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);
|