@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.
- package/dist/esm/cli/commands/migrate.js +313 -31
- package/dist/esm/cli/commands/migrate.js.map +1 -1
- package/dist/esm/cli/main.js +66 -11
- package/dist/esm/cli/main.js.map +1 -1
- package/dist/types/cli/commands/migrate.d.ts +45 -7
- package/dist/types/cli/commands/migrate.d.ts.map +1 -1
- package/dist/types/cli/main.d.ts +4 -4
- package/package.json +1 -1
- package/src/cli/commands/migrate.ts +338 -32
- package/src/cli/main.ts +71 -11
|
@@ -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.
|
|
6
|
-
*
|
|
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
|
|
10
|
-
* gitd migrate repo
|
|
11
|
-
* gitd migrate issues
|
|
12
|
-
* gitd migrate pulls
|
|
13
|
-
* gitd migrate 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
|
|
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 =
|
|
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(
|
|
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(
|
|
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>
|
|
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
|
-
//
|
|
245
|
+
// Resolve <owner/repo> — from argument or local git remote
|
|
157
246
|
// ---------------------------------------------------------------------------
|
|
158
247
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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 } =
|
|
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:
|
|
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
|
|
378
|
+
// Step 4: pull requests + reviews.
|
|
204
379
|
const pullCount = await migratePullsInner(ctx, owner, repo);
|
|
205
380
|
|
|
206
|
-
// Step
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
|
47
|
-
* gitd migrate issues
|
|
48
|
-
* gitd migrate pulls
|
|
49
|
-
* gitd migrate 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
|
|
174
|
-
console.log(' migrate repo
|
|
175
|
-
console.log(' migrate issues
|
|
176
|
-
console.log(' migrate pulls
|
|
177
|
-
console.log(' migrate 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 (
|
|
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
|
|
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) => {
|