@enbox/gitd 0.0.3 → 0.2.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.
@@ -2,47 +2,136 @@
2
2
  * `gitd migrate` — import repository data from GitHub.
3
3
  *
4
4
  * Fetches repo metadata, issues, pull requests, and releases from the
5
- * GitHub REST API and creates corresponding DWN records. A `GITHUB_TOKEN`
6
- * env var is recommended for authenticated requests (higher rate limits).
5
+ * GitHub REST API and creates corresponding DWN records.
6
+ *
7
+ * Authentication is resolved automatically:
8
+ * 1. `GITHUB_TOKEN` env var (if set)
9
+ * 2. `gh auth token` (GitHub CLI, if installed and authenticated)
10
+ *
11
+ * The `<owner/repo>` argument is optional — if omitted, it is detected
12
+ * from the `origin` (or `github`) remote of the current git repository.
7
13
  *
8
14
  * Usage:
9
- * gitd migrate all <owner/repo> Import everything
10
- * gitd migrate repo <owner/repo> Import repo metadata only
11
- * gitd migrate issues <owner/repo> Import issues + comments
12
- * gitd migrate pulls <owner/repo> Import PRs as patches + reviews
13
- * gitd migrate releases <owner/repo> Import releases
15
+ * gitd migrate all [owner/repo] Import everything
16
+ * gitd migrate repo [owner/repo] Import repo metadata only
17
+ * gitd migrate issues [owner/repo] Import issues + comments
18
+ * gitd migrate pulls [owner/repo] Import PRs as patches + reviews
19
+ * gitd migrate releases [owner/repo] Import releases
14
20
  *
15
21
  * @module
16
22
  */
17
23
 
18
24
  import type { AgentContext } from '../agent.js';
19
25
 
26
+ import { createFullBundle } from '../../git-server/bundle-sync.js';
27
+ import { flagValue } from '../flags.js';
28
+ import { getRepoContext } from '../repo-context.js';
20
29
  import { getRepoContextId } from '../repo-context.js';
30
+ import { GitBackend } from '../../git-server/git-backend.js';
31
+ import { readGitRefs } from '../../git-server/ref-sync.js';
32
+ import { readFile, unlink } from 'node:fs/promises';
33
+ import { spawn, spawnSync } from 'node:child_process';
21
34
 
22
35
  // ---------------------------------------------------------------------------
23
- // GitHub API helpers
36
+ // GitHub auth — resolve a token from env or the gh CLI
24
37
  // ---------------------------------------------------------------------------
25
38
 
26
39
  const GITHUB_API = 'https://api.github.com';
27
40
 
