From d3fce00c15633062d6d2e81c7b1c752dbf3850a9 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Wed, 11 Dec 2024 06:31:47 +0000 Subject: [PATCH 01/12] Fixed setup problems for me --- .dockerignore | 1 - docker-compose.yaml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 2f8f89bc3..65bdb7930 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,7 +12,6 @@ README.md # Ignore environment examples and sensitive info .env -*.local *.example # Ignore node modules, logs and cache files diff --git a/docker-compose.yaml b/docker-compose.yaml index 0c9acd09d..c4c1c5084 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,7 +7,7 @@ services: target: bolt-ai-production ports: - "5173:5173" - env_file: ".env.local" + env_file: ".env.local" #Remove if you want to use .env environment: - NODE_ENV=production - COMPOSE_PROFILES=production From a598710fea2d2e109f11e90855d76c4f23268965 Mon Sep 17 00:00:00 2001 From: "Arne Durr (Tandstad)" Date: Wed, 11 Dec 2024 10:58:44 +0100 Subject: [PATCH 02/12] this runs on mac --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 15f4d991a..f437adb05 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "start:windows": "wrangler pages dev ./build/client", "start:unix": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings", "start": "node -e \"const { spawn } = require('child_process'); const isWindows = process.platform === 'win32'; const cmd = isWindows ? 'npm run start:windows' : 'npm run start:unix'; const child = spawn(cmd, { shell: true, stdio: 'inherit' }); child.on('exit', code => process.exit(code));\"", - "dockerstart": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings --ip 0.0.0.0 --port 5173 --no-show-interactive-dev-session", + "dockerstartold": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings --ip 0.0.0.0 --port 5173 --no-show-interactive-dev-session", + "dockerstart": "cross-env-shell wrangler pages dev ./build/client ./bindings.sh --ip 0.0.0.0 --port 5173 --no-show-interactive-dev-session", "dockerrun": "docker run -it -d --name bolt-ai-live -p 5173:5173 --env-file .env.local bolt-ai", "dockerbuild:prod": "docker build -t bolt-ai:production -t bolt-ai:latest --target bolt-ai-production .", "dockerbuild": "docker build -t bolt-ai:development -t bolt-ai:latest --target bolt-ai-development .", From 80b3ed86c30107ad5c2f43fb30f3deb335ed3510 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Dec 2024 13:46:20 +0000 Subject: [PATCH 03/12] chore: update commit hash to 8aeba4b422ad5be52124abb7b39f1adb78813c6e --- app/commit.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/commit.json b/app/commit.json index 4f74a3ba2..6ffce9d26 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "4f02887565e13eeaabbfb6f699cbe089e802338f" } +{ "commit": "8aeba4b422ad5be52124abb7b39f1adb78813c6e" } From 8cec09678dea50662fb34eb0b473320d677b0be2 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sat, 14 Dec 2024 19:06:52 +0100 Subject: [PATCH 04/12] Added form to store gitlab settings --- app/commit.json | 2 +- app/components/chat/BaseChat.tsx | 6 ++++-- app/lib/stores/workbench.ts | 1 - docker-compose.yaml | 2 ++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/commit.json b/app/commit.json index 6ffce9d26..8fcd97d56 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "8aeba4b422ad5be52124abb7b39f1adb78813c6e" } +{ "commit": "80b3ed86c30107ad5c2f43fb30f3deb335ed3510" } diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 0ed6a30b6..adbc06cab 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -425,15 +425,17 @@ export const BaseChat = React.forwardRef( } event.preventDefault(); - + if (isStreaming) { handleStop?.(); return; } + // ignore if using input method engine if (event.nativeEvent.isComposing) { - return + return; } + handleSendMessage?.(event); } }} diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index bbd537d40..0d46057db 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -297,7 +297,6 @@ export class WorkbenchStore { const action = artifact.runner.actions.get()[data.actionId]; - if (!action || action.executed) { return; } diff --git a/docker-compose.yaml b/docker-compose.yaml index c4c1c5084..22735ca34 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -34,6 +34,8 @@ services: app-dev: image: bolt-ai:development build: + context: . + dockerfile: Dockerfile target: bolt-ai-development environment: - NODE_ENV=development From 8abfef9286e7637c7601770a848e46efa13c7f73 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sat, 14 Dec 2024 19:13:24 +0100 Subject: [PATCH 05/12] Added form to store gitlab key --- app/commit.json | 2 +- .../settings/connections/ConnectionsTab.tsx | 101 +++++++++++++----- 2 files changed, 75 insertions(+), 28 deletions(-) diff --git a/app/commit.json b/app/commit.json index 8fcd97d56..c2ff7d3e8 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "80b3ed86c30107ad5c2f43fb30f3deb335ed3510" } +{ "commit": "8cec09678dea50662fb34eb0b473320d677b0be2" } diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx index 4fe43d96e..5db52b854 100644 --- a/app/components/settings/connections/ConnectionsTab.tsx +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -6,8 +6,10 @@ import { logStore } from '~/lib/stores/logs'; export default function ConnectionsTab() { const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || ''); const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || ''); + const [gitlabUsername, setGitlabUsername] = useState(Cookies.get('gitlabUsername') || ''); + const [gitlabToken, setGitlabToken] = useState(Cookies.get('gitlabToken') || ''); - const handleSaveConnection = () => { + const handleSaveGithubConnection = () => { Cookies.set('githubUsername', githubUsername); Cookies.set('githubToken', githubToken); logStore.logSystem('GitHub connection settings updated', { @@ -18,36 +20,81 @@ export default function ConnectionsTab() { Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' })); }; + const handleSaveGitlabConnection = () => { + Cookies.set('gitlabUsername', gitlabUsername); + Cookies.set('gitlabToken', gitlabToken); + logStore.logSystem('GitLab connection settings updated', { + username: gitlabUsername, + hasToken: !!gitlabToken, + }); + toast.success('GitLab credentials saved successfully!'); + Cookies.set('git:gitlab.com', JSON.stringify({ username: gitlabToken, password: 'x-oauth-basic' })); + }; + return ( -
-

GitHub Connection

