swadhinbiswas

[email protected]
2026-01-17 22:29:54 +0600 committed NaN years ago
c0d7bf5
commit c0d7bf5
4 files changed
+586 -4
β–Όcli/bin/och.ts
+59-2
1
1
#!/usr/bin/env node
2
2
/**
3
3
* OpenCodeHub CLI
4
- * Stack-first PR workflow tool
4
+ * Stack-first PR workflow tool with repo management
5
5
*/
6
6
7
7
import { Command } from "commander";
8
8
import chalk from "chalk";
9
9
import { stackCommands } from "../src/commands/stack/index.js";
10
10
import { authCommands } from "../src/commands/auth.js";
11
+import { pushRepo, cloneRepo, createRepo, listRepos } from "../src/commands/repo.js";
11
12
12
13
const program = new Command();
13
14
14
15
program
15
16
.name("och")
16
- .description("OpenCodeHub CLI - Stack-first PR workflows")
17
+ .description("OpenCodeHub CLI - Git hosting and stack-first PR workflows")
17
18
.version("1.0.0");
18
19
19
20
// Auth commands
28
29
.description("Manage stacked PRs")
29
30
.addCommand(stackCommands);
30
31
32
+// Repo commands
33
+const repoCommand = program
34
+ .command("repo")
35
+ .description("Repository management");
36
+
37
+repoCommand
38
+ .command("push")
39
+ .description("Push local repository to OpenCodeHub")
40
+ .option("-b, --branch <branch>", "Branch to push")
41
+ .option("-f, --force", "Force push (overwrite remote)")
42
+ .action(async (options) => {
43
+ await pushRepo(options);
44
+ });
45
+
46
+repoCommand
47
+ .command("clone <repo>")
48
+ .description("Clone a repository from OpenCodeHub")
49
+ .argument("[destination]", "Local directory to clone into")
50
+ .action(async (repo, destination) => {
51
+ await cloneRepo(repo, destination);
52
+ });
53
+
54
+repoCommand
55
+ .command("create <name>")
56
+ .description("Create a new repository on OpenCodeHub")
57
+ .option("-d, --description <desc>", "Repository description")
58
+ .option("-p, --private", "Make repository private")
59
+ .option("--no-init", "Don't initialize with README")
60
+ .action(async (name, options) => {
61
+ await createRepo({
62
+ name,
63
+ description: options.description,
64
+ visibility: options.private ? "private" : "public",
65
+ init: options.init,
66
+ });
67
+ });
68
+
69
+repoCommand
70
+ .command("list")
71
+ .alias("ls")
72
+ .description("List your repositories")
73
+ .action(async () => {
74
+ await listRepos();
75
+ });
76
+
77
+// Shorthand: och push (same as och repo push)
78
+program
79
+ .command("push")
80
+ .description("Push to OpenCodeHub (shorthand for 'repo push')")
81
+ .option("-b, --branch <branch>", "Branch to push")
82
+ .option("-f, --force", "Force push")
83
+ .action(async (options) => {
84
+ await pushRepo(options);
85
+ });
86
+
31
87
// Repo init
32
88
program
33
89
.command("init")
60
116
});
61
117
62
118
program.parse(process.argv);
119
+
63
120
β–Όcli/src/commands/repo.ts
+323-0
1
+/**
2
+ * CLI Repo Commands
3
+ * Push, clone, create repositories via API
4
+ */
5
+
6
+import { execSync, spawnSync } from "child_process";
7
+import * as fs from "fs";
8
+import * as path from "path";
9
+import { apiCall, getWithAuth, postWithAuth } from "../lib/api.js";
10
+import { getConfig } from "../lib/config.js";
11
+
12
+interface RepoInfo {
13
+ id: string;
14
+ name: string;
15
+ fullName: string;
16
+ description?: string;
17
+ visibility: string;
18
+ defaultBranch: string;
19
+ httpCloneUrl: string;
20
+}
21
+
22
+/**
23
+ * Get git root directory
24
+ */
25
+function getGitRoot(): string | null {
26
+ try {
27
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
28
+ encoding: "utf-8",
29
+ });
30
+ if (result.status === 0) {
31
+ return result.stdout.trim();
32
+ }
33
+ return null;
34
+ } catch {
35
+ return null;
36
+ }
37
+}
38
+
39
+/**
40
+ * Get current branch name
41
+ */
42
+function getCurrentBranch(): string {
43
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
44
+ encoding: "utf-8",
45
+ });
46
+ return result.stdout.trim() || "main";
47
+}
48
+
49
+/**
50
+ * Create a git bundle/packfile of commits to push
51
+ */
52
+function createBundle(gitRoot: string, branch: string): Buffer {
53
+ // Create a bundle of all commits on the branch
54
+ const bundlePath = path.join(gitRoot, ".git", "opencodehub-push.bundle");
55
+
56
+ try {
57
+ // Create bundle with all refs
58
+ execSync(`git bundle create "${bundlePath}" --all`, {
59
+ cwd: gitRoot,
60
+ stdio: "pipe",
61
+ });
62
+
63
+ const bundle = fs.readFileSync(bundlePath);
64
+ fs.unlinkSync(bundlePath); // Clean up
65
+ return bundle;
66
+ } catch (error) {
67
+ throw new Error(`Failed to create git bundle: ${error}`);
68
+ }
69
+}
70
+
71
+/**
72
+ * Get remote origin info to determine repo name
73
+ */
74
+function getRemoteInfo(gitRoot: string): { owner: string; repo: string } | null {
75
+ try {
76
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
77
+ cwd: gitRoot,
78
+ encoding: "utf-8",
79
+ });
80
+
81
+ if (result.status !== 0) {
82
+ return null;
83
+ }
84
+
85
+ const url = result.stdout.trim();
86
+
87
+ // Parse URL formats:
88
+ // https://host/owner/repo.git
89
+ // git@host:owner/repo.git
90
+ // ssh://git@host:port/owner/repo.git
91
+ const httpsMatch = url.match(/https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?$/);
92
+ const sshMatch = url.match(/git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/);
93
+ const sshUrlMatch = url.match(/ssh:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?$/);
94
+
95
+ const match = httpsMatch || sshMatch || sshUrlMatch;
96
+ if (match) {
97
+ return { owner: match[1], repo: match[2] };
98
+ }
99
+
100
+ return null;
101
+ } catch {
102
+ return null;
103
+ }
104
+}
105
+
106
+/**
107
+ * Push local repository to OpenCodeHub
108
+ */
109
+export async function pushRepo(options: {
110
+ remote?: string;
111
+ branch?: string;
112
+ force?: boolean;
113
+}): Promise<void> {
114
+ const config = getConfig();
115
+
116
+ if (!config.token) {
117
+ console.error("❌ Not logged in. Run 'opencodehub auth login' first.");
118
+ process.exit(1);
119
+ }
120
+
121
+ const gitRoot = getGitRoot();
122
+ if (!gitRoot) {
123
+ console.error("❌ Not in a git repository.");
124
+ process.exit(1);
125
+ }
126
+
127
+ // Determine repo to push to
128
+ let owner: string;
129
+ let repo: string;
130
+
131
+ const remoteInfo = getRemoteInfo(gitRoot);
132
+ if (remoteInfo) {
133
+ owner = remoteInfo.owner;
134
+ repo = remoteInfo.repo;
135
+ console.log(`πŸ“¦ Pushing to ${owner}/${repo}...`);
136
+ } else {
137
+ // Use directory name as repo name
138
+ repo = path.basename(gitRoot);
139
+
140
+ // Get user info
141
+ try {
142
+ const userInfo = await getWithAuth<{ data: { username: string } }>("/api/user");
143
+ owner = userInfo.data.username;
144
+ console.log(`πŸ“¦ Pushing to ${owner}/${repo}...`);
145
+ } catch {
146
+ console.error("❌ Failed to get user info. Check your login.");
147
+ process.exit(1);
148
+ }
149
+ }
150
+
151
+ const branch = options.branch || getCurrentBranch();
152
+ console.log(`🌿 Branch: ${branch}`);
153
+
154
+ // Create bundle
155
+ console.log("πŸ“¦ Creating git bundle...");
156
+ const bundle = createBundle(gitRoot, branch);
157
+ console.log(` Bundle size: ${(bundle.length / 1024).toFixed(1)} KB`);
158
+
159
+ // Upload to API
160
+ console.log("⬆️ Uploading to OpenCodeHub...");
161
+
162
+ try {
163
+ const response = await fetch(`${config.serverUrl}/api/repos/${owner}/${repo}/git/push`, {
164
+ method: "POST",
165
+ headers: {
166
+ "Authorization": `Bearer ${config.token}`,
167
+ "Content-Type": "application/octet-stream",
168
+ "X-Git-Branch": branch,
169
+ "X-Git-Force": options.force ? "true" : "false",
170
+ },
171
+ body: new Uint8Array(bundle),
172
+ });
173
+
174
+ const result = await response.json();
175
+
176
+ if (!response.ok) {
177
+ if (response.status === 404) {
178
+ console.error(`❌ Repository ${owner}/${repo} not found.`);
179
+ console.log(` Create it first: opencodehub repo create ${repo}`);
180
+ } else {
181
+ console.error(`❌ Push failed: ${result.error || response.statusText}`);
182
+ }
183
+ process.exit(1);
184
+ }
185
+
186
+ console.log(`βœ… Push successful!`);
187
+ if (result.data?.refs) {
188
+ result.data.refs.forEach((ref: string) => {
189
+ console.log(` β†’ ${ref}`);
190
+ });
191
+ }
192
+ } catch (error) {
193
+ console.error(`❌ Push failed: ${error}`);
194
+ process.exit(1);
195
+ }
196
+}
197
+
198
+/**
199
+ * Clone a repository from OpenCodeHub
200
+ */
201
+export async function cloneRepo(
202
+ repoName: string,
203
+ destination?: string
204
+): Promise<void> {
205
+ const config = getConfig();
206
+
207
+ // Parse owner/repo format
208
+ let owner: string;
209
+ let repo: string;
210
+
211
+ if (repoName.includes("/")) {
212
+ [owner, repo] = repoName.split("/");
213
+ } else {
214
+ // Get current user
215
+ try {
216
+ const userInfo = await getWithAuth<{ data: { username: string } }>("/api/user");
217
+ owner = userInfo.data.username;
218
+ repo = repoName;
219
+ } catch {
220
+ console.error("❌ Failed to get user info. Specify full repo name: owner/repo");
221
+ process.exit(1);
222
+ }
223
+ }
224
+
225
+ console.log(`πŸ“¦ Cloning ${owner}/${repo}...`);
226
+
227
+ // Get repo info
228
+ try {
229
+ const repoInfo = await getWithAuth<{ data: RepoInfo }>(`/api/repos/${owner}/${repo}`);
230
+ const cloneUrl = repoInfo.data.httpCloneUrl;
231
+ const dest = destination || repo;
232
+
233
+ // Clone using git
234
+ console.log(`⬇️ Cloning to ${dest}/...`);
235
+ execSync(`git clone "${cloneUrl}" "${dest}"`, { stdio: "inherit" });
236
+
237
+ console.log(`βœ… Cloned successfully!`);
238
+ } catch (error: any) {
239
+ console.error(`❌ Clone failed: ${error.message || error}`);
240
+ process.exit(1);
241
+ }
242
+}
243
+
244
+/**
245
+ * Create a new repository on OpenCodeHub
246
+ */
247
+export async function createRepo(options: {
248
+ name: string;
249
+ description?: string;
250
+ visibility?: "public" | "private";
251
+ init?: boolean;
252
+}): Promise<void> {
253
+ const config = getConfig();
254
+
255
+ if (!config.token) {
256
+ console.error("❌ Not logged in. Run 'opencodehub auth login' first.");
257
+ process.exit(1);
258
+ }
259
+
260
+ console.log(`πŸ“¦ Creating repository ${options.name}...`);
261
+
262
+ try {
263
+ const result = await postWithAuth<{ data: RepoInfo }>("/api/repos", {
264
+ name: options.name,
265
+ description: options.description,
266
+ visibility: options.visibility || "public",
267
+ readme: options.init !== false,
268
+ });
269
+
270
+ console.log(`βœ… Repository created: ${result.data.fullName}`);
271
+ console.log(` Clone URL: ${result.data.httpCloneUrl}`);
272
+
273
+ // If in a git repo, add as remote
274
+ const gitRoot = getGitRoot();
275
+ if (gitRoot) {
276
+ try {
277
+ execSync(`git remote add opencode "${result.data.httpCloneUrl}"`, {
278
+ cwd: gitRoot,
279
+ stdio: "pipe",
280
+ });
281
+ console.log(` Added remote 'opencode'`);
282
+ } catch {
283
+ // Remote might already exist
284
+ }
285
+ }
286
+ } catch (error: any) {
287
+ console.error(`❌ Failed to create repository: ${error.message || error}`);
288
+ process.exit(1);
289
+ }
290
+}
291
+
292
+/**
293
+ * List user's repositories
294
+ */
295
+export async function listRepos(): Promise<void> {
296
+ const config = getConfig();
297
+
298
+ if (!config.token) {
299
+ console.error("❌ Not logged in. Run 'opencodehub auth login' first.");
300
+ process.exit(1);
301
+ }
302
+
303
+ try {
304
+ const result = await getWithAuth<{ data: RepoInfo[] }>("/api/repos?owner=me");
305
+
306
+ if (result.data.length === 0) {
307
+ console.log("No repositories found.");
308
+ return;
309
+ }
310
+
311
+ console.log("Your repositories:\n");
312
+ result.data.forEach((repo) => {
313
+ const visibility = repo.visibility === "private" ? "πŸ”’" : "🌐";
314
+ console.log(`${visibility} ${repo.fullName}`);
315
+ if (repo.description) {
316
+ console.log(` ${repo.description}`);
317
+ }
318
+ });
319
+ } catch (error: any) {
320
+ console.error(`❌ Failed to list repositories: ${error.message || error}`);
321
+ process.exit(1);
322
+ }
323
+}
0
324
β–Όsrc/lib/ssh.ts
+3-2
7
7
import { generateKeyPairSync } from "crypto";
8
8
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
9
import { dirname, join } from "path";
10
-import { Server } from "ssh2";
10
+import ssh2 from "ssh2";
11
+const { Server } = ssh2;
11
12
import { logger } from "@/lib/logger";
12
13
13
14
export interface SSHServerConfig {
31
32
onPush?: (userId: string, repoPath: string, refs: string[]) => Promise<void>;
32
33
}
33
34
34
-export function createSSHServer(config: SSHServerConfig): Server {
35
+export function createSSHServer(config: SSHServerConfig): InstanceType<typeof Server> {
35
36
const {
36
37
port,
37
38
hostKeyPath,
38
39
β–Όsrc/pages/api/repos/[owner]/[repo]/git/push.ts
+201-0
1
+/**
2
+ * Git Push API Endpoint
3
+ * Receives git bundles from CLI and unpacks them to storage
4
+ */
5
+
6
+import type { APIRoute } from "astro";
7
+import { eq, and } from "drizzle-orm";
8
+import { getDatabase } from "@/db";
9
+import { repositories, users } from "@/db/schema";
10
+import { getUserFromRequest } from "@/lib/auth";
11
+import { unauthorized, notFound, badRequest, serverError, success } from "@/lib/api";
12
+import { canWriteRepo } from "@/lib/permissions";
13
+import { getRepoPath } from "@/lib/utils";
14
+import {
15
+ isCloudStorage,
16
+ acquireRepo,
17
+ releaseRepo,
18
+ getDiskPath,
19
+ parseStoragePath
20
+} from "@/lib/git-storage";
21
+import { spawn, execSync } from "child_process";
22
+import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs";
23
+import { join } from "path";
24
+
25
+export const POST: APIRoute = async ({ request, params }) => {
26
+ try {
27
+ // Authenticate user
28
+ const tokenPayload = await getUserFromRequest(request);
29
+ if (!tokenPayload) {
30
+ return unauthorized();
31
+ }
32
+
33
+ const { owner, repo } = params;
34
+ if (!owner || !repo) {
35
+ return badRequest("Missing owner or repo parameter");
36
+ }
37
+
38
+ // Remove .git suffix if present
39
+ const repoName = repo.replace(/\.git$/, "");
40
+
41
+ const db = getDatabase();
42
+
43
+ // Find the repository
44
+ const ownerUser = await db.query.users.findFirst({
45
+ where: eq(users.username, owner),
46
+ });
47
+
48
+ if (!ownerUser) {
49
+ return notFound("Repository not found");
50
+ }
51
+
52
+ const repository = await db.query.repositories.findFirst({
53
+ where: and(
54
+ eq(repositories.ownerId, ownerUser.id),
55
+ eq(repositories.name, repoName)
56
+ ),
57
+ });
58
+
59
+ if (!repository) {
60
+ return notFound("Repository not found");
61
+ }
62
+
63
+ // Check write permission
64
+ if (!(await canWriteRepo(tokenPayload.userId, repository))) {
65
+ return unauthorized("You don't have write access to this repository");
66
+ }
67
+
68
+ // Get git headers
69
+ const branch = request.headers.get("X-Git-Branch") || "main";
70
+ const force = request.headers.get("X-Git-Force") === "true";
71
+
72
+ // Read the bundle from request body
73
+ const bundleData = await request.arrayBuffer();
74
+ if (bundleData.byteLength === 0) {
75
+ return badRequest("Empty bundle");
76
+ }
77
+
78
+ // Get local path to the repository
79
+ let repoPath: string;
80
+
81
+ if (await isCloudStorage()) {
82
+ const parsed = parseStoragePath(repository.diskPath);
83
+ if (parsed) {
84
+ repoPath = await acquireRepo(parsed.owner, parsed.repoName);
85
+ } else {
86
+ // Fallback to standard path
87
+ repoPath = await acquireRepo(owner, repoName);
88
+ }
89
+ } else {
90
+ repoPath = repository.diskPath;
91
+ }
92
+
93
+ // Ensure repo directory exists
94
+ if (!existsSync(repoPath)) {
95
+ return notFound("Repository storage not found. Try creating the repository first.");
96
+ }
97
+
98
+ // Write bundle to temp file
99
+ const tempBundlePath = join(repoPath, "incoming.bundle");
100
+
101
+ try {
102
+ writeFileSync(tempBundlePath, Buffer.from(bundleData));
103
+
104
+ // Verify the bundle
105
+ const verifyResult = spawn("git", ["bundle", "verify", tempBundlePath], {
106
+ cwd: repoPath,
107
+ });
108
+
109
+ await new Promise<void>((resolve, reject) => {
110
+ let stderr = "";
111
+ verifyResult.stderr.on("data", (data) => {
112
+ stderr += data.toString();
113
+ });
114
+ verifyResult.on("close", (code) => {
115
+ if (code !== 0) {
116
+ reject(new Error(`Invalid bundle: ${stderr}`));
117
+ } else {
118
+ resolve();
119
+ }
120
+ });
121
+ });
122
+
123
+ // Fetch from the bundle
124
+ const refs: string[] = [];
125
+
126
+ // Get list of refs in bundle
127
+ const listOutput = execSync(`git bundle list-heads "${tempBundlePath}"`, {
128
+ cwd: repoPath,
129
+ encoding: "utf-8",
130
+ });
131
+
132
+ const bundleRefs = listOutput
133
+ .trim()
134
+ .split("\n")
135
+ .filter(Boolean)
136
+ .map((line) => {
137
+ const [sha, ref] = line.split(/\s+/);
138
+ return { sha, ref };
139
+ });
140
+
141
+ // Fetch each ref from bundle
142
+ for (const { sha, ref } of bundleRefs) {
143
+ try {
144
+ // Fetch the ref
145
+ execSync(`git fetch "${tempBundlePath}" "${ref}:${ref}"`, {
146
+ cwd: repoPath,
147
+ stdio: "pipe",
148
+ });
149
+ refs.push(ref);
150
+ } catch (fetchError) {
151
+ // Try force update if regular fetch fails
152
+ if (force) {
153
+ try {
154
+ execSync(`git fetch "${tempBundlePath}" "+${ref}:${ref}"`, {
155
+ cwd: repoPath,
156
+ stdio: "pipe",
157
+ });
158
+ refs.push(`${ref} (force)`);
159
+ } catch {
160
+ console.error(`Failed to fetch ${ref}:`, fetchError);
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ // Clean up temp bundle
167
+ unlinkSync(tempBundlePath);
168
+
169
+ // If using cloud storage, sync back
170
+ if (await isCloudStorage()) {
171
+ const parsed = parseStoragePath(repository.diskPath);
172
+ if (parsed) {
173
+ await releaseRepo(parsed.owner, parsed.repoName, true);
174
+ }
175
+ }
176
+
177
+ // Update repository timestamp
178
+ await db
179
+ .update(repositories)
180
+ .set({ updatedAt: new Date().toISOString() })
181
+ .where(eq(repositories.id, repository.id));
182
+
183
+ return success({
184
+ message: "Push successful",
185
+ refs,
186
+ branch,
187
+ });
188
+ } catch (error) {
189
+ // Clean up on error
190
+ if (existsSync(tempBundlePath)) {
191
+ try {
192
+ unlinkSync(tempBundlePath);
193
+ } catch { }
194
+ }
195
+ throw error;
196
+ }
197
+ } catch (error) {
198
+ console.error("Git push error:", error);
199
+ return serverError(`Push failed: ${error instanceof Error ? error.message : error}`);
200
+ }
201
+};
0
202