gitnexushub 0.2.12 → 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.
@@ -9,10 +9,15 @@ import path from 'path';
9
9
  import fs from 'fs/promises';
10
10
  import { readJsonFile, writeJsonFile } from '../utils.js';
11
11
  import { HUB_SKILLS } from '../content.js';
12
+ import { computeFingerprint, getDeviceName } from '../fingerprint.js';
12
13
  function getMcpConfig(hubUrl, token) {
13
14
  return {
14
15
  serverUrl: `${hubUrl}/mcp`,
15
- headers: { Authorization: `Bearer ${token}` },
16
+ headers: {
17
+ Authorization: `Bearer ${token}`,
18
+ 'X-Device-Fingerprint': computeFingerprint(),
19
+ 'X-Device-Name': getDeviceName(),
20
+ },
16
21
  };
17
22
  }
18
23
  async function configure(hubUrl, token) {
@@ -25,7 +30,11 @@ async function configure(hubUrl, token) {
25
30
  const overrodeCli = !!(existing.mcpServers.gitnexus && 'command' in existing.mcpServers.gitnexus);
26
31
  existing.mcpServers.gitnexus = getMcpConfig(hubUrl, token);
27
32
  await writeJsonFile(mcpPath, existing);
28
- return { success: true, message: 'MCP configured in ~/.codeium/windsurf/mcp_config.json', overrodeCli };
33
+ return {
34
+ success: true,
35
+ message: 'MCP configured in ~/.codeium/windsurf/mcp_config.json',
36
+ overrodeCli,
37
+ };
29
38
  }
30
39
  catch (err) {
31
40
  return { success: false, message: `Failed: ${err.message}` };
@@ -69,7 +78,9 @@ async function removeSkills() {
69
78
  await fs.rm(path.join(skillsDir, name), { recursive: true, force: true });
70
79
  removed++;
71
80
  }
72
- catch { /* already gone */ }
81
+ catch {
82
+ /* already gone */
83
+ }
73
84
  }
74
85
  return removed;
75
86
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Device Fingerprint
3
+ *
4
+ * Computes a stable fingerprint for the current device.
5
+ * Used to bind device tokens to specific machines.
6
+ * Same algorithm as gitnexus/src/cli/auth.ts:computeFingerprint().
7
+ */
8
+ /** SHA-256 hash of hostname:username:platform:arch, truncated to 16 hex chars. */
9
+ export declare function computeFingerprint(): string;
10
+ /** Human-readable device name: USER@HOSTNAME */
11
+ export declare function getDeviceName(): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Device Fingerprint
3
+ *
4
+ * Computes a stable fingerprint for the current device.
5
+ * Used to bind device tokens to specific machines.
6
+ * Same algorithm as gitnexus/src/cli/auth.ts:computeFingerprint().
7
+ */
8
+ import os from 'os';
9
+ import crypto from 'crypto';
10
+ /** SHA-256 hash of hostname:username:platform:arch, truncated to 16 hex chars. */
11
+ export function computeFingerprint() {
12
+ const identity = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}`;
13
+ return crypto.createHash('sha256').update(identity).digest('hex').slice(0, 16);
14
+ }
15
+ /** Human-readable device name: USER@HOSTNAME */
16
+ export function getDeviceName() {
17
+ return `${os.userInfo().username}@${os.hostname()}`;
18
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-editor hook installers.
3
+ *
4
+ * Writes idempotent editor-specific hook config files that point at
5
+ * the shipped gitnexus-enterprise-hook.cjs script. Called from the
6
+ * `gnx connect` flow after the MCP config is written.
7
+ */
8
+ export interface InstallOptions {
9
+ /** Defaults to os.homedir(). Injectable for tests. */
10
+ homeDir?: string;
11
+ /** Absolute path to the shipped hook script. */
12
+ hookScriptPath: string;
13
+ }
14
+ /**
15
+ * Install the Claude Code PreToolUse + PostToolUse hooks.
16
+ * Returns the absolute path to the written hooks.json.
17
+ */
18
+ export declare function installClaudeCodeHook(opts: InstallOptions): Promise<string>;
19
+ /**
20
+ * Install the Cursor beforeShellExecution hook.
21
+ * Returns the absolute path to the written hooks.json.
22
+ */
23
+ export declare function installCursorHook(opts: InstallOptions): Promise<string>;
24
+ /**
25
+ * Install the OpenCode plugin stub.
26
+ * Returns the absolute path to the written TypeScript file.
27
+ *
28
+ * The plugin is currently a stub — the actual tool.before / tool.after
29
+ * wiring delegating to gitnexus-enterprise-hook.cjs will be filled in
30
+ * when we have concrete users on OpenCode. For M4.2 we write the file
31
+ * so gnx connect reports success and the user has a template to extend.
32
+ */
33
+ export declare function installOpenCodeHook(opts: InstallOptions): Promise<string>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Per-editor hook installers.
3
+ *
4
+ * Writes idempotent editor-specific hook config files that point at
5
+ * the shipped gitnexus-enterprise-hook.cjs script. Called from the
6
+ * `gnx connect` flow after the MCP config is written.
7
+ */
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ function getHome(opts) {
12
+ return opts.homeDir ?? os.homedir();
13
+ }
14
+ async function writeJsonIdempotent(filePath, obj) {
15
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
16
+ const body = JSON.stringify(obj, null, 2) + '\n';
17
+ await fs.writeFile(filePath, body);
18
+ }
19
+ /**
20
+ * Install the Claude Code PreToolUse + PostToolUse hooks.
21
+ * Returns the absolute path to the written hooks.json.
22
+ */
23
+ export async function installClaudeCodeHook(opts) {
24
+ const home = getHome(opts);
25
+ const hooksDir = path.join(home, '.claude', 'plugins', 'gitnexus-enterprise', 'hooks');
26
+ const hooksJsonPath = path.join(hooksDir, 'hooks.json');
27
+ // Double-quote the absolute path so shell splitting doesn't break
28
+ // when the install dir contains a space (e.g. Windows profiles at
29
+ // `C:\Users\John Doe\...`). The hook spec is a shell-style command
30
+ // string, not an argv array, so quoting is the only knob available.
31
+ const quotedScriptPath = `"${opts.hookScriptPath}"`;
32
+ const config = {
33
+ hooks: {
34
+ PreToolUse: [
35
+ {
36
+ matcher: 'Grep|Glob|Bash',
37
+ hooks: [
38
+ {
39
+ type: 'command',
40
+ command: `node ${quotedScriptPath}`,
41
+ timeout: 5,
42
+ },
43
+ ],
44
+ },
45
+ ],
46
+ PostToolUse: [
47
+ {
48
+ matcher: 'Bash',
49
+ hooks: [
50
+ {
51
+ type: 'command',
52
+ command: `node ${quotedScriptPath}`,
53
+ timeout: 5,
54
+ },
55
+ ],
56
+ },
57
+ ],
58
+ },
59
+ };
60
+ await writeJsonIdempotent(hooksJsonPath, config);
61
+ return hooksJsonPath;
62
+ }
63
+ /**
64
+ * Install the Cursor beforeShellExecution hook.
65
+ * Returns the absolute path to the written hooks.json.
66
+ */
67
+ export async function installCursorHook(opts) {
68
+ const home = getHome(opts);
69
+ const hooksJsonPath = path.join(home, '.cursor', 'hooks.json');
70
+ // Same shell-quoting rationale as installClaudeCodeHook — the
71
+ // command is a shell string, not argv, so the absolute path must
72
+ // be quoted to survive spaces.
73
+ const config = {
74
+ beforeShellExecution: {
75
+ command: `node "${opts.hookScriptPath}"`,
76
+ timeout: 5000,
77
+ },
78
+ };
79
+ await writeJsonIdempotent(hooksJsonPath, config);
80
+ return hooksJsonPath;
81
+ }
82
+ /**
83
+ * Install the OpenCode plugin stub.
84
+ * Returns the absolute path to the written TypeScript file.
85
+ *
86
+ * The plugin is currently a stub — the actual tool.before / tool.after
87
+ * wiring delegating to gitnexus-enterprise-hook.cjs will be filled in
88
+ * when we have concrete users on OpenCode. For M4.2 we write the file
89
+ * so gnx connect reports success and the user has a template to extend.
90
+ */
91
+ export async function installOpenCodeHook(opts) {
92
+ const home = getHome(opts);
93
+ const pluginDir = path.join(home, '.opencode', 'plugin');
94
+ const pluginPath = path.join(pluginDir, 'gitnexus-enterprise.ts');
95
+ const content = `// GitNexus Enterprise OpenCode plugin
96
+ // Stub — delegates to gitnexus-enterprise-hook.cjs via child process.
97
+ // Hook script: ${opts.hookScriptPath}
98
+
99
+ export default {
100
+ async toolBefore(ctx: { tool: string }) {
101
+ if (!['Grep', 'Glob', 'Bash'].includes(ctx.tool)) return;
102
+ // TODO: spawn gitnexus-enterprise-hook.cjs with the tool context on stdin
103
+ // and forward additionalContext from stdout.
104
+ },
105
+ async toolAfter(ctx: { tool: string }) {
106
+ if (ctx.tool !== 'Bash') return;
107
+ // TODO: spawn gitnexus-enterprise-hook.cjs for PostToolUse staleness check
108
+ },
109
+ };
110
+ `;
111
+ await fs.mkdir(pluginDir, { recursive: true });
112
+ await fs.writeFile(pluginPath, content);
113
+ return pluginPath;
114
+ }
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 { loadConfig, saveConfig, clearConfig } from './config.js';
16
- import { HubAPI } from './api.js';
17
- import { isGitRepo, getGitRemoteUrl, parseGitRemote, matchRepo } from './project.js';
18
- import { writeProjectContext, removeProjectContext } from './context.js';
19
- import { generateConnectContext } from './content.js';
20
- import { detectInstalledEditors } from './editors/detect.js';
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
- program.parse();
149
- // ─── Connect Flow ─────────────────────────────────────────────────
150
- async function runConnect(tokenArg, opts) {
151
- const { api, hubUrl, token } = await resolveAuth(tokenArg, opts.hub);
152
- // ── Resolve editor ─────────────────────────────────────────────
153
- const config = await loadConfig();
154
- let editorId;
155
- if (opts.editor) {
156
- if (!(opts.editor in EDITORS)) {
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
- else if (!opts.skipProject) {
254
- console.log('');
255
- info('Not inside a git repo — skipping project context');
108
+ catch (err) {
109
+ console.error(' ' + pc.red('✗') + ' ' + (err.message || String(err)));
110
+ process.exit(1);
256
111
  }
257
- // ── Summary ────────────────────────────────────────────────────
258
- console.log('');
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>;
@@ -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>;