+
+
diff --git a/lib/services/repoService.ts b/lib/services/repoService.ts
new file mode 100644
index 0000000..27500eb
--- /dev/null
+++ b/lib/services/repoService.ts
@@ -0,0 +1,292 @@
+'use server'
+
+import { auth } from '@/lib/auth'
+import { prisma } from '@/lib/db'
+import { execCommand, TtydExecError } from '@/lib/util/ttyd-exec'
+
+export type RepoInitResult = {
+ success: boolean
+ message: string
+ code?: string
+}
+
+/**
+ * Helper to get project TTYD context and verify ownership.
+ * This ensures the project belongs to the requesting user before proceeding.
+ */
+async function getTtydContext(projectId: string, userId: string) {
+ // security measure
+ const project = await prisma.project.findFirst({
+ where: {
+ id: projectId,
+ userId: userId,
+ },
+ include: {
+ sandboxes: true,
+ environments: true,
+ },
+ })
+
+ if (!project) {
+ throw new Error('Project not found')
+ }
+
+ const sandbox = project.sandboxes[0]
+ if (!sandbox) {
+ throw new Error('Sandbox not found')
+ }
+
+ const ttydAccessToken = project.environments.find(
+ (env) => env.key === 'TTYD_ACCESS_TOKEN'
+ )?.value
+
+ if (!sandbox.ttydUrl || !ttydAccessToken) {
+ throw new Error('Sandbox configuration missing')
+ }
+
+ // Parse the ttydUrl to get base URL (without query params)
+ const ttydBaseUrl = new URL(sandbox.ttydUrl)
+ ttydBaseUrl.search = '' // Remove query params
+ const baseUrl = ttydBaseUrl.toString().replace(/\/$/, '')
+
+ return { baseUrl, accessToken: ttydAccessToken, project }
+}
+
+
+/**
+ * Initialize a git repository in the project's sandbox
+ * @param projectId - The ID of the project
+ */
+export async function initializeRepo(projectId: string): Promise {
+ const session = await auth()
+
+ if (!session) {
+ return { success: false, message: 'Unauthorized' }
+ }
+
+ try {
+ const { baseUrl, accessToken, project } = await getTtydContext(projectId, session.user.id)
+
+ // Create GitHub repo first
+ const repoResult = await createGithubRepo(project.name)
+ if (!repoResult.success) {
+ return { success: false, message: repoResult.message, code: repoResult.code }
+ }
+
+ // Save repo URL to database
+ await prisma.project.update({
+ where: { id: projectId },
+ data: { githubRepo: repoResult.repoUrl },
+ })
+
+ await runInitCommand(baseUrl, accessToken)
+
+ // Push the initial code to GitHub
+ const pushResult = await pushToGithub(projectId)
+ if (!pushResult.success) {
+ return { success: true, message: `Initialized locally but failed to push: ${pushResult.message}` }
+ }
+
+ return { success: true, message: 'Repository initialized and pushed successfully' }
+ } catch (error) {
+ console.error('Failed to initialize repo:', error)
+ const errorMessage = error instanceof TtydExecError ? error.message : 'Unknown error'
+ return { success: false, message: `Failed to initialize: ${errorMessage}` }
+ }
+}
+
+async function runInitCommand(baseUrl: string, accessToken: string) {
+ return execCommand(
+ baseUrl,
+ accessToken,
+ 'git init -b main && git add . && claude -p "commit all staged changes with a descriptive message" --dangerously-skip-permissions',
+ 300000
+ )
+
+}
+
+export type CreateRepoResult = {
+ success: boolean
+ message: string
+ repoUrl?: string
+ cloneUrl?: string
+ code?: string
+}
+
+/**
+ * Create a new GitHub repository for the user
+ * @param repoName - The name of the new repository
+ */
+export async function createGithubRepo(repoName: string): Promise {
+ const session = await auth()
+
+ if (!session) {
+ return { success: false, message: 'Unauthorized' }
+ }
+
+ try {
+ // Find UserIdentity for GitHub to get the token
+ const identity = await prisma.userIdentity.findFirst({
+ where: {
+ userId: session.user.id,
+ provider: 'GITHUB',
+ },
+ })
+
+ if (!identity) {
+ return { success: false, message: 'GitHub identity not found. Please link your GitHub account.', code: 'GITHUB_NOT_BOUND' }
+ }
+
+ const metadata = identity.metadata as { token?: string }
+ const token = metadata?.token
+
+ if (!token) {
+ return { success: false, message: 'GitHub token not found in identity metadata.', code: 'GITHUB_NOT_BOUND' }
+ }
+
+ // Call GitHub API to create repository
+ const response = await fetch('https://api.github.com/user/repos', {
+ method: 'POST',
+ headers: {
+ Authorization: `token ${token}`,
+ 'Content-Type': 'application/json',
+ Accept: 'application/vnd.github.v3+json',
+ },
+ body: JSON.stringify({
+ name: repoName,
+ private: true, // Default to private
+ description: 'Created by Fulling, Powered by Sealos',
+ auto_init: false, // Don't create README/LICENSE, we'll push existing code
+ }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ return {
+ success: false,
+ message: `GitHub API Error: ${errorData.message || response.statusText}`
+ }
+ }
+
+ const repoData = await response.json()
+
+ return {
+ success: true,
+ message: 'Repository created successfully',
+ repoUrl: repoData.html_url,
+ cloneUrl: repoData.clone_url,
+ }
+ } catch (error) {
+ console.error('Failed to create GitHub repo:', error)
+ return { success: false, message: `Failed to create repository: ${error instanceof Error ? error.message : String(error)}` }
+ }
+}
+
+/**
+ * Commit all changes in the project's sandbox
+ * @param projectId - The ID of the project
+ */
+export async function commitChanges(projectId: string): Promise {
+ const session = await auth()
+
+ if (!session) {
+ return { success: false, message: 'Unauthorized' }
+ }
+
+ try {
+ const { baseUrl, accessToken } = await getTtydContext(projectId, session.user.id)
+
+ await execCommand(
+ baseUrl,
+ accessToken,
+ 'git add . && claude -p "commit all staged changes with a descriptive message" --dangerously-skip-permissions',
+ )
+
+ // Push changes to GitHub
+ const pushResult = await pushToGithub(projectId)
+ if (!pushResult.success) {
+ // If authentication failed, we should return failure so the UI can prompt for binding
+ if (pushResult.code === 'GITHUB_NOT_BOUND') {
+ return { success: false, message: pushResult.message, code: pushResult.code }
+ }
+ return { success: true, message: `Committed locally but failed to push: ${pushResult.message}` }
+ }
+
+ return { success: true, message: 'Changes committed and pushed successfully' }
+ } catch (error) {
+ console.error('Failed to commit changes:', error)
+ const errorMessage = error instanceof TtydExecError ? error.message : 'Unknown error'
+ return { success: false, message: `Failed to commit: ${errorMessage}` }
+ }
+}
+
+/**
+ * Push local commits to GitHub
+ * @param projectId - The ID of the project
+ */
+export async function pushToGithub(projectId: string): Promise {
+ const session = await auth()
+
+ if (!session) {
+ return { success: false, message: 'Unauthorized' }
+ }
+
+ try {
+ const { baseUrl, accessToken, project } = await getTtydContext(projectId, session.user.id)
+
+ if (!project.githubRepo) {
+ return { success: false, message: 'No GitHub repository linked to this project' }
+ }
+
+ // Get GitHub token
+ const identity = await prisma.userIdentity.findFirst({
+ where: {
+ userId: session.user.id,
+ provider: 'GITHUB',
+ },
+ })
+
+ // Type checking for metadata token
+ const metadata = identity?.metadata as { token?: string } | undefined
+ const githubToken = metadata?.token
+
+ if (!githubToken) {
+ return { success: false, message: 'GitHub token not found', code: 'GITHUB_NOT_BOUND' }
+ }
+
+ // Validate GitHub URL format to prevent injection attacks
+ // Allow standard GitHub URLs: https://github.com/username/repo or https://github.com/username/repo.git
+ const githubUrlPattern = /^https:\/\/github\.com\/[a-zA-Z0-9-]+\/[a-zA-Z0-9-._]+(\.git)?$/
+ if (!githubUrlPattern.test(project.githubRepo)) {
+ return { success: false, message: 'Invalid GitHub repository URL' }
+ }
+
+ // Extract owner/repo from URL (e.g., https://github.com/owner/repo)
+ // We want to construct: https://oauth2:token@github.com/owner/repo.git
+ let repoUrlStr = project.githubRepo
+ if (!repoUrlStr.endsWith('.git')) {
+ repoUrlStr += '.git'
+ }
+
+ // Remove protocol to insert auth
+ const urlNoProtocol = repoUrlStr.replace(/^https?:\/\//, '')
+ const authUrl = `https://oauth2:${githubToken}@${urlNoProtocol}`
+
+ // Configure remote and push
+ // We use 'git remote set-url' if origin exists, or 'git remote add' if it doesn't
+ // SECURITY: Use single quotes around URL to prevent command injection
+ const command = `
+ (git remote get-url origin > /dev/null 2>&1 && git remote set-url origin '${authUrl}') || git remote add origin '${authUrl}' &&
+ git branch -M main &&
+ git push -u origin main
+ `.replace(/\n/g, ' ').trim()
+
+ await execCommand(baseUrl, accessToken, command, 300000)
+
+ return { success: true, message: 'Code pushed to GitHub successfully' }
+ } catch (error) {
+ console.error('Failed to push to GitHub:', error)
+ const errorMessage = error instanceof TtydExecError ? error.message : 'Unknown error'
+ return { success: false, message: `Failed to push: ${errorMessage}` }
+ }
+}
diff --git a/lib/util/ttyd-exec.ts b/lib/util/ttyd-exec.ts
index 5afc636..c37a40c 100644
--- a/lib/util/ttyd-exec.ts
+++ b/lib/util/ttyd-exec.ts
@@ -525,12 +525,14 @@ export async function executeTtydCommand(options: TtydExecOptions): Promise {
const result = await executeTtydCommand({
ttydUrl,
accessToken,
command,
+ timeoutMs,
})
if (result.timedOut) {
diff --git a/package.json b/package.json
index cb902d2..358918a 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.545.0",
"nanoid": "^5.1.6",
- "next": "16.0.7",
+ "next": "16.0.10",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6",
"pino": "^10.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0cfd6e2..6e9a78e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -99,11 +99,11 @@ importers:
specifier: ^5.1.6
version: 5.1.6
next:
- specifier: 16.0.7
- version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ specifier: 16.0.10
+ version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
next-auth:
specifier: ^5.0.0-beta.29
- version: 5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)
+ version: 5.0.0-beta.30(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -514,56 +514,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
- '@next/env@16.0.7':
- resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==}
+ '@next/env@16.0.10':
+ resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==}
'@next/eslint-plugin-next@16.0.7':
resolution: {integrity: sha512-hFrTNZcMEG+k7qxVxZJq3F32Kms130FAhG8lvw2zkKBgAcNOJIxlljNiCjGygvBshvaGBdf88q2CqWtnqezDHA==}
- '@next/swc-darwin-arm64@16.0.7':
- resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==}
+ '@next/swc-darwin-arm64@16.0.10':
+ resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.0.7':
- resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==}
+ '@next/swc-darwin-x64@16.0.10':
+ resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.0.7':
- resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==}
+ '@next/swc-linux-arm64-gnu@16.0.10':
+ resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-arm64-musl@16.0.7':
- resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==}
+ '@next/swc-linux-arm64-musl@16.0.10':
+ resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- '@next/swc-linux-x64-gnu@16.0.7':
- resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==}
+ '@next/swc-linux-x64-gnu@16.0.10':
+ resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-linux-x64-musl@16.0.7':
- resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==}
+ '@next/swc-linux-x64-musl@16.0.10':
+ resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- '@next/swc-win32-arm64-msvc@16.0.7':
- resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==}
+ '@next/swc-win32-arm64-msvc@16.0.10':
+ resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.0.7':
- resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==}
+ '@next/swc-win32-x64-msvc@16.0.10':
+ resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2577,8 +2577,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@16.0.7:
- resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==}
+ next@16.0.10:
+ resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -3627,34 +3627,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@next/env@16.0.7': {}
+ '@next/env@16.0.10': {}
'@next/eslint-plugin-next@16.0.7':
dependencies:
fast-glob: 3.3.1
- '@next/swc-darwin-arm64@16.0.7':
+ '@next/swc-darwin-arm64@16.0.10':
optional: true
- '@next/swc-darwin-x64@16.0.7':
+ '@next/swc-darwin-x64@16.0.10':
optional: true
- '@next/swc-linux-arm64-gnu@16.0.7':
+ '@next/swc-linux-arm64-gnu@16.0.10':
optional: true
- '@next/swc-linux-arm64-musl@16.0.7':
+ '@next/swc-linux-arm64-musl@16.0.10':
optional: true
- '@next/swc-linux-x64-gnu@16.0.7':
+ '@next/swc-linux-x64-gnu@16.0.10':
optional: true
- '@next/swc-linux-x64-musl@16.0.7':
+ '@next/swc-linux-x64-musl@16.0.10':
optional: true
- '@next/swc-win32-arm64-msvc@16.0.7':
+ '@next/swc-win32-arm64-msvc@16.0.10':
optional: true
- '@next/swc-win32-x64-msvc@16.0.7':
+ '@next/swc-win32-x64-msvc@16.0.10':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -5714,10 +5714,10 @@ snapshots:
natural-compare@1.4.0: {}
- next-auth@5.0.0-beta.30(next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1):
+ next-auth@5.0.0-beta.30(next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1):
dependencies:
'@auth/core': 0.41.0
- next: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
+ next: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
react: 19.2.1
next-themes@0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
@@ -5725,9 +5725,9 @@ snapshots:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
- next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
+ next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
- '@next/env': 16.0.7
+ '@next/env': 16.0.10
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001759
postcss: 8.4.31
@@ -5735,14 +5735,14 @@ snapshots:
react-dom: 19.2.1(react@19.2.1)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.0.7
- '@next/swc-darwin-x64': 16.0.7
- '@next/swc-linux-arm64-gnu': 16.0.7
- '@next/swc-linux-arm64-musl': 16.0.7
- '@next/swc-linux-x64-gnu': 16.0.7
- '@next/swc-linux-x64-musl': 16.0.7
- '@next/swc-win32-arm64-msvc': 16.0.7
- '@next/swc-win32-x64-msvc': 16.0.7
+ '@next/swc-darwin-arm64': 16.0.10
+ '@next/swc-darwin-x64': 16.0.10
+ '@next/swc-linux-arm64-gnu': 16.0.10
+ '@next/swc-linux-arm64-musl': 16.0.10
+ '@next/swc-linux-x64-gnu': 16.0.10
+ '@next/swc-linux-x64-musl': 16.0.10
+ '@next/swc-win32-arm64-msvc': 16.0.10
+ '@next/swc-win32-x64-msvc': 16.0.10
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'