41
+ /** Cached token so we only resolve once per process. */
42
+ let cachedToken: string | undefined;
43
+
44
+ /** Reset the cached token (for testing). */
45
+ export function resetTokenCache(): void {
46
+ cachedToken = undefined;
47
+ }
48
+
49
+ /**
50
+ * Resolve a GitHub API token.
51
+ *
52
+ * Priority:
53
+ * 1. `GITHUB_TOKEN` environment variable
54
+ * 2. `gh auth token` (GitHub CLI)
55
+ *
56
+ * Returns `undefined` when no token is available.
57
+ */
58
+ export function resolveGitHubToken(): string | undefined {
59
+ if (cachedToken !== undefined) { return cachedToken; }
60
+
61
+ // 1. Env var takes precedence.
62
+ const envToken = process.env.GITHUB_TOKEN;
63
+ if (envToken) {
64
+ cachedToken = envToken;
65
+ return cachedToken;
66
+ }
67
+
68
+ // 2. Try the GitHub CLI.
69
+ try {
70
+ const result = spawnSync('gh', ['auth', 'token'], {
71
+ stdio : ['pipe', 'pipe', 'pipe'],
72
+ timeout : 2_000,
73
+ });
74
+ const token = result.stdout?.toString().trim();
75
+ if (result.status === 0 && token) {
76
+ cachedToken = token;
77
+ return cachedToken;
78
+ }
79
+ } catch {
80
+ // gh not installed or not on PATH — fall through.
81
+ }
82
+
83
+ cachedToken = ''; // empty string = resolved, nothing found
84
+ return undefined;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // GitHub API helpers
89
+ // ---------------------------------------------------------------------------
90
+
28
91
  /** Headers for GitHub API requests. */
29
92
  function githubHeaders(): Record<string, string> {
30
93
  const headers: Record<string, string> = {
31
94
  'Accept' : 'application/vnd.github+json',
32
95
  'User-Agent' : 'gitd-migrate/0.1',
33
96
  };
34
- const token = process.env.GITHUB_TOKEN;
97
+ const token = resolveGitHubToken();
35
98
  if (token) { headers['Authorization'] = `Bearer ${token}`; }
36
99
  return headers;
37
100
  }
38
101
 
102
+ /**
103
+ * Build a user-friendly error message for GitHub API failures.
104
+ * For 404s without a token, hint at authentication.
105
+ */
106
+ function ghErrorMessage(status: number, body: string): string {
107
+ const base = `GitHub API ${status}: ${body.slice(0, 200)}`;
108
+
109
+ if (status === 404 && !resolveGitHubToken()) {
110
+ return (
111
+ `${base}\n\n` +
112
+ ` Hint: this may be a private repo. Authenticate with one of:\n` +
113
+ ` - gh auth login (GitHub CLI — recommended)\n` +
114
+ ` - export GITHUB_TOKEN=ghp_...\n`
115
+ );
116
+ }
117
+
118
+ if (status === 401 || status === 403) {
119
+ return (
120
+ `${base}\n\n` +
121
+ ` Hint: your token may lack the required scopes. Ensure it has "repo" access.\n`
122
+ );
123
+ }
124
+
125
+ return base;
126
+ }
127
+
39
128
  /** Fetch a single page from the GitHub API. Throws on non-2xx. */
40
129
  async function ghFetch<T>(path: string): Promise<T> {
41
130
  const url = `${GITHUB_API}${path}`;
42
131
  const res = await fetch(url, { headers: githubHeaders() });
43
132
  if (!res.ok) {
44
133
  const body = await res.text();
45
- throw new Error(`GitHub API ${res.status}: ${body.slice(0, 200)}`);
134
+ throw new Error(ghErrorMessage(res.status, body));
46
135
  }
47
136
  return res.json() as Promise<T>;
48
137
  }
@@ -59,7 +148,7 @@ async function ghFetchAll<T>(path: string, perPage = 100): Promise<T[]> {
59
148
  const res: Response = await fetch(url, { headers: githubHeaders() });
60
149
  if (!res.ok) {
61
150
  const body = await res.text();
62
- throw new Error(`GitHub API ${res.status}: ${body.slice(0, 200)}`);
151
+ throw new Error(ghErrorMessage(res.status, body));
63
152
  }
64
153
 
65
154
  const items = await res.json() as T[];
@@ -147,31 +236,88 @@ export async function migrateCommand(ctx: AgentContext, args: string[]): Promise
147
236
  case 'pulls': return migratePulls(ctx, rest);
148
237
  case 'releases': return migrateReleases(ctx, rest);
149
238
  default:
150
- console.error('Usage: gitd migrate <all|repo|issues|pulls|releases> <owner/repo>');
239
+ console.error('Usage: gitd migrate <all|repo|issues|pulls|releases> [owner/repo] [--repos <path>]');
151
240
  process.exit(1);
152
241
  }
153
242
  }
154
243
 
155
244
  // ---------------------------------------------------------------------------
156
- // Parse <owner/repo> argument
245
+ // Resolve <owner/repo> — from argument or local git remote
157
246
  // ---------------------------------------------------------------------------
158
247
 
