gitnexushub 0.3.0 → 0.4.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/dist/api.d.ts +28 -0
- package/dist/api.js +39 -0
- package/dist/cli-helpers.d.ts +23 -0
- package/dist/cli-helpers.js +57 -0
- package/dist/connect-command.d.ts +29 -0
- package/dist/connect-command.js +169 -0
- package/dist/editors/claude-code.js +8 -0
- package/dist/editors/cursor.js +5 -1
- package/dist/hooks-installer.d.ts +33 -0
- package/dist/hooks-installer.js +114 -0
- package/dist/index.js +23 -171
- package/dist/registry.d.ts +41 -0
- package/dist/registry.js +92 -0
- package/dist/sync-command.d.ts +16 -0
- package/dist/sync-command.js +169 -0
- package/dist/tarball.d.ts +17 -0
- package/dist/tarball.js +75 -0
- package/hooks/gitnexus-enterprise-hook.cjs +415 -0
- package/package.json +5 -2
- package/skills/gitnexus-guide.md +1 -1
- package/skills/gitnexus-refactoring.md +1 -1
package/dist/index.js
CHANGED
|
@@ -12,23 +12,12 @@ import { Command } from 'commander';
|
|
|
12
12
|
import pc from 'picocolors';
|
|
13
13
|
const require = createRequire(import.meta.url);
|
|
14
14
|
const PKG_VERSION = require('../package.json').version;
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { cursorEditor } from './editors/cursor.js';
|
|
22
|
-
import { windsurfEditor } from './editors/windsurf.js';
|
|
23
|
-
import { opencodeEditor } from './editors/opencode.js';
|
|
24
|
-
import { claudeCodeEditor } from './editors/claude-code.js';
|
|
25
|
-
const DEFAULT_HUB_URL = process.env.GITNEXUS_HUB_URL || 'https://gitnexus-enterprise-production.up.railway.app';
|
|
26
|
-
const EDITORS = {
|
|
27
|
-
'claude-code': claudeCodeEditor,
|
|
28
|
-
cursor: cursorEditor,
|
|
29
|
-
windsurf: windsurfEditor,
|
|
30
|
-
opencode: opencodeEditor,
|
|
31
|
-
};
|
|
15
|
+
import { clearConfig } from './config.js';
|
|
16
|
+
import { isGitRepo } from './project.js';
|
|
17
|
+
import { removeProjectContext } from './context.js';
|
|
18
|
+
import { runSync } from './sync-command.js';
|
|
19
|
+
import { ok, info, warn, fail, resolveAuth, DEFAULT_HUB_URL, EDITORS } from './cli-helpers.js';
|
|
20
|
+
import { runConnect } from './connect-command.js';
|
|
32
21
|
const BANNER = [
|
|
33
22
|
' ██████╗ ██╗████████╗███╗ ██╗███████╗██╗ ██╗██╗ ██╗███████╗',
|
|
34
23
|
'██╔════╝ ██║╚══██╔══╝████╗ ██║██╔════╝╚██╗██╔╝██║ ██║██╔════╝',
|
|
@@ -37,10 +26,6 @@ const BANNER = [
|
|
|
37
26
|
'╚██████╔╝██║ ██║ ██║ ╚████║███████╗██╔╝ ██╗╚██████╔╝███████║',
|
|
38
27
|
' ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝',
|
|
39
28
|
];
|
|
40
|
-
const ok = (msg) => console.log(` ${pc.green('✔')} ${msg}`);
|
|
41
|
-
const info = (msg) => console.log(` ${pc.cyan('ℹ')} ${msg}`);
|
|
42
|
-
const warn = (msg) => console.log(` ${pc.yellow('⚠')} ${msg}`);
|
|
43
|
-
const fail = (msg) => console.error(` ${pc.red('✗')} ${msg}`);
|
|
44
29
|
function printBanner() {
|
|
45
30
|
console.log('');
|
|
46
31
|
for (const line of BANNER) {
|
|
@@ -50,44 +35,9 @@ function printBanner() {
|
|
|
50
35
|
console.log(` ${pc.dim('Plug into the living brain of your codebase')}`);
|
|
51
36
|
console.log('');
|
|
52
37
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Resolve token + Hub URL from args/config. Exits on failure.
|
|
55
|
-
*/
|
|
56
|
-
async function resolveAuth(tokenArg, hubOpt) {
|
|
57
|
-
const config = await loadConfig();
|
|
58
|
-
const token = tokenArg || config.hubToken;
|
|
59
|
-
const hubUrl = hubOpt || config.hubUrl || DEFAULT_HUB_URL;
|
|
60
|
-
if (!token) {
|
|
61
|
-
fail('No API token provided.');
|
|
62
|
-
console.error('');
|
|
63
|
-
console.error(` Usage: ${pc.cyan('npx gitnexushub gnx_YOUR_TOKEN --editor cursor')}`);
|
|
64
|
-
console.error(` Generate a token at: ${pc.cyan(hubUrl + '/settings/tokens')}`);
|
|
65
|
-
console.error('');
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
if (!token.startsWith('gnx_')) {
|
|
69
|
-
fail(`Invalid token format. Tokens must start with ${pc.bold('gnx_')}`);
|
|
70
|
-
console.error('');
|
|
71
|
-
process.exit(1);
|
|
72
|
-
}
|
|
73
|
-
const api = new HubAPI(hubUrl, token);
|
|
74
|
-
const user = await api.getMe().catch((err) => {
|
|
75
|
-
fail(`Authentication failed: ${err.message}`);
|
|
76
|
-
console.error(` Check your token and try again.`);
|
|
77
|
-
console.error('');
|
|
78
|
-
process.exit(1);
|
|
79
|
-
});
|
|
80
|
-
ok(`Hello, ${pc.bold(user.name)}!`);
|
|
81
|
-
console.log('');
|
|
82
|
-
await saveConfig({ hubToken: token, hubUrl });
|
|
83
|
-
return { api, hubUrl, token };
|
|
84
|
-
}
|
|
85
38
|
// ─── CLI Setup ────────────────────────────────────────────────────
|
|
86
39
|
const program = new Command();
|
|
87
|
-
program
|
|
88
|
-
.name('gnx')
|
|
89
|
-
.description('Connect your editor to GitNexus Hub')
|
|
90
|
-
.version(PKG_VERSION);
|
|
40
|
+
program.name('gnx').description('Connect your editor to GitNexus Hub').version(PKG_VERSION);
|
|
91
41
|
// ─── connect command (also the default) ──────────────────────────
|
|
92
42
|
const connectAction = async (tokenArg, opts) => {
|
|
93
43
|
try {
|
|
@@ -145,120 +95,22 @@ program
|
|
|
145
95
|
process.exit(1);
|
|
146
96
|
}
|
|
147
97
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
fail(`Unknown editor: ${pc.bold(opts.editor)}`);
|
|
158
|
-
console.error(` Supported: ${pc.cyan(Object.keys(EDITORS).join(', '))}`);
|
|
159
|
-
console.error('');
|
|
160
|
-
return process.exit(1);
|
|
161
|
-
}
|
|
162
|
-
editorId = opts.editor;
|
|
163
|
-
}
|
|
164
|
-
else if (!config.hubToken) {
|
|
165
|
-
const detected = await detectInstalledEditors();
|
|
166
|
-
if (detected.length === 1) {
|
|
167
|
-
editorId = detected[0];
|
|
168
|
-
info(`Auto-detected editor: ${pc.bold(EDITORS[editorId].name)}`);
|
|
169
|
-
}
|
|
170
|
-
else if (detected.length > 1) {
|
|
171
|
-
warn('Multiple editors detected. Please specify one:');
|
|
172
|
-
for (const id of detected) {
|
|
173
|
-
console.error(` ${pc.cyan('--editor ' + id)}`);
|
|
174
|
-
}
|
|
175
|
-
console.error('');
|
|
176
|
-
return process.exit(1);
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
fail('No editor detected. Please specify one:');
|
|
180
|
-
console.error(` ${pc.cyan('--editor claude-code | cursor | windsurf | opencode')}`);
|
|
181
|
-
console.error('');
|
|
182
|
-
return process.exit(1);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
// ── Configure editor MCP ────────────────────────────────────────
|
|
186
|
-
let skills = [];
|
|
187
|
-
if (editorId) {
|
|
188
|
-
const editor = EDITORS[editorId];
|
|
189
|
-
info(`Configuring ${pc.bold(editor.name)}...`);
|
|
190
|
-
const result = await editor.configure(hubUrl, token);
|
|
191
|
-
if (result.success) {
|
|
192
|
-
ok(result.message);
|
|
193
|
-
if (result.overrodeCli) {
|
|
194
|
-
console.log('');
|
|
195
|
-
console.log(` ${pc.cyan('⬆')} ${pc.bold('GitNexus open-source detected — upgraded to Hub')}`);
|
|
196
|
-
console.log(` ${pc.dim('Remote indexing. Deeper analysis. PR blast radius. Auto-reindex on push.')}`);
|
|
197
|
-
console.log(` ${pc.dim('Your tools (query, context, impact) are unchanged — just faster and smarter.')}`);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
else {
|
|
201
|
-
fail(result.message);
|
|
202
|
-
}
|
|
203
|
-
try {
|
|
204
|
-
const bundled = await generateConnectContext('_', {});
|
|
205
|
-
skills = bundled.skills;
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
// Skills load failed — continue without
|
|
209
|
-
}
|
|
210
|
-
if (editor.installSkills && skills.length > 0) {
|
|
211
|
-
const count = await editor.installSkills(skills);
|
|
212
|
-
if (count > 0) {
|
|
213
|
-
ok(`${count} skills installed`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// ── Write project context ───────────────────────────────────────
|
|
218
|
-
if (!opts.skipProject && isGitRepo()) {
|
|
219
|
-
const remoteUrl = getGitRemoteUrl();
|
|
220
|
-
const remoteFullName = remoteUrl ? parseGitRemote(remoteUrl) : null;
|
|
221
|
-
if (remoteFullName) {
|
|
222
|
-
console.log('');
|
|
223
|
-
info(`Project: ${pc.bold(remoteFullName)}`);
|
|
224
|
-
try {
|
|
225
|
-
const repos = await api.listRepos();
|
|
226
|
-
const matched = matchRepo(remoteFullName, repos);
|
|
227
|
-
if (matched) {
|
|
228
|
-
if (matched.status === 'ready') {
|
|
229
|
-
const ctx = await generateConnectContext(matched.fullName, matched.stats || {});
|
|
230
|
-
const result = await writeProjectContext(process.cwd(), ctx);
|
|
231
|
-
for (const file of result.files) {
|
|
232
|
-
ok(file);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
236
|
-
warn(`Repo status: ${pc.yellow(matched.status)} ${pc.dim('(waiting for indexing)')}`);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
240
|
-
warn('Repo not indexed on Hub yet');
|
|
241
|
-
console.log(` Add it at: ${pc.cyan(hubUrl)}`);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
catch (err) {
|
|
245
|
-
fail(`Failed to fetch project context: ${err.message}`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
console.log('');
|
|
250
|
-
warn('No GitHub remote found — skipping project context');
|
|
251
|
-
}
|
|
98
|
+
// ─── sync command ─────────────────────────────────────────────────
|
|
99
|
+
program
|
|
100
|
+
.command('sync')
|
|
101
|
+
.description('Push local working-tree state to the hub for re-indexing')
|
|
102
|
+
.option('--wait', 'Wait for indexing to complete', false)
|
|
103
|
+
.option('--hub <url>', 'Hub URL', DEFAULT_HUB_URL)
|
|
104
|
+
.action(async (opts) => {
|
|
105
|
+
try {
|
|
106
|
+
await runSync(opts);
|
|
252
107
|
}
|
|
253
|
-
|
|
254
|
-
console.
|
|
255
|
-
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.error(' ' + pc.red('✗') + ' ' + (err.message || String(err)));
|
|
110
|
+
process.exit(1);
|
|
256
111
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
console.log(` ${pc.green('✔')} ${pc.bold('Done!')} Open your editor — GitNexus MCP is ready.`);
|
|
260
|
-
console.log('');
|
|
261
|
-
}
|
|
112
|
+
});
|
|
113
|
+
program.parse();
|
|
262
114
|
// ─── Disconnect Flow ──────────────────────────────────────────────
|
|
263
115
|
async function runDisconnect(opts) {
|
|
264
116
|
// ── Resolve which editors to clean ─────────────────────────────
|
|
@@ -332,7 +184,7 @@ async function runIndex(repo, opts) {
|
|
|
332
184
|
// Already added — look it up instead of failing
|
|
333
185
|
if (msg.includes('already added')) {
|
|
334
186
|
const repos = await api.listRepos();
|
|
335
|
-
const existing = repos.find(r => r.fullName === fullName);
|
|
187
|
+
const existing = repos.find((r) => r.fullName === fullName);
|
|
336
188
|
if (existing) {
|
|
337
189
|
if (existing.status === 'ready') {
|
|
338
190
|
ok(`${pc.bold(fullName)} is already indexed and ready.`);
|
|
@@ -367,7 +219,7 @@ async function runIndex(repo, opts) {
|
|
|
367
219
|
const spinFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
368
220
|
let frame = 0;
|
|
369
221
|
while (true) {
|
|
370
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
222
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
371
223
|
try {
|
|
372
224
|
const detail = await api.getRepo(repoId);
|
|
373
225
|
const spinner = pc.cyan(spinFrames[frame % spinFrames.length]);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local repo registry — stored at ~/.gitnexus/connect-registry.json.
|
|
3
|
+
*
|
|
4
|
+
* Maps local filesystem paths to hub repo identities so that `gnx sync`
|
|
5
|
+
* and the editor hook script can resolve cwd → hub_repo_id without
|
|
6
|
+
* talking to the hub. The registry is written by `gnx sync` when a repo
|
|
7
|
+
* is first registered and read on every subsequent sync and every hook
|
|
8
|
+
* invocation.
|
|
9
|
+
*
|
|
10
|
+
* Matcher semantics (ported from gitnexus/src/core/augmentation/engine.ts
|
|
11
|
+
* but intentionally kept separate — the OSS registry has a different
|
|
12
|
+
* shape keyed by storagePath/lbugPath):
|
|
13
|
+
* - longest-path match wins (inner repo beats outer)
|
|
14
|
+
* - symlinks in cwd are resolved via fs.realpath before matching
|
|
15
|
+
* - match must land on a path-separator boundary so /projects/foo does
|
|
16
|
+
* not match /projects/foobar
|
|
17
|
+
* - falls back to path.resolve on realpath errors so non-existent
|
|
18
|
+
* paths still return a best-effort syntactic match
|
|
19
|
+
*/
|
|
20
|
+
export interface RegistryEntry {
|
|
21
|
+
localPath: string;
|
|
22
|
+
fullName: string;
|
|
23
|
+
hubRepoId: string;
|
|
24
|
+
lastSyncedSha?: string;
|
|
25
|
+
lastSyncedAt?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function readRegistry(): Promise<RegistryEntry[]>;
|
|
28
|
+
export declare function writeRegistry(entries: RegistryEntry[]): Promise<void>;
|
|
29
|
+
export declare function upsertRegistryEntry(entry: RegistryEntry): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a working directory to its best-matching registry entry.
|
|
32
|
+
*
|
|
33
|
+
* Uses longest-path matching with symlink resolution and path-boundary
|
|
34
|
+
* checks so /projects/foo does not match /projects/foobar. When `opts.entries`
|
|
35
|
+
* is passed, resolution is done against that in-memory list (used by tests
|
|
36
|
+
* and by callers that already hold the entries); otherwise the registry is
|
|
37
|
+
* read from disk.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveCwdToRepo(cwd: string, opts?: {
|
|
40
|
+
entries?: RegistryEntry[];
|
|
41
|
+
}): Promise<RegistryEntry | null>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local repo registry — stored at ~/.gitnexus/connect-registry.json.
|
|
3
|
+
*
|
|
4
|
+
* Maps local filesystem paths to hub repo identities so that `gnx sync`
|
|
5
|
+
* and the editor hook script can resolve cwd → hub_repo_id without
|
|
6
|
+
* talking to the hub. The registry is written by `gnx sync` when a repo
|
|
7
|
+
* is first registered and read on every subsequent sync and every hook
|
|
8
|
+
* invocation.
|
|
9
|
+
*
|
|
10
|
+
* Matcher semantics (ported from gitnexus/src/core/augmentation/engine.ts
|
|
11
|
+
* but intentionally kept separate — the OSS registry has a different
|
|
12
|
+
* shape keyed by storagePath/lbugPath):
|
|
13
|
+
* - longest-path match wins (inner repo beats outer)
|
|
14
|
+
* - symlinks in cwd are resolved via fs.realpath before matching
|
|
15
|
+
* - match must land on a path-separator boundary so /projects/foo does
|
|
16
|
+
* not match /projects/foobar
|
|
17
|
+
* - falls back to path.resolve on realpath errors so non-existent
|
|
18
|
+
* paths still return a best-effort syntactic match
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
function getRegistryPath() {
|
|
24
|
+
return path.join(os.homedir(), '.gitnexus', 'connect-registry.json');
|
|
25
|
+
}
|
|
26
|
+
export async function readRegistry() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await fs.readFile(getRegistryPath(), 'utf-8');
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function writeRegistry(entries) {
|
|
37
|
+
const p = getRegistryPath();
|
|
38
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
39
|
+
await fs.writeFile(p, JSON.stringify({ entries }, null, 2));
|
|
40
|
+
}
|
|
41
|
+
export async function upsertRegistryEntry(entry) {
|
|
42
|
+
const entries = await readRegistry();
|
|
43
|
+
const idx = entries.findIndex((e) => e.hubRepoId === entry.hubRepoId);
|
|
44
|
+
if (idx >= 0)
|
|
45
|
+
entries[idx] = { ...entries[idx], ...entry };
|
|
46
|
+
else
|
|
47
|
+
entries.push(entry);
|
|
48
|
+
await writeRegistry(entries);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a working directory to its best-matching registry entry.
|
|
52
|
+
*
|
|
53
|
+
* Uses longest-path matching with symlink resolution and path-boundary
|
|
54
|
+
* checks so /projects/foo does not match /projects/foobar. When `opts.entries`
|
|
55
|
+
* is passed, resolution is done against that in-memory list (used by tests
|
|
56
|
+
* and by callers that already hold the entries); otherwise the registry is
|
|
57
|
+
* read from disk.
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveCwdToRepo(cwd, opts) {
|
|
60
|
+
const entries = opts?.entries ?? (await readRegistry());
|
|
61
|
+
if (entries.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
let resolved;
|
|
64
|
+
try {
|
|
65
|
+
resolved = await fs.realpath(path.resolve(cwd));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
resolved = path.resolve(cwd);
|
|
69
|
+
}
|
|
70
|
+
// Normalize casing on Windows so D:\foo and d:\foo match.
|
|
71
|
+
const isWindows = process.platform === 'win32';
|
|
72
|
+
const normalizedCwd = isWindows ? resolved.toLowerCase() : resolved;
|
|
73
|
+
const sep = path.sep;
|
|
74
|
+
let best = null;
|
|
75
|
+
let bestLen = 0;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
let entryPath;
|
|
78
|
+
try {
|
|
79
|
+
entryPath = await fs.realpath(path.resolve(entry.localPath));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
entryPath = path.resolve(entry.localPath);
|
|
83
|
+
}
|
|
84
|
+
const normalizedEntry = isWindows ? entryPath.toLowerCase() : entryPath;
|
|
85
|
+
const matched = normalizedCwd === normalizedEntry || normalizedCwd.startsWith(normalizedEntry + sep);
|
|
86
|
+
if (matched && normalizedEntry.length > bestLen) {
|
|
87
|
+
best = entry;
|
|
88
|
+
bestLen = normalizedEntry.length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return best;
|
|
92
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gnx sync` — push local working-tree state to the hub for re-indexing.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Resolve cwd → registered repo (from connect-registry.json)
|
|
6
|
+
* 2. If not registered, look up via hub API by GitHub remote, save to registry
|
|
7
|
+
* 3. If still not found, tell user to add via hub UI
|
|
8
|
+
* 4. Compute local HEAD and dirty state
|
|
9
|
+
* 5. Short-circuit if hub already has this commit AND tree is clean
|
|
10
|
+
* 6. Build tarball, upload, optionally poll for completion
|
|
11
|
+
*/
|
|
12
|
+
export interface SyncOptions {
|
|
13
|
+
wait?: boolean;
|
|
14
|
+
hub?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function runSync(opts: SyncOptions): Promise<void>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gnx sync` — push local working-tree state to the hub for re-indexing.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Resolve cwd → registered repo (from connect-registry.json)
|
|
6
|
+
* 2. If not registered, look up via hub API by GitHub remote, save to registry
|
|
7
|
+
* 3. If still not found, tell user to add via hub UI
|
|
8
|
+
* 4. Compute local HEAD and dirty state
|
|
9
|
+
* 5. Short-circuit if hub already has this commit AND tree is clean
|
|
10
|
+
* 6. Build tarball, upload, optionally poll for completion
|
|
11
|
+
*/
|
|
12
|
+
import { execFileSync } from 'child_process';
|
|
13
|
+
import pc from 'picocolors';
|
|
14
|
+
import { loadConfig } from './config.js';
|
|
15
|
+
import { HubAPI } from './api.js';
|
|
16
|
+
import { resolveCwdToRepo, upsertRegistryEntry } from './registry.js';
|
|
17
|
+
import { buildTarballStream } from './tarball.js';
|
|
18
|
+
import { isGitRepo, getGitRemoteUrl, parseGitRemote } from './project.js';
|
|
19
|
+
function getLocalHead(cwd) {
|
|
20
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd }).toString('utf-8').trim();
|
|
21
|
+
}
|
|
22
|
+
function isDirty(cwd) {
|
|
23
|
+
const out = execFileSync('git', ['status', '--porcelain'], { cwd }).toString('utf-8').trim();
|
|
24
|
+
return out.length > 0;
|
|
25
|
+
}
|
|
26
|
+
function getCurrentBranch(cwd) {
|
|
27
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
|
|
28
|
+
.toString('utf-8')
|
|
29
|
+
.trim();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Terse output helpers — NO internal-implementation leakage in user-visible strings.
|
|
33
|
+
*/
|
|
34
|
+
const ok = (msg) => console.log(' ' + pc.green('✓') + ' ' + msg);
|
|
35
|
+
const fail = (msg) => console.error(' ' + pc.red('✗') + ' ' + msg);
|
|
36
|
+
const info = (msg) => console.error(' ' + pc.yellow('⏱') + ' ' + msg);
|
|
37
|
+
export async function runSync(opts) {
|
|
38
|
+
const cwd = process.cwd();
|
|
39
|
+
if (!isGitRepo()) {
|
|
40
|
+
fail('Not inside a git repository');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const config = await loadConfig();
|
|
44
|
+
const token = config.hubToken;
|
|
45
|
+
const hubUrl = opts.hub || config.hubUrl;
|
|
46
|
+
if (!token || !hubUrl) {
|
|
47
|
+
fail('Not connected. Run `gnx connect <token>` first.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const api = new HubAPI(hubUrl, token);
|
|
51
|
+
// Step 1: Resolve cwd → registered repo
|
|
52
|
+
let entry = await resolveCwdToRepo(cwd);
|
|
53
|
+
// Step 2: If not registered locally, try to match by GitHub remote
|
|
54
|
+
if (!entry) {
|
|
55
|
+
const remoteUrl = getGitRemoteUrl();
|
|
56
|
+
const fullName = remoteUrl ? parseGitRemote(remoteUrl) : null;
|
|
57
|
+
if (!fullName) {
|
|
58
|
+
fail('Could not determine the GitHub remote for this directory');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const hubRepos = await api
|
|
62
|
+
.listRepos()
|
|
63
|
+
.catch(() => []);
|
|
64
|
+
const matched = hubRepos.find((r) => r.fullName === fullName);
|
|
65
|
+
if (!matched) {
|
|
66
|
+
fail(`${pc.bold(fullName)} is not indexed on the hub.`);
|
|
67
|
+
console.error(' Add it at: ' + pc.cyan(hubUrl));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
entry = {
|
|
72
|
+
localPath: cwd,
|
|
73
|
+
fullName: matched.fullName,
|
|
74
|
+
hubRepoId: matched.id,
|
|
75
|
+
};
|
|
76
|
+
await upsertRegistryEntry(entry);
|
|
77
|
+
}
|
|
78
|
+
// entry is guaranteed non-null at this point (process.exit above on the failure path)
|
|
79
|
+
const repo = entry;
|
|
80
|
+
// Step 3: Compute local state
|
|
81
|
+
const localHead = getLocalHead(cwd);
|
|
82
|
+
const dirty = isDirty(cwd);
|
|
83
|
+
// Step 4: Short-circuit via hub meta (saves an upload if clean and matching)
|
|
84
|
+
try {
|
|
85
|
+
const meta = await api.meta(repo.hubRepoId);
|
|
86
|
+
if (meta.last_commit === localHead && !dirty) {
|
|
87
|
+
ok('Up to date');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Meta fetch failed; proceed with upload anyway (network hiccup, etc.)
|
|
93
|
+
}
|
|
94
|
+
// Step 5: Build tarball and upload
|
|
95
|
+
process.stdout.write(' ' + pc.cyan('→') + ' Syncing...');
|
|
96
|
+
const tarStream = buildTarballStream(cwd, { includeDirty: true });
|
|
97
|
+
let result;
|
|
98
|
+
try {
|
|
99
|
+
result = await api.sync(repo.hubRepoId, {
|
|
100
|
+
metadata: {
|
|
101
|
+
local_head: localHead,
|
|
102
|
+
local_branch: getCurrentBranch(cwd),
|
|
103
|
+
dirty,
|
|
104
|
+
},
|
|
105
|
+
tarball: tarStream,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
110
|
+
// Differentiate retry-later states from real failures so an LLM
|
|
111
|
+
// (or human) reading the transcript can tell whether sync is
|
|
112
|
+
// broken or just deferred.
|
|
113
|
+
if (err?.statusCode === 503) {
|
|
114
|
+
info('Hub busy — retry in a few minutes.');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (err?.statusCode === 409) {
|
|
119
|
+
info('Indexing in progress — retry shortly.');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
fail('Sync failed: ' + (err.message || String(err)));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Clear the progress line
|
|
128
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
129
|
+
await upsertRegistryEntry({
|
|
130
|
+
...repo,
|
|
131
|
+
lastSyncedSha: localHead,
|
|
132
|
+
lastSyncedAt: new Date().toISOString(),
|
|
133
|
+
});
|
|
134
|
+
if (result.status === 'already_fresh') {
|
|
135
|
+
ok('Up to date');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!opts.wait || !result.job_id) {
|
|
139
|
+
ok('Synced');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Step 6: Poll for completion
|
|
143
|
+
const jobId = result.job_id;
|
|
144
|
+
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
145
|
+
let frame = 0;
|
|
146
|
+
// eslint-disable-next-line no-constant-condition
|
|
147
|
+
while (true) {
|
|
148
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
149
|
+
try {
|
|
150
|
+
const status = await api.syncStatus(repo.hubRepoId, jobId);
|
|
151
|
+
if (status.status === 'done') {
|
|
152
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
153
|
+
ok('Synced');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (status.status === 'failed') {
|
|
157
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
158
|
+
fail('Indexing failed');
|
|
159
|
+
process.exit(1);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
process.stdout.write(`\r ${pc.cyan(spinnerFrames[frame % spinnerFrames.length])} Syncing...`);
|
|
163
|
+
frame++;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Network hiccup, keep polling
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tarball builder for gnx sync uploads.
|
|
3
|
+
*
|
|
4
|
+
* Uses `git ls-files` to pick files so .gitignore is respected automatically.
|
|
5
|
+
* Optionally includes untracked files via `git ls-files --others --exclude-standard`.
|
|
6
|
+
* Returns a tar-stream Readable so callers can pipe through gzip and HTTP upload
|
|
7
|
+
* without buffering the whole tarball in memory.
|
|
8
|
+
*/
|
|
9
|
+
import { Readable } from 'stream';
|
|
10
|
+
export interface TarballOptions {
|
|
11
|
+
includeDirty: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Build a tar-stream Readable containing the repo's tracked files
|
|
15
|
+
* (and optionally untracked files).
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildTarballStream(repoRoot: string, opts: TarballOptions): Readable;
|
package/dist/tarball.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tarball builder for gnx sync uploads.
|
|
3
|
+
*
|
|
4
|
+
* Uses `git ls-files` to pick files so .gitignore is respected automatically.
|
|
5
|
+
* Optionally includes untracked files via `git ls-files --others --exclude-standard`.
|
|
6
|
+
* Returns a tar-stream Readable so callers can pipe through gzip and HTTP upload
|
|
7
|
+
* without buffering the whole tarball in memory.
|
|
8
|
+
*/
|
|
9
|
+
import fsp from 'fs/promises';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import tar from 'tar-stream';
|
|
13
|
+
/**
|
|
14
|
+
* Hardcoded exclusions regardless of .gitignore (belt-and-suspenders).
|
|
15
|
+
* .git is already excluded by `git ls-files`. These cover edge cases where
|
|
16
|
+
* users might have tracked vendor/build directories they didn't mean to ship.
|
|
17
|
+
*/
|
|
18
|
+
const HARD_EXCLUDES = [
|
|
19
|
+
/(\/|^)node_modules\//,
|
|
20
|
+
/^target\//,
|
|
21
|
+
/^dist\//,
|
|
22
|
+
/^build\//,
|
|
23
|
+
/^\.venv\//,
|
|
24
|
+
/^\.git\//,
|
|
25
|
+
];
|
|
26
|
+
function listFiles(repoRoot, includeDirty) {
|
|
27
|
+
const tracked = execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot })
|
|
28
|
+
.toString('utf-8')
|
|
29
|
+
.split('\0')
|
|
30
|
+
.filter((s) => s.length > 0);
|
|
31
|
+
let all = tracked;
|
|
32
|
+
if (includeDirty) {
|
|
33
|
+
const untracked = execFileSync('git', ['ls-files', '-z', '--others', '--exclude-standard'], {
|
|
34
|
+
cwd: repoRoot,
|
|
35
|
+
})
|
|
36
|
+
.toString('utf-8')
|
|
37
|
+
.split('\0')
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
all = [...tracked, ...untracked];
|
|
40
|
+
}
|
|
41
|
+
return all.filter((f) => !HARD_EXCLUDES.some((re) => re.test(f)));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build a tar-stream Readable containing the repo's tracked files
|
|
45
|
+
* (and optionally untracked files).
|
|
46
|
+
*/
|
|
47
|
+
export function buildTarballStream(repoRoot, opts) {
|
|
48
|
+
const pack = tar.pack();
|
|
49
|
+
const files = listFiles(repoRoot, opts.includeDirty);
|
|
50
|
+
void (async () => {
|
|
51
|
+
for (const rel of files) {
|
|
52
|
+
try {
|
|
53
|
+
const abs = path.join(repoRoot, rel);
|
|
54
|
+
const stat = await fsp.stat(abs);
|
|
55
|
+
if (!stat.isFile())
|
|
56
|
+
continue;
|
|
57
|
+
const content = await fsp.readFile(abs);
|
|
58
|
+
pack.entry({ name: rel, size: content.length, mode: stat.mode & 0o777 }, content);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Skip unreadable files silently — git may have listed files that
|
|
62
|
+
// vanished or lack read permission. A missing file is not a sync failure.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pack.finalize();
|
|
66
|
+
})().catch((err) => {
|
|
67
|
+
try {
|
|
68
|
+
pack.destroy(err);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return pack;
|
|
75
|
+
}
|