-
-
- - setGithubUsername(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> +
+
+

GitHub Connection

+
+
+ + setGithubUsername(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+ + setGithubToken(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
-
- - setGithubToken(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> +
+
-
- + +
+

GitLab Connection

+
+
+ + setGitlabUsername(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+ + setGitlabToken(e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+
+ +
); From 76bb97852cbbffa4ac9f1c344eb6e926cc603ee8 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sat, 14 Dec 2024 19:17:59 +0100 Subject: [PATCH 06/12] Added error handing for onsupported urls --- app/commit.json | 2 +- app/lib/hooks/useGit.ts | 92 +++++++++++++++++++++++------------------ 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/app/commit.json b/app/commit.json index c2ff7d3e8..f7ac920da 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "8cec09678dea50662fb34eb0b473320d677b0be2" } +{ "commit": "8abfef9286e7637c7601770a848e46efa13c7f73" } diff --git a/app/lib/hooks/useGit.ts b/app/lib/hooks/useGit.ts index 3c8c61bb2..d789efc69 100644 --- a/app/lib/hooks/useGit.ts +++ b/app/lib/hooks/useGit.ts @@ -49,48 +49,58 @@ export function useGit() { } fileData.current = {}; - await git.clone({ - fs, - http, - dir: webcontainer.workdir, - url, - depth: 1, - singleBranch: true, - corsProxy: 'https://cors.isomorphic-git.org', - onAuth: (url) => { - // let domain=url.split("/")[2] - - let auth = lookupSavedPassword(url); - - if (auth) { - return auth; - } - - if (confirm('This repo is password protected. Ready to enter a username & password?')) { - auth = { - username: prompt('Enter username'), - password: prompt('Enter password'), - }; - return auth; - } else { - return { cancel: true }; - } - }, - onAuthFailure: (url, _auth) => { - toast.error(`Error Authenticating with ${url.split('/')[2]}`); - }, - onAuthSuccess: (url, auth) => { - saveGitAuth(url, auth); - }, - }); - - const data: Record = {}; - - for (const [key, value] of Object.entries(fileData.current)) { - data[key] = value; - } - return { workdir: webcontainer.workdir, data }; + try { + if (url.startsWith('git@')) { + throw new Error('SSH protocol is not supported. Please use HTTPS URL instead.'); + } + + await git.clone({ + fs, + http, + dir: webcontainer.workdir, + url, + depth: 1, + singleBranch: true, + corsProxy: 'https://cors.isomorphic-git.org', + onAuth: (url) => { + // let domain=url.split("/")[2] + + let auth = lookupSavedPassword(url); + + if (auth) { + return auth; + } + + if (confirm('This repo is password protected. Ready to enter a username & password?')) { + auth = { + username: prompt('Enter username'), + password: prompt('Enter password'), + }; + return auth; + } else { + return { cancel: true }; + } + }, + onAuthFailure: (url, _auth) => { + toast.error(`Error Authenticating with ${url.split('/')[2]}`); + }, + onAuthSuccess: (url, auth) => { + saveGitAuth(url, auth); + }, + }); + + const data: Record = {}; + + for (const [key, value] of Object.entries(fileData.current)) { + data[key] = value; + } + + return { workdir: webcontainer.workdir, data }; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to clone repository'); + throw error; + } }, [webcontainer], ); From 2291d1313d12972f92f7655e3e578da1cea78992 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sat, 14 Dec 2024 23:53:43 +0100 Subject: [PATCH 07/12] Now storing git keys secure and flexible --- app/commit.json | 2 +- .../settings/connections/ConnectionsTab.tsx | 223 ++++++++----- app/lib/hooks/useCredentials.ts | 299 ++++++++++++++++++ app/lib/hooks/useGit.ts | 54 ++-- 4 files changed, 462 insertions(+), 116 deletions(-) create mode 100644 app/lib/hooks/useCredentials.ts diff --git a/app/commit.json b/app/commit.json index f7ac920da..5e242d68b 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "8abfef9286e7637c7601770a848e46efa13c7f73" } +{ "commit": "76bb97852cbbffa4ac9f1c344eb6e926cc603ee8" } diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx index 5db52b854..9927ce11a 100644 --- a/app/components/settings/connections/ConnectionsTab.tsx +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -1,101 +1,166 @@ -import React, { useState } from 'react'; -import { toast } from 'react-toastify'; -import Cookies from 'js-cookie'; +import React, { useState, useEffect } from 'react'; import { logStore } from '~/lib/stores/logs'; +import { + lookupSavedPassword, + saveGitAuth, + ensureEncryption, + isEncryptionInitialized, + hasMasterKeyStored, +} from '~/lib/hooks/useCredentials'; export default function ConnectionsTab() { - const [githubUsername, setGithubUsername] = useState(Cookies.get('githubUsername') || ''); - const [githubToken, setGithubToken] = useState(Cookies.get('githubToken') || ''); - const [gitlabUsername, setGitlabUsername] = useState(Cookies.get('gitlabUsername') || ''); - const [gitlabToken, setGitlabToken] = useState(Cookies.get('gitlabToken') || ''); + const [credentials, setCredentials] = useState({ + github: { username: '', token: '' }, + gitlab: { username: '', token: '' }, + }); + const [isEncrypted, setIsEncrypted] = useState(isEncryptionInitialized()); - const handleSaveGithubConnection = () => { - Cookies.set('githubUsername', githubUsername); - Cookies.set('githubToken', githubToken); - logStore.logSystem('GitHub connection settings updated', { - username: githubUsername, - hasToken: !!githubToken, - }); - toast.success('GitHub credentials saved successfully!'); - Cookies.set('git:github.com', JSON.stringify({ username: githubToken, password: 'x-oauth-basic' })); + const providers = { + github: { + url: 'github.com', + username: credentials.github.username, + token: credentials.github.token, + title: 'GitHub', + setCredentials: (username: string, token: string) => + setCredentials((prev) => ({ + ...prev, + github: { username, token }, + })), + }, + gitlab: { + url: 'gitlab.com', + username: credentials.gitlab.username, + token: credentials.gitlab.token, + title: 'GitLab', + setCredentials: (username: string, token: string) => + setCredentials((prev) => ({ + ...prev, + gitlab: { username, token }, + })), + }, + }; + + useEffect(() => { + // If we have a stored key but it's not initialized, prompt for password + if (hasMasterKeyStored() && !isEncrypted) { + handleSetupEncryption(); + } + }, []); + + useEffect(() => { + if (isEncrypted) { + loadSavedCredentials(); + } + }, [isEncrypted]); + + const handleSetupEncryption = async () => { + const success = await ensureEncryption(); + + if (success) { + setIsEncrypted(true); + } + }; + + const loadSavedCredentials = async () => { + for (const [provider, config] of Object.entries(providers)) { + console.log('loadSaved', provider, config); + + const auth = await lookupSavedPassword(config.url); + console.log('auth', auth); + + if (auth?.username && auth?.password) { + console.log('user and pass', auth.username, auth.password); + config.setCredentials(auth.username, auth.password); + } + } }; - const handleSaveGitlabConnection = () => { - Cookies.set('gitlabUsername', gitlabUsername); - Cookies.set('gitlabToken', gitlabToken); - logStore.logSystem('GitLab connection settings updated', { - username: gitlabUsername, - hasToken: !!gitlabToken, + const handleSaveConnection = async (provider: keyof typeof providers) => { + if (!(await ensureEncryption())) { + return; + } + + const { url, username, token, title } = providers[provider]; + + await saveGitAuth(url, { + username, + password: token, + }); + + logStore.logSystem(`${title} connection settings updated`, { + username, + hasToken: !!token, }); - toast.success('GitLab credentials saved successfully!'); - Cookies.set('git:gitlab.com', JSON.stringify({ username: gitlabToken, password: 'x-oauth-basic' })); }; return (
-

GitHub Connection

-
-
- - setGithubUsername(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> -
-
- - setGithubToken(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> +
+

Encryption Status

+
+ {isEncrypted ? ( + <> +
+ Encryption Key Set + + ) : ( + <> +
+ Encryption Key Not Set + + + )}
-
- -
+

+ {isEncrypted + ? 'Your credentials are securely encrypted. You can safely store and manage your Git credentials.' + : 'Setup encryption to securely store your Git credentials. Your credentials will be encrypted before being saved.'} +

-
-

GitLab Connection

-
-
- - setGitlabUsername(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> + {Object.entries(providers).map(([key, provider]) => ( +
+

{provider.title} Connection

+
+
+ + provider.setCredentials(e.target.value, provider.token)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+ + provider.setCredentials(provider.username, e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
-
- - setGitlabToken(e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> +
+
-
- -
-
+ ))}
); } diff --git a/app/lib/hooks/useCredentials.ts b/app/lib/hooks/useCredentials.ts new file mode 100644 index 000000000..e52b24844 --- /dev/null +++ b/app/lib/hooks/useCredentials.ts @@ -0,0 +1,299 @@ +import { toast } from 'react-toastify'; +import Cookies from 'js-cookie'; +import type { GitAuth } from 'isomorphic-git'; + +interface EncryptedKey { + salt: string; + key: string; +} + +let masterKey: CryptoKey | null = null; + +const hasMasterKeyStored = (): boolean => { + return localStorage.getItem('masterKeyData') !== null; +}; + +const isEncryptionInitialized = (): boolean => { + return masterKey !== null; +}; + +const encrypt = async (text: string): Promise => { + if (!masterKey) { + throw new Error('Master key not initialized'); + } + + const encoder = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encodedText = encoder.encode(text); + + const encryptedData = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, masterKey, encodedText); + + const encryptedArray = new Uint8Array(encryptedData); + const combinedArray = new Uint8Array(iv.length + encryptedArray.length); + combinedArray.set(iv); + combinedArray.set(encryptedArray, iv.length); + + return btoa(String.fromCharCode(...combinedArray)); +}; + +const decrypt = async (encryptedText: string): Promise => { + if (!masterKey) { + throw new Error('Master key not initialized'); + } + + try { + const decoder = new TextDecoder(); + const encryptedArray = new Uint8Array( + atob(encryptedText) + .split('') + .map((char) => char.charCodeAt(0)), + ); + + const iv = encryptedArray.slice(0, 12); + const encryptedData = encryptedArray.slice(12); + + const decryptedData = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, masterKey, encryptedData); + + return decoder.decode(decryptedData); + } catch (error) { + console.error('Decryption failed:', error); + throw error; + } +}; + +const loadMasterKey = async (password: string): Promise => { + try { + const keyData = localStorage.getItem('masterKeyData'); + + if (!keyData) { + return false; + } + + const { salt, key } = JSON.parse(keyData) as EncryptedKey; + const saltArray = new Uint8Array( + atob(salt) + .split('') + .map((c) => c.charCodeAt(0)), + ); + const keyArray = new Uint8Array( + atob(key) + .split('') + .map((c) => c.charCodeAt(0)), + ); + + const derivedKey = await crypto.subtle.importKey( + 'raw', + await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: saltArray, + iterations: 100000, + hash: 'SHA-256', + }, + await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']), + 256, + ), + 'AES-GCM', + false, + ['decrypt'], + ); + + const iv = keyArray.slice(0, 12); + const encryptedKey = keyArray.slice(12); + + const decryptedKey = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, derivedKey, encryptedKey); + + masterKey = await crypto.subtle.importKey('raw', decryptedKey, 'AES-GCM', false, ['encrypt', 'decrypt']); + + return true; + } catch (error) { + console.error('Failed to load master key:', error); + return false; + } +}; + +const generateMasterKey = async (password: string): Promise => { + try { + // Generate a random master key + const newMasterKey = crypto.getRandomValues(new Uint8Array(32)); + const salt = crypto.getRandomValues(new Uint8Array(16)); + + // Derive a key from the password + const derivedKey = await crypto.subtle.importKey( + 'raw', + await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']), + 256, + ), + 'AES-GCM', + false, + ['encrypt'], + ); + + // Encrypt the master key + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encryptedKey = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, derivedKey, newMasterKey); + + // Store the master key for use + masterKey = await crypto.subtle.importKey('raw', newMasterKey, 'AES-GCM', false, ['encrypt', 'decrypt']); + + // Combine IV and encrypted key + const combined = new Uint8Array(iv.length + encryptedKey.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encryptedKey), iv.length); + + // Store the encrypted master key + localStorage.setItem( + 'masterKeyData', + JSON.stringify({ + salt: btoa(String.fromCharCode(...salt)), + key: btoa(String.fromCharCode(...combined)), + }), + ); + + return true; + } catch (error) { + console.error('Failed to generate master key:', error); + return false; + } +}; + +const initializeMasterKey = async (password: string): Promise => { + if (hasMasterKeyStored()) { + return await loadMasterKey(password); + } else { + return await generateMasterKey(password); + } +}; + +const promptForEncryption = async (): Promise => { + const password = prompt('Enter password for encryption key'); + + if (!password) { + toast.error('Password is required'); + return false; + } + + try { + const success = await initializeMasterKey(password); + + if (!success) { + toast.error('Invalid password or failed to setup encryption'); + return false; + } + + return true; + } catch (error) { + console.error('Encryption setup failed:', error); + toast.error('Failed to setup encryption'); + + return false; + } +}; + +const ensureEncryption = async (): Promise => { + if (!isEncryptionInitialized()) { + return await promptForEncryption(); + } + + return true; +}; + +const lookupSavedPassword = async (url: string): Promise => { + if (!isEncryptionInitialized()) { + return null; + } + + const domain = getDomain(url); + const encryptedCreds = Cookies.get(domain); + + if (!encryptedCreds) { + // Fallback to legacy format + const provider = domain.split('.')[0]; + const encryptedUsername = Cookies.get(`${provider}Username`); + const encryptedToken = Cookies.get(`${provider}Token`); + + if (encryptedUsername && encryptedToken) { + try { + const username = await decrypt(encryptedUsername); + const token = await decrypt(encryptedToken); + + if (username && token) { + return { username, password: token }; + } + } catch (error) { + console.error('Failed to decrypt legacy credentials:', error); + } + } + + return null; + } + + try { + const decryptedCreds = await decrypt(encryptedCreds); + const { username, password } = JSON.parse(decryptedCreds); + + if (!username || !password) { + return null; + } + + return { username, password }; + } catch (error) { + console.error('Failed to parse or decrypt Git Cookie:', error); + return null; + } +}; + +const getDomain = (url: string): string => { + return url.replace(/^https?:\/\//, ''); +}; + +const saveGitAuth = async (url: string, auth: GitAuth) => { + if (!isEncryptionInitialized()) { + throw new Error('Encryption not initialized'); + } + + const domain = getDomain(url); + const provider = domain.split('.')[0]; + + if (!auth.username || !auth.password) { + toast.error('Username and token are required'); + return; + } + + try { + // Encrypt the full credentials object + const encryptedCreds = await encrypt( + JSON.stringify({ + username: auth.username, + password: auth.password, + }), + ); + Cookies.set(domain, encryptedCreds); + + // Also save in legacy format + const encryptedUsername = await encrypt(auth.username); + const encryptedToken = await encrypt(auth.password); + Cookies.set(`${provider}Username`, encryptedUsername); + Cookies.set(`${provider}Token`, encryptedToken); + } catch (error) { + console.error('Failed to encrypt credentials:', error); + toast.error('Failed to save credentials securely'); + } +}; + +export { + lookupSavedPassword, + saveGitAuth, + initializeMasterKey, + isEncryptionInitialized, + promptForEncryption, + ensureEncryption, + hasMasterKeyStored, +}; diff --git a/app/lib/hooks/useGit.ts b/app/lib/hooks/useGit.ts index d789efc69..5b3d4b6c5 100644 --- a/app/lib/hooks/useGit.ts +++ b/app/lib/hooks/useGit.ts @@ -1,38 +1,17 @@ import type { WebContainer } from '@webcontainer/api'; import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'; import { webcontainer as webcontainerPromise } from '~/lib/webcontainer'; -import git, { type GitAuth, type PromiseFsClient } from 'isomorphic-git'; +import git, { type PromiseFsClient } from 'isomorphic-git'; import http from 'isomorphic-git/http/web'; -import Cookies from 'js-cookie'; import { toast } from 'react-toastify'; - -const lookupSavedPassword = (url: string) => { - const domain = url.split('/')[2]; - const gitCreds = Cookies.get(`git:${domain}`); - - if (!gitCreds) { - return null; - } - - try { - const { username, password } = JSON.parse(gitCreds || '{}'); - return { username, password }; - } catch (error) { - console.log(`Failed to parse Git Cookie ${error}`); - return null; - } -}; - -const saveGitAuth = (url: string, auth: GitAuth) => { - const domain = url.split('/')[2]; - Cookies.set(`git:${domain}`, JSON.stringify(auth)); -}; +import { ensureEncryption, lookupSavedPassword, saveGitAuth } from './useCredentials'; export function useGit() { const [ready, setReady] = useState(false); const [webcontainer, setWebcontainer] = useState(); const [fs, setFs] = useState(); const fileData = useRef>({}); + useEffect(() => { webcontainerPromise.then((container) => { fileData.current = {}; @@ -63,30 +42,33 @@ export function useGit() { depth: 1, singleBranch: true, corsProxy: 'https://cors.isomorphic-git.org', - onAuth: (url) => { - // let domain=url.split("/")[2] + onAuth: async (url) => { + if (!(await ensureEncryption())) { + return { cancel: true }; + } - let auth = lookupSavedPassword(url); + const auth = await lookupSavedPassword(url); if (auth) { return auth; } if (confirm('This repo is password protected. Ready to enter a username & password?')) { - auth = { - username: prompt('Enter username'), - password: prompt('Enter password'), - }; - return auth; - } else { - return { cancel: true }; + const username = prompt('Enter username'); + const password = prompt('Enter password'); + + if (username && password) { + return { username, password }; + } } + + return { cancel: true }; }, onAuthFailure: (url, _auth) => { toast.error(`Error Authenticating with ${url.split('/')[2]}`); }, - onAuthSuccess: (url, auth) => { - saveGitAuth(url, auth); + onAuthSuccess: async (url, auth) => { + await saveGitAuth(url, auth); }, }); From 92912e0eb6422cd488fd3109d2e2f90c667a005f Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sun, 15 Dec 2024 00:28:02 +0100 Subject: [PATCH 08/12] Auto generate master key and remove old cookies --- app/commit.json | 2 +- .../settings/connections/ConnectionsTab.tsx | 79 ++--- app/lib/hooks/useCredentials.ts | 286 +++++++----------- 3 files changed, 143 insertions(+), 224 deletions(-) diff --git a/app/commit.json b/app/commit.json index 5e242d68b..fd6fe9888 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "76bb97852cbbffa4ac9f1c344eb6e926cc603ee8" } +{ "commit": "2291d1313d12972f92f7655e3e578da1cea78992" } diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx index 9927ce11a..91c55c759 100644 --- a/app/components/settings/connections/ConnectionsTab.tsx +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -3,9 +3,8 @@ import { logStore } from '~/lib/stores/logs'; import { lookupSavedPassword, saveGitAuth, - ensureEncryption, isEncryptionInitialized, - hasMasterKeyStored, + ensureEncryption, } from '~/lib/hooks/useCredentials'; export default function ConnectionsTab() { @@ -15,6 +14,29 @@ export default function ConnectionsTab() { }); const [isEncrypted, setIsEncrypted] = useState(isEncryptionInitialized()); + useEffect(() => { + initializeEncryption(); + }, []); + + const initializeEncryption = async () => { + const success = await ensureEncryption(); + + if (success) { + setIsEncrypted(true); + loadSavedCredentials(); + } + }; + + const loadSavedCredentials = async () => { + for (const [, config] of Object.entries(providers)) { + const auth = await lookupSavedPassword(config.url); + + if (auth?.username && auth?.password) { + config.setCredentials(auth.username, auth.password); + } + } + }; + const providers = { github: { url: 'github.com', @@ -40,46 +62,7 @@ export default function ConnectionsTab() { }, }; - useEffect(() => { - // If we have a stored key but it's not initialized, prompt for password - if (hasMasterKeyStored() && !isEncrypted) { - handleSetupEncryption(); - } - }, []); - - useEffect(() => { - if (isEncrypted) { - loadSavedCredentials(); - } - }, [isEncrypted]); - - const handleSetupEncryption = async () => { - const success = await ensureEncryption(); - - if (success) { - setIsEncrypted(true); - } - }; - - const loadSavedCredentials = async () => { - for (const [provider, config] of Object.entries(providers)) { - console.log('loadSaved', provider, config); - - const auth = await lookupSavedPassword(config.url); - console.log('auth', auth); - - if (auth?.username && auth?.password) { - console.log('user and pass', auth.username, auth.password); - config.setCredentials(auth.username, auth.password); - } - } - }; - const handleSaveConnection = async (provider: keyof typeof providers) => { - if (!(await ensureEncryption())) { - return; - } - const { url, username, token, title } = providers[provider]; await saveGitAuth(url, { @@ -102,26 +85,18 @@ export default function ConnectionsTab() { {isEncrypted ? ( <>
- Encryption Key Set + Encryption Active ) : ( <>
- Encryption Key Not Set - + Initializing Encryption... )}

- {isEncrypted - ? 'Your credentials are securely encrypted. You can safely store and manage your Git credentials.' - : 'Setup encryption to securely store your Git credentials. Your credentials will be encrypted before being saved.'} + Your credentials are automatically encrypted before being stored.

diff --git a/app/lib/hooks/useCredentials.ts b/app/lib/hooks/useCredentials.ts index e52b24844..b6abbba6e 100644 --- a/app/lib/hooks/useCredentials.ts +++ b/app/lib/hooks/useCredentials.ts @@ -2,21 +2,42 @@ import { toast } from 'react-toastify'; import Cookies from 'js-cookie'; import type { GitAuth } from 'isomorphic-git'; -interface EncryptedKey { - salt: string; - key: string; -} - let masterKey: CryptoKey | null = null; -const hasMasterKeyStored = (): boolean => { - return localStorage.getItem('masterKeyData') !== null; +const generateRandomKey = (): Uint8Array => { + return crypto.getRandomValues(new Uint8Array(32)); }; const isEncryptionInitialized = (): boolean => { return masterKey !== null; }; +const initializeMasterKey = async (): Promise => { + try { + const storedKey = localStorage.getItem('masterKey'); + + let keyData: Uint8Array; + + if (storedKey) { + keyData = new Uint8Array( + atob(storedKey) + .split('') + .map((c) => c.charCodeAt(0)), + ); + } else { + keyData = generateRandomKey(); + localStorage.setItem('masterKey', btoa(String.fromCharCode(...keyData))); + } + + masterKey = await crypto.subtle.importKey('raw', keyData, 'AES-GCM', false, ['encrypt', 'decrypt']); + + return true; + } catch (error) { + console.error('Failed to initialize master key:', error); + return false; + } +}; + const encrypt = async (text: string): Promise => { if (!masterKey) { throw new Error('Master key not initialized'); @@ -61,177 +82,89 @@ const decrypt = async (encryptedText: string): Promise => { } }; -const loadMasterKey = async (password: string): Promise => { - try { - const keyData = localStorage.getItem('masterKeyData'); - - if (!keyData) { - return false; - } - - const { salt, key } = JSON.parse(keyData) as EncryptedKey; - const saltArray = new Uint8Array( - atob(salt) - .split('') - .map((c) => c.charCodeAt(0)), - ); - const keyArray = new Uint8Array( - atob(key) - .split('') - .map((c) => c.charCodeAt(0)), - ); - - const derivedKey = await crypto.subtle.importKey( - 'raw', - await crypto.subtle.deriveBits( - { - name: 'PBKDF2', - salt: saltArray, - iterations: 100000, - hash: 'SHA-256', - }, - await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']), - 256, - ), - 'AES-GCM', - false, - ['decrypt'], - ); +const ensureEncryption = async (): Promise => { + if (!isEncryptionInitialized()) { + return await initializeMasterKey(); + } - const iv = keyArray.slice(0, 12); - const encryptedKey = keyArray.slice(12); + return true; +}; - const decryptedKey = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, derivedKey, encryptedKey); +const getDomain = (url: string): string => { + const withoutProtocol = url.replace(/^https?:\/\//, ''); + return withoutProtocol.split(/[/?#]/)[0]; +}; - masterKey = await crypto.subtle.importKey('raw', decryptedKey, 'AES-GCM', false, ['encrypt', 'decrypt']); +const getLegacyCredentials = async (domain: string): Promise => { + const provider = domain.split('.')[0]; + const encryptedUsername = Cookies.get(`${provider}Username`); + const encryptedToken = Cookies.get(`${provider}Token`); - return true; - } catch (error) { - console.error('Failed to load master key:', error); - return false; + if (!encryptedUsername || !encryptedToken) { + return null; } -}; -const generateMasterKey = async (password: string): Promise => { try { - // Generate a random master key - const newMasterKey = crypto.getRandomValues(new Uint8Array(32)); - const salt = crypto.getRandomValues(new Uint8Array(16)); - - // Derive a key from the password - const derivedKey = await crypto.subtle.importKey( - 'raw', - await crypto.subtle.deriveBits( - { - name: 'PBKDF2', - salt, - iterations: 100000, - hash: 'SHA-256', - }, - await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']), - 256, - ), - 'AES-GCM', - false, - ['encrypt'], - ); - - // Encrypt the master key - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encryptedKey = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, derivedKey, newMasterKey); + const username = await decrypt(encryptedUsername); + const token = await decrypt(encryptedToken); - // Store the master key for use - masterKey = await crypto.subtle.importKey('raw', newMasterKey, 'AES-GCM', false, ['encrypt', 'decrypt']); + if (!username || !token) { + Cookies.remove(`${provider}Username`); + Cookies.remove(`${provider}Token`); - // Combine IV and encrypted key - const combined = new Uint8Array(iv.length + encryptedKey.byteLength); - combined.set(iv); - combined.set(new Uint8Array(encryptedKey), iv.length); - - // Store the encrypted master key - localStorage.setItem( - 'masterKeyData', - JSON.stringify({ - salt: btoa(String.fromCharCode(...salt)), - key: btoa(String.fromCharCode(...combined)), - }), - ); + return null; + } - return true; + return { username, password: token }; } catch (error) { - console.error('Failed to generate master key:', error); - return false; - } -}; + console.error('Failed to decrypt legacy credentials:', error); + + const provider = domain.split('.')[0]; + Cookies.remove(`${provider}Username`); + Cookies.remove(`${provider}Token`); -const initializeMasterKey = async (password: string): Promise => { - if (hasMasterKeyStored()) { - return await loadMasterKey(password); - } else { - return await generateMasterKey(password); + return null; } }; -const promptForEncryption = async (): Promise => { - const password = prompt('Enter password for encryption key'); - - if (!password) { - toast.error('Password is required'); - return false; - } +const migrateLegacyCredentials = async (domain: string, auth: GitAuth): Promise => { + const provider = domain.split('.')[0]; try { - const success = await initializeMasterKey(password); + const encryptedCreds = await encrypt(JSON.stringify(auth)); + Cookies.set(domain, encryptedCreds); - if (!success) { - toast.error('Invalid password or failed to setup encryption'); - return false; - } + Cookies.remove(`${provider}Username`); + Cookies.remove(`${provider}Token`); + + const legacyKeys = [ + `${provider}AccessToken`, + `${provider}Auth`, + `${provider}Credentials`, + `${provider}_username`, + `${provider}_token`, + ]; + + legacyKeys.forEach((key) => { + if (Cookies.get(key)) { + Cookies.remove(key); + console.log(`Removed legacy cookie: ${key}`); + } + }); + + console.log(`Successfully migrated ${provider} credentials to new format and cleaned up legacy data`); return true; } catch (error) { - console.error('Encryption setup failed:', error); - toast.error('Failed to setup encryption'); - + console.error('Failed to migrate legacy credentials:', error); return false; } }; -const ensureEncryption = async (): Promise => { - if (!isEncryptionInitialized()) { - return await promptForEncryption(); - } - - return true; -}; - -const lookupSavedPassword = async (url: string): Promise => { - if (!isEncryptionInitialized()) { - return null; - } - - const domain = getDomain(url); +const getNewFormatCredentials = async (domain: string): Promise => { const encryptedCreds = Cookies.get(domain); if (!encryptedCreds) { - // Fallback to legacy format - const provider = domain.split('.')[0]; - const encryptedUsername = Cookies.get(`${provider}Username`); - const encryptedToken = Cookies.get(`${provider}Token`); - - if (encryptedUsername && encryptedToken) { - try { - const username = await decrypt(encryptedUsername); - const token = await decrypt(encryptedToken); - - if (username && token) { - return { username, password: token }; - } - } catch (error) { - console.error('Failed to decrypt legacy credentials:', error); - } - } - return null; } @@ -240,27 +173,52 @@ const lookupSavedPassword = async (url: string): Promise => { const { username, password } = JSON.parse(decryptedCreds); if (!username || !password) { + Cookies.remove(domain); return null; } return { username, password }; } catch (error) { console.error('Failed to parse or decrypt Git Cookie:', error); + Cookies.remove(domain); + return null; } }; -const getDomain = (url: string): string => { - return url.replace(/^https?:\/\//, ''); +const lookupSavedPassword = async (url: string): Promise => { + if (!(await ensureEncryption())) { + return null; + } + + const domain = getDomain(url); + + const newFormatCreds = await getNewFormatCredentials(domain); + + if (newFormatCreds) { + return newFormatCreds; + } + + const legacyCreds = await getLegacyCredentials(domain); + + if (legacyCreds) { + const migrationSuccess = await migrateLegacyCredentials(domain, legacyCreds); + + if (migrationSuccess) { + return legacyCreds; + } + } + + return null; }; const saveGitAuth = async (url: string, auth: GitAuth) => { - if (!isEncryptionInitialized()) { - throw new Error('Encryption not initialized'); + if (!(await ensureEncryption())) { + toast.error('Failed to initialize encryption'); + return; } const domain = getDomain(url); - const provider = domain.split('.')[0]; if (!auth.username || !auth.password) { toast.error('Username and token are required'); @@ -268,7 +226,6 @@ const saveGitAuth = async (url: string, auth: GitAuth) => { } try { - // Encrypt the full credentials object const encryptedCreds = await encrypt( JSON.stringify({ username: auth.username, @@ -276,24 +233,11 @@ const saveGitAuth = async (url: string, auth: GitAuth) => { }), ); Cookies.set(domain, encryptedCreds); - - // Also save in legacy format - const encryptedUsername = await encrypt(auth.username); - const encryptedToken = await encrypt(auth.password); - Cookies.set(`${provider}Username`, encryptedUsername); - Cookies.set(`${provider}Token`, encryptedToken); + toast.success(`${domain} credentials saved successfully!`); } catch (error) { console.error('Failed to encrypt credentials:', error); toast.error('Failed to save credentials securely'); } }; -export { - lookupSavedPassword, - saveGitAuth, - initializeMasterKey, - isEncryptionInitialized, - promptForEncryption, - ensureEncryption, - hasMasterKeyStored, -}; +export { lookupSavedPassword, saveGitAuth, isEncryptionInitialized, ensureEncryption }; From 57ff677e5d96adbbd510c867341dfd3fba5f13b5 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sun, 15 Dec 2024 00:35:42 +0100 Subject: [PATCH 09/12] Remove encryption interface --- app/commit.json | 2 +- .../settings/connections/ConnectionsTab.tsx | 31 +------------------ 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/app/commit.json b/app/commit.json index fd6fe9888..2fae841da 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "2291d1313d12972f92f7655e3e578da1cea78992" } +{ "commit": "92912e0eb6422cd488fd3109d2e2f90c667a005f" } diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx index 91c55c759..2920b98d4 100644 --- a/app/components/settings/connections/ConnectionsTab.tsx +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -1,18 +1,12 @@ import React, { useState, useEffect } from 'react'; import { logStore } from '~/lib/stores/logs'; -import { - lookupSavedPassword, - saveGitAuth, - isEncryptionInitialized, - ensureEncryption, -} from '~/lib/hooks/useCredentials'; +import { lookupSavedPassword, saveGitAuth, ensureEncryption } from '~/lib/hooks/useCredentials'; export default function ConnectionsTab() { const [credentials, setCredentials] = useState({ github: { username: '', token: '' }, gitlab: { username: '', token: '' }, }); - const [isEncrypted, setIsEncrypted] = useState(isEncryptionInitialized()); useEffect(() => { initializeEncryption(); @@ -22,7 +16,6 @@ export default function ConnectionsTab() { const success = await ensureEncryption(); if (success) { - setIsEncrypted(true); loadSavedCredentials(); } }; @@ -78,28 +71,6 @@ export default function ConnectionsTab() { return (
-
-
-

Encryption Status

-
- {isEncrypted ? ( - <> -
- Encryption Active - - ) : ( - <> -
- Initializing Encryption... - - )} -
-
-

- Your credentials are automatically encrypted before being stored. -

-
- {Object.entries(providers).map(([key, provider]) => (
Date: Sun, 15 Dec 2024 15:02:44 +0100 Subject: [PATCH 10/12] Added gitlab workflow to the workbench --- app/commit.json | 2 +- .../settings/connections/ConnectionsTab.tsx | 112 +++++++++--- app/components/workbench/Workbench.client.tsx | 103 +++++++---- app/lib/stores/workbench.ts | 173 +++++++++++++++--- package.json | 1 + pnpm-lock.yaml | 59 ++++++ tsconfig.json | 7 +- 7 files changed, 365 insertions(+), 92 deletions(-) diff --git a/app/commit.json b/app/commit.json index 2fae841da..e75f07668 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "92912e0eb6422cd488fd3109d2e2f90c667a005f" } +{ "commit": "57ff677e5d96adbbd510c867341dfd3fba5f13b5" } diff --git a/app/components/settings/connections/ConnectionsTab.tsx b/app/components/settings/connections/ConnectionsTab.tsx index 2920b98d4..5f91ace71 100644 --- a/app/components/settings/connections/ConnectionsTab.tsx +++ b/app/components/settings/connections/ConnectionsTab.tsx @@ -7,6 +7,7 @@ export default function ConnectionsTab() { github: { username: '', token: '' }, gitlab: { username: '', token: '' }, }); + const [expandedProviders, setExpandedProviders] = useState>({}); useEffect(() => { initializeEncryption(); @@ -30,12 +31,27 @@ export default function ConnectionsTab() { } }; + const toggleProvider = (provider: string) => { + setExpandedProviders((prev) => ({ + ...prev, + [provider]: !prev[provider], + })); + }; + const providers = { github: { url: 'github.com', username: credentials.github.username, token: credentials.github.token, title: 'GitHub', + instructions: 'Enter your GitHub username and personal access token.', + tokenSetupSteps: [ + '1. Go to GitHub.com → Settings → Developer settings → Personal access tokens → Tokens (classic)', + '2. Generate new token (classic) with these scopes:', + ' • repo (Full control of private repositories)', + ' • workflow (Optional: Update GitHub Action workflows)', + '3. Copy the generated token and paste it here', + ], setCredentials: (username: string, token: string) => setCredentials((prev) => ({ ...prev, @@ -47,6 +63,14 @@ export default function ConnectionsTab() { username: credentials.gitlab.username, token: credentials.gitlab.token, title: 'GitLab', + instructions: 'To set up GitLab access:', + tokenSetupSteps: [ + '1. Go to GitLab.com → Profile Settings → Access Tokens', + '2. Create a new token with these scopes:', + ' • api (Full API access)', + ' • write_repository (Read/write access)', + '3. Copy the generated token and paste it here', + ], setCredentials: (username: string, token: string) => setCredentials((prev) => ({ ...prev, @@ -71,40 +95,76 @@ export default function ConnectionsTab() { return (
+ {/* Encryption status section remains the same */} + {Object.entries(providers).map(([key, provider]) => (
-

{provider.title} Connection

-
-
- - provider.setCredentials(e.target.value, provider.token)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> +
toggleProvider(key)}> +
+

{provider.title} Connection

+ {provider.username && ( + ({provider.username}) + )}
-
- - provider.setCredentials(provider.username, e.target.value)} - className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" - /> +
+ {provider.username && provider.token && ( +
+
+ Connected +
+ )} +
+
+
-
- -
+ + {expandedProviders[key] && ( +
+
+

{provider.instructions}

+
    + {provider.tokenSetupSteps.map((step, index) => ( +
  • {step}
  • + ))} +
+
+ +
+
+ + provider.setCredentials(e.target.value, provider.token)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+ + provider.setCredentials(provider.username, e.target.value)} + className="w-full bg-white dark:bg-bolt-elements-background-depth-4 relative px-2 py-1.5 rounded-md focus:outline-none placeholder-bolt-elements-textTertiary text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary border border-bolt-elements-borderColor" + /> +
+
+
+ +
+
+ )}
))}
diff --git a/app/components/workbench/Workbench.client.tsx b/app/components/workbench/Workbench.client.tsx index 0e34b5998..c6a9de4fe 100644 --- a/app/components/workbench/Workbench.client.tsx +++ b/app/components/workbench/Workbench.client.tsx @@ -17,13 +17,18 @@ import { renderLogger } from '~/utils/logger'; import { EditorPanel } from './EditorPanel'; import { Preview } from './Preview'; import useViewport from '~/lib/hooks'; -import Cookies from 'js-cookie'; +import { ensureEncryption, lookupSavedPassword } from '~/lib/hooks/useCredentials'; interface WorkspaceProps { chatStarted?: boolean; isStreaming?: boolean; } +interface GitCredentials { + github: boolean; + gitlab: boolean; +} + const viewTransition = { ease: cubicEasingFn }; const sliderOptions: SliderOptions = { @@ -58,6 +63,10 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => renderLogger.trace('Workbench'); const [isSyncing, setIsSyncing] = useState(false); + const [hasCredentials, setHasCredentials] = useState({ + github: false, + gitlab: false, + }); const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0)); const showWorkbench = useStore(workbenchStore.showWorkbench); @@ -83,6 +92,20 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => workbenchStore.setDocuments(files); }, [files]); + useEffect(() => { + checkGitCredentials(); + }, []); + + const checkGitCredentials = async () => { + const githubAuth = await lookupSavedPassword('github.com'); + const gitlabAuth = await lookupSavedPassword('gitlab.com'); + + setHasCredentials({ + github: !!(githubAuth?.username && githubAuth?.password), + gitlab: !!(gitlabAuth?.username && gitlabAuth?.password), + }); + }; + const onEditorChange = useCallback((update) => { workbenchStore.setCurrentDocumentContent(update.content); }, []); @@ -120,6 +143,38 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => } }, []); + const handleGitPush = async (provider: 'github' | 'gitlab') => { + const repoName = prompt( + `Please enter a name for your new ${provider === 'github' ? 'GitHub' : 'GitLab'} repository:`, + 'bolt-generated-project', + ); + + // TODO store and load repoName from downloaded project + if (!repoName) { + toast.error('Repository name is required'); + return; + } + + if (!(await ensureEncryption())) { + toast.error('Failed to initialize secure storage'); + return; + } + + const auth = await lookupSavedPassword(`${provider}.com`); + + if (auth?.username && auth?.password) { + if (provider === 'github') { + workbenchStore.pushToGitHub(repoName, auth.username, auth.password); + } else { + workbenchStore.pushToGitLab(repoName, auth.username, auth.password); + } + } else { + toast.info( + `Please set up your ${provider === 'github' ? 'GitHub' : 'GitLab'} credentials in the Connections tab`, + ); + } + }; + return ( chatStarted && (
Toggle Terminal - { - const repoName = prompt( - 'Please enter a name for your new GitHub repository:', - 'bolt-generated-project', - ); - - if (!repoName) { - alert('Repository name is required. Push to GitHub cancelled.'); - return; - } - - const githubUsername = Cookies.get('githubUsername'); - const githubToken = Cookies.get('githubToken'); - - if (!githubUsername || !githubToken) { - const usernameInput = prompt('Please enter your GitHub username:'); - const tokenInput = prompt('Please enter your GitHub personal access token:'); - - if (!usernameInput || !tokenInput) { - alert('GitHub username and token are required. Push to GitHub cancelled.'); - return; - } - - workbenchStore.pushToGitHub(repoName, usernameInput, tokenInput); - } else { - workbenchStore.pushToGitHub(repoName, githubUsername, githubToken); - } - }} - > -
- Push to GitHub - + {hasCredentials.github && ( + handleGitPush('github')}> +
+ Push to GitHub + + )} + {hasCredentials.gitlab && ( + handleGitPush('gitlab')}> +
+ Push to GitLab + + )}
)} { if (dirent?.type === 'file' && dirent.content) { @@ -466,21 +468,19 @@ export class WorkbenchStore { }), ); - const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs + const validBlobs = blobs.filter(Boolean); if (validBlobs.length === 0) { throw new Error('No valid files to push'); } - // Get the latest commit SHA (assuming main branch, update dynamically if needed) const { data: ref } = await octokit.git.getRef({ owner: repo.owner.login, repo: repo.name, - ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch + ref: `heads/${repo.default_branch || 'main'}`, }); const latestCommitSha = ref.object.sha; - // Create a new tree const { data: newTree } = await octokit.git.createTree({ owner: repo.owner.login, repo: repo.name, @@ -493,7 +493,6 @@ export class WorkbenchStore { })), }); - // Create a new commit const { data: newCommit } = await octokit.git.createCommit({ owner: repo.owner.login, repo: repo.name, @@ -502,18 +501,134 @@ export class WorkbenchStore { parents: [latestCommitSha], }); - // Update the reference await octokit.git.updateRef({ owner: repo.owner.login, repo: repo.name, - ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch + ref: `heads/${repo.default_branch || 'main'}`, sha: newCommit.sha, }); - alert(`Repository created and code pushed: ${repo.html_url}`); + toast.success(`Repository created and code pushed: ${repo.html_url}`); + + return repo.html_url; } catch (error) { console.error('Error pushing to GitHub:', error); - throw error; // Rethrow the error for further handling + + if (error instanceof Error) { + if (error.message === 'Repository creation cancelled') { + throw error; + } + + if ('status' in error && error.status === 401) { + toast.error('Authentication failed. Please check your GitHub token in the Connections tab.'); + } else { + toast.error('Failed to push to GitHub. Please try again.'); + } + } + + throw error; + } + } + + async pushToGitLab(repoName: string, username: string, token: string) { + try { + const gitlab = axios.create({ + baseURL: 'https://gitlab.com/api/v4', + headers: { + 'PRIVATE-TOKEN': token, + 'Content-Type': 'application/json', + }, + timeout: 10000, + }); + + gitlab.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + throw new Error('Authentication failed'); + } + + throw new Error(error.response?.data?.message || error.message); + }, + ); + + let project; + + try { + const { data } = await gitlab.get(`/projects/${encodeURIComponent(`${username}/${repoName}`)}`); + project = data; + } catch (error: any) { + if (error.response?.status === 404) { + // Project doesn't exist, ask for confirmation + const shouldCreate = confirm(`Repository "${repoName}" doesn't exist. Would you like to create it?`); + + if (!shouldCreate) { + throw new Error('Repository creation cancelled'); + } + + // Create new project after confirmation + const { data } = await gitlab.post('/projects', { + name: repoName, + visibility: 'public', + initialize_with_readme: true, + }); + project = data; + } else { + // Re-throw other errors + throw error; + } + } + + // Get all files + const files = this.files.get(); + + if (!files || Object.keys(files).length === 0) { + throw new Error('No files found to push'); + } + + const actions = Object.entries(files) + .filter((entry): entry is [string, File] => { + const [, dirent] = entry; + + if (actions.length === 0) { + throw new Error('No valid files to push'); + } + + return dirent?.type === 'file' && typeof dirent?.content === 'string'; + }) + .map(([filePath, dirent]) => ({ + action: 'create', + file_path: extractRelativePath(filePath), + content: dirent.content, + })); + + await gitlab.post(`/projects/${project.id}/repository/commits`, { + branch: project.default_branch || 'main', + commit_message: 'Initial commit from your app', + actions, + }); + + toast.success(`Repository created and code pushed: ${project.web_url}`); + + return project.web_url; + } catch (error) { + console.error('Error pushing to GitLab:', error); + + if (error instanceof Error) { + if (error.message === 'Repository creation cancelled') { + throw error; + } + + if (error.message === 'Authentication failed') { + toast.error('Authentication failed. Please check your GitLab token in the Connections tab.'); + } else { + toast.error(`Failed to push to GitLab: ${error.message}`); + } + } else { + toast.error('Failed to push to GitLab. Please try again.'); + } + + throw error; } } } diff --git a/package.json b/package.json index 79a13590b..60cd15c04 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "ai": "^3.4.33", + "axios": "^1.7.9", "date-fns": "^3.6.0", "diff": "^5.2.0", "file-saver": "^2.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e355d04ea..db02a0e3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: ai: specifier: ^3.4.33 version: 3.4.33(react@18.3.1)(sswr@2.1.0(svelte@5.4.0))(svelte@5.4.0)(vue@3.5.13(typescript@5.7.2))(zod@3.23.8) + axios: + specifier: ^1.7.9 + version: 1.7.9 date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -2517,10 +2520,16 @@ packages: async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios@1.7.9: + resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2745,6 +2754,10 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2922,6 +2935,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3295,6 +3312,15 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -3302,6 +3328,10 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -4635,6 +4665,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} @@ -8196,10 +8229,20 @@ snapshots: async-lock@1.4.1: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 + axios@1.7.9: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -8456,6 +8499,10 @@ snapshots: colorjs.io@0.5.2: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} common-tags@1.8.2: {} @@ -8604,6 +8651,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -9108,6 +9157,8 @@ snapshots: flatted@3.3.2: {} + follow-redirects@1.15.9: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -9117,6 +9168,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + format@0.2.2: {} formdata-polyfill@4.0.10: @@ -10872,6 +10929,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + public-encrypt@4.0.3: dependencies: bn.js: 4.12.1 diff --git a/tsconfig.json b/tsconfig.json index 8ef1458c7..22a37cf9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,12 @@ { "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01", "@types/dom-speech-recognition"], + "types": [ + "@remix-run/cloudflare", + "vite/client", + "@cloudflare/workers-types/2023-07-01", + "@types/dom-speech-recognition" + ], "isolatedModules": true, "esModuleInterop": true, "jsx": "react-jsx", From 511bce10df5c8ac84bac6c295024296993723a24 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sun, 15 Dec 2024 15:31:53 +0100 Subject: [PATCH 11/12] updating gitlab project working --- app/commit.json | 2 +- app/lib/stores/workbench.ts | 94 ++++++++++++++++++++++++++----------- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/app/commit.json b/app/commit.json index e75f07668..e29c0e27f 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "57ff677e5d96adbbd510c867341dfd3fba5f13b5" } +{ "commit": "6191f3745e80b42bbf6fc9c2ab23d7574530c67e" } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index 52d363087..d28c55b6d 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -555,7 +555,9 @@ export class WorkbenchStore { let project; try { - const { data } = await gitlab.get(`/projects/${encodeURIComponent(`${username}/${repoName}`)}`); + // Use proper URL encoding for the project path + const projectPath = encodeURIComponent(`${username}/${repoName}`); + const { data } = await gitlab.get(`/projects/${projectPath}`); project = data; } catch (error: any) { if (error.response?.status === 404) { @@ -566,13 +568,21 @@ export class WorkbenchStore { throw new Error('Repository creation cancelled'); } - // Create new project after confirmation - const { data } = await gitlab.post('/projects', { - name: repoName, - visibility: 'public', - initialize_with_readme: true, - }); - project = data; + try { + // Create new project after confirmation + const { data } = await gitlab.post('/projects', { + name: repoName, + visibility: 'public', + initialize_with_readme: false, + }); + project = data; + } catch (createError: any) { + if (createError.response?.status === 400) { + throw new Error(`Failed to create project: ${createError.response.data.message}`); + } + + throw createError; + } } else { // Re-throw other errors throw error; @@ -586,31 +596,61 @@ export class WorkbenchStore { throw new Error('No files found to push'); } - const actions = Object.entries(files) - .filter((entry): entry is [string, File] => { - const [, dirent] = entry; + const validFiles = Object.entries(files).filter((entry): entry is [string, File] => { + const [, dirent] = entry; + return dirent?.type === 'file' && typeof dirent?.content === 'string'; + }); + + if (validFiles.length === 0) { + throw new Error('No valid files to push'); + } - if (actions.length === 0) { - throw new Error('No valid files to push'); - } + // Get existing files in the repository + let existingFiles: string[] = []; - return dirent?.type === 'file' && typeof dirent?.content === 'string'; - }) - .map(([filePath, dirent]) => ({ - action: 'create', - file_path: extractRelativePath(filePath), - content: dirent.content, - })); + try { + const { data: treeData } = await gitlab.get(`/projects/${project.id}/repository/tree`, { + params: { + ref: project.default_branch || 'main', + recursive: true, + per_page: 100, + }, + }); + existingFiles = treeData.map((file: any) => file.path); + } catch (error: any) { + // If we can't get the tree (e.g., empty repository), assume no files exist + if (error.response?.status !== 404) { + console.error('Error getting repository tree:', error); + } + } - await gitlab.post(`/projects/${project.id}/repository/commits`, { - branch: project.default_branch || 'main', - commit_message: 'Initial commit from your app', - actions, + // Create actions for each file + const actions = validFiles.map(([filePath, dirent]) => { + const relativePath = extractRelativePath(filePath); + return { + action: existingFiles.includes(relativePath) ? 'update' : 'create', + file_path: relativePath, + content: dirent.content, + }; }); - toast.success(`Repository created and code pushed: ${project.web_url}`); + try { + await gitlab.post(`/projects/${project.id}/repository/commits`, { + branch: project.default_branch || 'main', + commit_message: 'Update from your app', + actions, + }); + + toast.success(`Repository updated and code pushed: ${project.web_url}`); - return project.web_url; + return project.web_url; + } catch (commitError: any) { + if (commitError.response?.data?.message) { + throw new Error(`Failed to commit changes: ${commitError.response.data.message}`); + } + + throw commitError; + } } catch (error) { console.error('Error pushing to GitLab:', error); From 9d0c2932967d747ea0395fb5fc3acd5e4b5e5be7 Mon Sep 17 00:00:00 2001 From: Arne Durr Date: Sun, 15 Dec 2024 15:45:01 +0100 Subject: [PATCH 12/12] New and existing push work with gitlab --- app/commit.json | 2 +- app/lib/stores/workbench.ts | 32 +++++++++++--------------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/app/commit.json b/app/commit.json index e29c0e27f..cf3167dd2 100644 --- a/app/commit.json +++ b/app/commit.json @@ -1 +1 @@ -{ "commit": "6191f3745e80b42bbf6fc9c2ab23d7574530c67e" } +{ "commit": "511bce10df5c8ac84bac6c295024296993723a24" } diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index d28c55b6d..82fe22cb4 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -548,44 +548,36 @@ export class WorkbenchStore { throw new Error('Authentication failed'); } - throw new Error(error.response?.data?.message || error.message); + throw error; }, ); let project; try { - // Use proper URL encoding for the project path + // First try to get existing project const projectPath = encodeURIComponent(`${username}/${repoName}`); const { data } = await gitlab.get(`/projects/${projectPath}`); project = data; + console.log('Found existing project:', project); } catch (error: any) { if (error.response?.status === 404) { - // Project doesn't exist, ask for confirmation - const shouldCreate = confirm(`Repository "${repoName}" doesn't exist. Would you like to create it?`); - - if (!shouldCreate) { - throw new Error('Repository creation cancelled'); - } - + // Project doesn't exist, create it try { - // Create new project after confirmation const { data } = await gitlab.post('/projects', { name: repoName, visibility: 'public', initialize_with_readme: false, }); project = data; + console.log('Created new project:', project); } catch (createError: any) { - if (createError.response?.status === 400) { - throw new Error(`Failed to create project: ${createError.response.data.message}`); - } - - throw createError; + console.error('Error creating project:', createError); + throw new Error(createError.response?.data?.message || 'Failed to create project'); } } else { - // Re-throw other errors - throw error; + console.error('Error accessing project:', error); + throw new Error('Failed to access project. Please check if you have the correct permissions.'); } } @@ -645,6 +637,8 @@ export class WorkbenchStore { return project.web_url; } catch (commitError: any) { + console.error('Commit error:', commitError); + if (commitError.response?.data?.message) { throw new Error(`Failed to commit changes: ${commitError.response.data.message}`); } @@ -655,10 +649,6 @@ export class WorkbenchStore { console.error('Error pushing to GitLab:', error); if (error instanceof Error) { - if (error.message === 'Repository creation cancelled') { - throw error; - } - if (error.message === 'Authentication failed') { toast.error('Authentication failed. Please check your GitLab token in the Connections tab.'); } else {