159
- function parseGhRepo(args: string[]): { owner: string; repo: string } {
160
- const target = args[0];
161
- if (!target || !target.includes('/')) {
162
- console.error('Usage: gitd migrate <subcommand> <owner/repo>');
163
- process.exit(1);
248
+ /** Well-known GitHub remote URL patterns. */
249
+ const GH_SSH_RE = /^git@github\.com:(.+?)\/(.+?)(?:\.git)?$/;
250
+ const GH_HTTPS_RE = /^https?:\/\/github\.com\/(.+?)\/(.+?)(?:\.git)?$/;
251
+
252
+ /**
253
+ * Extract `owner/repo` from a GitHub remote URL.
254
+ * Supports both SSH (`git@github.com:owner/repo.git`) and
255
+ * HTTPS (`https://github.com/owner/repo.git`) forms.
256
+ */
257
+ export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
258
+ const ssh = GH_SSH_RE.exec(url);
259
+ if (ssh) { return { owner: ssh[1], repo: ssh[2] }; }
260
+
261
+ const https = GH_HTTPS_RE.exec(url);
262
+ if (https) { return { owner: https[1], repo: https[2] }; }
263
+
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Detect the GitHub `owner/repo` from the current directory's git remotes.
269
+ * Checks `origin` first, then `github`.
270
+ */
271
+ function detectGhRepoFromRemotes(): { owner: string; repo: string } | null {
272
+ for (const remoteName of ['origin', 'github']) {
273
+ try {
274
+ const result = spawnSync('git', ['remote', 'get-url', remoteName], {
275
+ stdio : ['pipe', 'pipe', 'pipe'],
276
+ timeout : 5_000,
277
+ });
278
+ const url = result.stdout?.toString().trim();
279
+ if (result.status === 0 && url) {
280
+ const parsed = parseGitHubRemote(url);
281
+ if (parsed) { return parsed; }
282
+ }
283
+ } catch {
284
+ // git not available or not a repo — continue.
285
+ }
164
286
  }
287
+ return null;
288
+ }
165
289
 
166
- const [owner, ...repoParts] = target.split('/');
167
- const repo = repoParts.join('/');
290
+ /**
291
+ * Resolve the GitHub `owner/repo` to migrate.
292
+ *
293
+ * 1. If `args[0]` contains a `/`, treat it as an explicit `owner/repo`.
294
+ * 2. Otherwise, detect from the current directory's git remotes.
295
+ * 3. If neither works, print an error and exit.
296
+ */
297
+ export function resolveGhRepo(args: string[]): { owner: string; repo: string } {
298
+ const target = args[0];
168
299
 
169
- if (!owner || !repo) {
170
- console.error('Invalid repository format. Use: owner/repo');
171
- process.exit(1);
300
+ // Explicit argument.
301
+ if (target && target.includes('/')) {
302
+ const [owner, ...repoParts] = target.split('/');
303
+ const repo = repoParts.join('/');
304
+ if (owner && repo) { return { owner, repo }; }
305
+ }
306
+
307
+ // Auto-detect from git remotes.
308
+ const detected = detectGhRepoFromRemotes();
309
+ if (detected) {
310
+ console.log(`Detected GitHub repo: ${detected.owner}/${detected.repo}`);
311
+ return detected;
172
312
  }
173
313
 
174
- return { owner, repo };
314
+ console.error(
315
+ 'Could not determine GitHub repository.\n' +
316
+ ' Either pass owner/repo explicitly:\n' +
317
+ ' gitd migrate <subcommand> <owner/repo>\n' +
318
+ ' Or run from inside a git repo with a GitHub remote.\n',
319
+ );
320
+ process.exit(1);
175
321
  }
176
322
 
177
323
  /**
@@ -187,23 +333,52 @@ function prependAuthor(body: string, ghLogin: string): string {
187
333
  // migrate all
188
334
  // ---------------------------------------------------------------------------
189
335
 
336
+ /**
337
+ * Resolve the repos base path from CLI flags or environment.
338
+ * Returns `null` when no explicit path was provided, so callers can
339
+ * decide whether to skip git content migration.
340
+ */
341
+ function resolveReposPath(args: string[]): string | null {
342
+ return flagValue(args, '--repos') ?? process.env.GITD_REPOS ?? null;
343
+ }
344
+
190
345
  async function migrateAll(ctx: AgentContext, args: string[]): Promise<void> {
191
- const { owner, repo } = parseGhRepo(args);
346
+ const { owner, repo } = resolveGhRepo(args);
347
+ const reposPath = resolveReposPath(args);
192
348
  const slug = `${owner}/${repo}`;
193
349
 
350
+ const token = resolveGitHubToken();
351
+ if (!token) {
352
+ console.log(`Warning: no GitHub token found. Private repos will fail.`);
353
+ console.log(` Run "gh auth login" or set GITHUB_TOKEN to authenticate.\n`);
354
+ }
355
+
194
356
  console.log(`Migrating ${slug} from GitHub...\n`);
195
357
 
196
358
  try {
197
359
  // Step 1: repo metadata.
198
360
  await migrateRepoInner(ctx, owner, repo);
199
361
 
200
- // Step 2: issues + comments.
362
+ // Step 2: git content (clone, bundle, refs).
363
+ if (reposPath) {
364
+ try {
365
+ await migrateGitContent(ctx, owner, repo, reposPath);
366
+ } catch (err) {
367
+ console.error(` Warning: git content migration failed: ${(err as Error).message}`);
368
+ console.error(' Metadata, issues, PRs, and releases will still be imported.');
369
+ console.error(' Re-run with a valid --repos path to retry git content migration.\n');
370
+ }
371
+ } else {
372
+ console.log(' Skipping git content — pass --repos <path> or set GITD_REPOS to include git data.');
373
+ }
374
+
375
+ // Step 3: issues + comments.
201
376
  const issueCount = await migrateIssuesInner(ctx, owner, repo);
202
377
 
203
- // Step 3: pull requests + reviews.
378
+ // Step 4: pull requests + reviews.
204
379
  const pullCount = await migratePullsInner(ctx, owner, repo);
205
380
 
206
- // Step 4: releases.
381
+ // Step 5: releases.
207
382
  const releaseCount = await migrateReleasesInner(ctx, owner, repo);
208
383
 
209
384
  console.log(`\nMigration complete: ${slug}`);
@@ -221,9 +396,15 @@ async function migrateAll(ctx: AgentContext, args: string[]): Promise<void> {
221
396
  // ---------------------------------------------------------------------------
222
397
 
223
398
  async function migrateRepo(ctx: AgentContext, args: string[]): Promise<void> {
224
- const { owner, repo } = parseGhRepo(args);
399
+ const { owner, repo } = resolveGhRepo(args);
400
+ const reposPath = resolveReposPath(args);
225
401
  try {
226
402
  await migrateRepoInner(ctx, owner, repo);
403
+ if (reposPath) {
404
+ await migrateGitContent(ctx, owner, repo, reposPath);
405
+ } else {
406
+ console.log(' Skipping git content — pass --repos <path> or set GITD_REPOS to include git data.');
407
+ }
227
408
  } catch (err) {
228
409
  console.error(`Failed to migrate repo: ${(err as Error).message}`);
229
410
  process.exit(1);
@@ -267,12 +448,137 @@ async function migrateRepoInner(ctx: AgentContext, owner: string, repo: string):
267
448
  console.log(` Source: ${gh.html_url}`);
268
449
  }
269
450
 
451
+ // ---------------------------------------------------------------------------
452
+ // migrate git content (clone + bundle + refs)
453
+ // ---------------------------------------------------------------------------
454
+
455
+ /**
456
+ * Clone the GitHub repository as a bare repo, create a full git bundle,
457
+ * upload it to DWN, and sync all refs to DWN records.
458
+ *
459
+ * This is the "git content" migration step that turns the metadata-only
460
+ * repo record into a fully cloneable repo.
461
+ */
462
+ async function migrateGitContent(
463
+ ctx: AgentContext,
464
+ owner: string,
465
+ repo: string,
466
+ reposPath: string,
467
+ ): Promise<void> {
468
+ const slug = `${owner}/${repo}`;
469
+ console.log(`Importing git content from ${slug}...`);
470
+
471
+ const backend = new GitBackend({ basePath: reposPath });
472
+ const repoPath = backend.repoPath(ctx.did, repo);
473
+
474
+ // Step 1: Clone bare repo from GitHub (or skip if already on disk).
475
+ if (backend.exists(ctx.did, repo)) {
476
+ console.log(` Bare repo already exists at ${repoPath} — skipping clone.`);
477
+ } else {
478
+ const cloneUrl = buildCloneUrl(owner, repo);
479
+ console.log(` Cloning ${cloneUrl} → ${repoPath}`);
480
+ await cloneBare(cloneUrl, repoPath);
481
+ console.log(' Clone complete.');
482
+ }
483
+
484
+ // Step 2: Create full git bundle.
485
+ console.log(' Creating git bundle...');
486
+ const bundleInfo = await createFullBundle(repoPath);
487
+ console.log(` Bundle: ${bundleInfo.size} bytes, ${bundleInfo.refCount} ref(s), tip: ${bundleInfo.tipCommit.slice(0, 8)}`);
488
+
489
+ // Step 3: Upload bundle to DWN.
490
+ const { contextId: repoContextId, visibility } = await getRepoContext(ctx);
491
+ const encrypt = visibility === 'private';
492
+
493
+ try {
494
+ const bundleData = new Uint8Array(await readFile(bundleInfo.path));
495
+
496
+ const { status } = await ctx.repo.records.create('repo/bundle', {
497
+ data : bundleData,
498
+ dataFormat : 'application/x-git-bundle',
499
+ tags : {
500
+ tipCommit : bundleInfo.tipCommit,
501
+ isFull : true,
502
+ refCount : bundleInfo.refCount,
503
+ size : bundleInfo.size,
504
+ },
505
+ parentContextId : repoContextId,
506
+ encryption : encrypt,
507
+ } as any);
508
+
509
+ if (status.code >= 300) {
510
+ throw new Error(`Failed to create bundle record: ${status.code} ${status.detail}`);
511
+ }
512
+ console.log(' Bundle uploaded to DWN.');
513
+ } finally {
514
+ await unlink(bundleInfo.path).catch(() => {});
515
+ }
516
+
517
+ // Step 4: Sync git refs to DWN.
518
+ console.log(' Syncing refs to DWN...');
519
+ const gitRefs = await readGitRefs(repoPath);
520
+
521
+ let refCount = 0;
522
+ for (const ref of gitRefs) {
523
+ const { status } = await ctx.refs.records.create('repo/ref', {
524
+ data : { name: ref.name, target: ref.target, type: ref.type },
525
+ tags : { name: ref.name, type: ref.type, target: ref.target },
526
+ parentContextId : repoContextId,
527
+ });
528
+
529
+ if (status.code >= 300) {
530
+ console.error(` Failed to sync ref ${ref.name}: ${status.code} ${status.detail}`);
531
+ continue;
532
+ }
533
+ refCount++;
534
+ }
535
+
536
+ console.log(` Synced ${refCount} ref(s) to DWN.`);
537
+ console.log(` Git content migration complete.`);
538
+ }
539
+
540
+ /**
541
+ * Build the clone URL for a GitHub repository.
542
+ * Uses the token (if available) for authenticated HTTPS clone.
543
+ */
544
+ function buildCloneUrl(owner: string, repo: string): string {
545
+ const token = resolveGitHubToken();
546
+ if (token) {
547
+ return `https://${token}@github.com/${owner}/${repo}.git`;
548
+ }
549
+ return `https://github.com/${owner}/${repo}.git`;
550
+ }
551
+
552
+ /**
553
+ * Clone a git repository as a bare repo using `git clone --bare`.
554
+ */
555
+ function cloneBare(url: string, destPath: string): Promise<void> {
556
+ return new Promise((resolve, reject) => {
557
+ const child = spawn('git', ['clone', '--bare', url, destPath], {
558
+ stdio: ['pipe', 'pipe', 'pipe'],
559
+ });
560
+
561
+ const stderrChunks: Buffer[] = [];
562
+ child.stderr!.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
563
+
564
+ child.on('error', reject);
565
+ child.on('exit', (code: number | null) => {
566
+ if (code !== 0) {
567
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
568
+ reject(new Error(`git clone --bare failed (exit ${code}): ${stderr}`));
569
+ } else {
570
+ resolve();
571
+ }
572
+ });
573
+ });
574
+ }
575
+
270
576
  // ---------------------------------------------------------------------------
271
577
  // migrate issues
272
578
  // ---------------------------------------------------------------------------
273
579
 
274
580
  async function migrateIssues(ctx: AgentContext, args: string[]): Promise<void> {
275
- const { owner, repo } = parseGhRepo(args);
581
+ const { owner, repo } = resolveGhRepo(args);
276
582
  try {
277
583
  const count = await migrateIssuesInner(ctx, owner, repo);
278
584
  console.log(`\nImported ${count} issue${count !== 1 ? 's' : ''}.`);
@@ -356,7 +662,7 @@ async function migrateIssuesInner(ctx: AgentContext, owner: string, repo: string
356
662
  // ---------------------------------------------------------------------------
357
663
 
358
664
  async function migratePulls(ctx: AgentContext, args: string[]): Promise<void> {
359
- const { owner, repo } = parseGhRepo(args);
665
+ const { owner, repo } = resolveGhRepo(args);
360
666
  try {
361
667
  const count = await migratePullsInner(ctx, owner, repo);
362
668
  console.log(`\nImported ${count} patch${count !== 1 ? 'es' : ''}.`);
@@ -467,7 +773,7 @@ async function migratePullsInner(ctx: AgentContext, owner: string, repo: string)
467
773
  // ---------------------------------------------------------------------------
468
774
 
469
775
  async function migrateReleases(ctx: AgentContext, args: string[]): Promise<void> {
470
- const { owner, repo } = parseGhRepo(args);
776
+ const { owner, repo } = resolveGhRepo(args);
471
777
  try {
472
778
  const count = await migrateReleasesInner(ctx, owner, repo);
473
779
  console.log(`\nImported ${count} release${count !== 1 ? 's' : ''}.`);
package/src/cli/main.ts CHANGED
@@ -43,10 +43,10 @@
43
43
  * gitd social star <did> Star a repo
44
44
  * gitd social follow <did> Follow a user
45
45
  * gitd notification list [--unread] List notifications
46
- * gitd migrate all <owner/repo> Import everything from GitHub
47
- * gitd migrate issues <owner/repo> Import issues + comments
48
- * gitd migrate pulls <owner/repo> Import PRs as patches
49
- * gitd migrate releases <owner/repo> Import releases
46
+ * gitd migrate all [owner/repo] Import everything from GitHub
47
+ * gitd migrate issues [owner/repo] Import issues + comments
48
+ * gitd migrate pulls [owner/repo] Import PRs as patches
49
+ * gitd migrate releases [owner/repo] Import releases
50
50
  * gitd web [--port <port>] Start the read-only web UI
51
51
  * gitd indexer [--port] [--interval] [--seed] Start the indexer service
52
52
  * gitd daemon [--config <path>] [--only ...] Start unified shim daemon
@@ -69,6 +69,7 @@
69
69
  import { ciCommand } from './commands/ci.js';
70
70
  import { cloneCommand } from './commands/clone.js';
71
71
  import { connectAgent } from './agent.js';
72
+ import { createRequire } from 'node:module';
72
73
  import { daemonCommand } from './commands/daemon.js';
73
74
  import { githubApiCommand } from './commands/github-api.js';
74
75
  import { indexerCommand } from '../indexer/main.js';
@@ -170,11 +171,11 @@ function printUsage(): void {
170
171
  console.log(' notification read <id> Mark as read');
171
172
  console.log(' notification clear Clear read notifications');
172
173
  console.log('');
173
- console.log(' migrate all <owner/repo> Import everything from GitHub');
174
- console.log(' migrate repo <owner/repo> Import repo metadata');
175
- console.log(' migrate issues <owner/repo> Import issues + comments');
176
- console.log(' migrate pulls <owner/repo> Import PRs as patches + reviews');
177
- console.log(' migrate releases <owner/repo> Import releases');
174
+ console.log(' migrate all [owner/repo] Import everything from GitHub');
175
+ console.log(' migrate repo [owner/repo] Import repo metadata');
176
+ console.log(' migrate issues [owner/repo] Import issues + comments');
177
+ console.log(' migrate pulls [owner/repo] Import PRs as patches + reviews');
178
+ console.log(' migrate releases [owner/repo] Import releases');
178
179
  console.log('');
179
180
  console.log(' web [--port <port>] Start read-only web UI (default: 8080)');
180
181
  console.log('');
@@ -204,7 +205,7 @@ function printUsage(): void {
204
205
  console.log(' GITD_NPM_SHIM_PORT npm shim port (default: 4873)');
205
206
  console.log(' GITD_GO_SHIM_PORT Go proxy shim port (default: 4874)');
206
207
  console.log(' GITD_OCI_SHIM_PORT OCI registry shim port (default: 5555)');
207
- console.log(' GITHUB_TOKEN GitHub API token for migration (optional, higher rate limits)');
208
+ console.log(' GITHUB_TOKEN GitHub API token for migration (auto-detected from gh CLI)');
208
209
  }
209
210
 
210
211
  // ---------------------------------------------------------------------------
@@ -216,8 +217,49 @@ async function getPassword(): Promise<string> {
216
217
  const env = process.env.GITD_PASSWORD;
217
218
  if (env) { return env; }
218
219
 
219
- // Interactive prompt via stdin.
220
+ // Interactive prompt hide input when running in a TTY.
220
221
  process.stdout.write('Vault password: ');
222
+
223
+ if (process.stdin.isTTY) {
224
+ // Raw mode: read character-by-character, echo nothing.
225
+ const password = await new Promise<string>((resolve) => {
226
+ let buf = '';
227
+ process.stdin.setRawMode(true);
228
+ process.stdin.setEncoding('utf8');
229
+ process.stdin.resume();
230
+
231
+ const onData = (ch: string): void => {
232
+ const code = ch.charCodeAt(0);
233
+
234
+ if (ch === '\r' || ch === '\n') {
235
+ // Enter — done.
236
+ process.stdin.setRawMode(false);
237
+ process.stdin.pause();
238
+ process.stdin.removeListener('data', onData);
239
+ process.stdout.write('\n');
240
+ resolve(buf);
241
+ } else if (code === 3) {
242
+ // Ctrl-C — abort.
243
+ process.stdin.setRawMode(false);
244
+ process.stdout.write('\n');
245
+ process.exit(130);
246
+ } else if (code === 127 || code === 8) {
247
+ // Backspace / Delete.
248
+ if (buf.length > 0) {
249
+ buf = buf.slice(0, -1);
250
+ }
251
+ } else if (code >= 32) {
252
+ // Printable character.
253
+ buf += ch;
254
+ }
255
+ };
256
+
257
+ process.stdin.on('data', onData);
258
+ });
259
+ return password;
260
+ }
261
+
262
+ // Non-TTY fallback (piped input).
221
263
  const response = await new Promise<string>((resolve) => {
222
264
  let buf = '';
223
265
  process.stdin.setEncoding('utf8');
@@ -234,7 +276,18 @@ async function getPassword(): Promise<string> {
234
276
  // Main
235
277
  // ---------------------------------------------------------------------------
236
278
 
279
+ function printVersion(): void {
280
+ const require = createRequire(import.meta.url);
281
+ const pkg = require('../../package.json') as { version: string };
282
+ console.log(`gitd ${pkg.version}`);
283
+ }
284
+
237
285
  async function main(): Promise<void> {
286
+ if (command === '--version' || command === '-v' || command === 'version') {
287
+ printVersion();
288
+ return;
289
+ }
290
+
238
291
  if (!command || command === 'help' || command === '--help' || command === '-h') {
239
292
  printUsage();
240
293
  return;
@@ -342,6 +395,13 @@ async function main(): Promise<void> {
342
395
  printUsage();
343
396
  process.exit(1);
344
397
  }
398
+
399
+ // One-shot commands reach here after completing. The Web5 agent keeps
400
+ // LevelDB stores and other handles open, which prevents the process from
401
+ // exiting naturally. Long-running commands (serve, web, daemon, indexer,
402
+ // github-api, shim) never reach this point because they block on an
403
+ // infinite promise internally.
404
+ process.exit(0);
345
405
  }
346
406
 
347
407
  main().catch((err: Error) => {