Merge pull request #3933 from SillyTavern/feat/ext-installer-branch Add branch selection on extension installer

b25322b844cac8ced9993ca12a9d0cd7c97dfe82

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
5 files changed, +313 -51Ignore whitespace
public/scripts/RossAscends-mods.js+1 -1
@@ -1120,7 +1120,7 @@ export function initRossMods() {
1120 const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', {1120 const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', {
1121 customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }],1121 customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }],
1122 onClose: (popup) => {1122 onClose: (popup) => {
1123 regenerateWithCtrlEnter = popup.inputResults.get('regenerateWithCtrlEnter') ?? false;1123 regenerateWithCtrlEnter = Boolean(popup.inputResults.get('regenerateWithCtrlEnter') ?? false);
1124 },1124 },
1125 });1125 });
1126 if (!result) {1126 if (!result) {
public/scripts/extensions.js+124 -3
@@ -661,6 +661,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
661 let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" data-i18n="[title]Delete" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';661 let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" data-i18n="[title]Delete" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';
662 let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';662 let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';
663 let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';663 let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';
664 let branchButton = isExternal && isUserAdmin ? `<button class="btn_branch menu_button" data-name="${externalId}" data-i18n="[title]Switch branch" title="Switch branch"><i class="fa-solid fa-code-branch fa-fw"></i></button>` : '';
664 let modulesInfo = '';665 let modulesInfo = '';
665666
666 if (isActive && Array.isArray(manifest.optional)) {667 if (isActive && Array.isArray(manifest.optional)) {
@@ -701,6 +702,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
701702
702 <div class="extension_actions flex-container alignItemsCenter">703 <div class="extension_actions flex-container alignItemsCenter">
703 ${updateButton}704 ${updateButton}
705 ${branchButton}
704 ${moveButton}706 ${moveButton}
705 ${deleteButton}707 ${deleteButton}
706 </div>708 </div>
@@ -944,6 +946,44 @@ async function onDeleteClick() {
944 }946 }
945}947}
946948
949async function onBranchClick() {
950 const extensionName = $(this).data('name');
951 const isCurrentUserAdmin = isAdmin();
952 const isGlobal = getExtensionType(extensionName) === 'global';
953 if (isGlobal && !isCurrentUserAdmin) {
954 toastr.error(t`You don't have permission to switch branch.`);
955 return;
956 }
957
958 let newBranch = '';
959
960 const branches = await getExtensionBranches(extensionName, isGlobal);
961 const selectElement = document.createElement('select');
962 selectElement.classList.add('text_pole', 'wide100p');
963 selectElement.addEventListener('change', function () {
964 newBranch = this.value;
965 });
966 for (const branch of branches) {
967 const option = document.createElement('option');
968 option.value = branch.name;
969 option.textContent = `${branch.name} (${branch.commit}) [${branch.label}]`;
970 option.selected = branch.current;
971 selectElement.appendChild(option);
972 }
973
974 const popup = new Popup(selectElement, POPUP_TYPE.CONFIRM, '', {
975 okButton: t`Switch`,
976 cancelButton: t`Cancel`,
977 });
978 const popupResult = await popup.show();
979
980 if (!popupResult || !newBranch) {
981 return;
982 }
983
984 await switchExtensionBranch(extensionName, isGlobal, newBranch);
985}
986
947async function onMoveClick() {987async function onMoveClick() {
948 const extensionName = $(this).data('name');988 const extensionName = $(this).data('name');
949 const isCurrentUserAdmin = isAdmin();989 const isCurrentUserAdmin = isAdmin();
@@ -1056,12 +1096,82 @@ async function getExtensionVersion(extensionName, abortSignal) {
1056}1096}
10571097
1058/**1098/**
1099 * Gets the list of branches for a specific extension.
1100 * @param {string} extensionName The name of the extension
1101 * @param {boolean} isGlobal Whether the extension is global or not
1102 * @returns {Promise<ExtensionBranch[]>} List of branches for the extension
1103 * @typedef {object} ExtensionBranch
1104 * @property {string} name The name of the branch
1105 * @property {string} commit The commit hash of the branch
1106 * @property {boolean} current Whether this branch is the current one
1107 * @property {string} label The commit label of the branch
1108 */
1109async function getExtensionBranches(extensionName, isGlobal) {
1110 try {
1111 const response = await fetch('/api/extensions/branches', {
1112 method: 'POST',
1113 headers: getRequestHeaders(),
1114 body: JSON.stringify({
1115 extensionName,
1116 global: isGlobal,
1117 }),
1118 });
1119
1120 if (!response.ok) {
1121 const text = await response.text();
1122 toastr.error(text || response.statusText, t`Extension branches fetch failed`);
1123 console.error('Extension branches fetch failed', response.status, response.statusText, text);
1124 return [];
1125 }
1126
1127 return await response.json();
1128 } catch (error) {
1129 console.error('Error:', error);
1130 return [];
1131 }
1132}
1133
1134/**
1135 * Switches the branch of an extension.
1136 * @param {string} extensionName The name of the extension
1137 * @param {boolean} isGlobal If the extension is global
1138 * @param {string} branch Branch name to switch to
1139 * @returns {Promise<void>}
1140 */
1141async function switchExtensionBranch(extensionName, isGlobal, branch) {
1142 try {
1143 const response = await fetch('/api/extensions/switch', {
1144 method: 'POST',
1145 headers: getRequestHeaders(),
1146 body: JSON.stringify({
1147 extensionName,
1148 branch,
1149 global: isGlobal,
1150 }),
1151 });
1152
1153 if (!response.ok) {
1154 const text = await response.text();
1155 toastr.error(text || response.statusText, t`Extension branch switch failed`);
1156 console.error('Extension branch switch failed', response.status, response.statusText, text);
1157 return;
1158 }
1159
1160 toastr.success(t`Extension ${extensionName} switched to ${branch}`);
1161 await loadExtensionSettings({}, false, false);
1162 void showExtensionsDetails();
1163 } catch (error) {
1164 console.error('Error:', error);
1165 }
1166}
1167
1168/**
1059 * Installs a third-party extension via the API.1169 * Installs a third-party extension via the API.
1060 * @param {string} url Extension repository URL1170 * @param {string} url Extension repository URL
1061 * @param {boolean} global Is the extension global?1171 * @param {boolean} global Is the extension global?
1062 * @returns {Promise<void>}1172 * @returns {Promise<void>}
1063 */1173 */
1064export async function installExtension(url, global) {1174export async function installExtension(url, global, branch = '') {
1065 console.debug('Extension installation started', url);1175 console.debug('Extension installation started', url);
10661176
1067 toastr.info(t`Please wait...`, t`Installing extension`);1177 toastr.info(t`Please wait...`, t`Installing extension`);
@@ -1072,6 +1182,7 @@ export async function installExtension(url, global) {
1072 body: JSON.stringify({1182 body: JSON.stringify({
1073 url,1183 url,
1074 global,1184 global,
1185 branch,
1075 }),1186 }),
1076 });1187 });
10771188
@@ -1406,9 +1517,17 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
1406 await popup.complete(POPUP_RESULT.AFFIRMATIVE);1517 await popup.complete(POPUP_RESULT.AFFIRMATIVE);
1407 },1518 },
1408 };1519 };
1520 /** @type {import('./popup.js').CustomPopupInput} */
1521 const branchNameInput = {
1522 id: 'extension_branch_name',
1523 label: t`Branch or tag name (optional)`,
1524 type: 'text',
1525 tooltip: 'e.g. main, dev, v1.0.0',
1526 };
14091527
1410 const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];1528 const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];
1411 const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons });1529 const customInputs = [branchNameInput];
1530 const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons, customInputs });
1412 const input = await popup.show();1531 const input = await popup.show();
14131532
1414 if (!input) {1533 if (!input) {
@@ -1417,7 +1536,8 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
1417 }1536 }
14181537
1419 const url = String(input).trim();1538 const url = String(input).trim();
1420 await installExtension(url, global);1539 const branchName = String(popup.inputResults.get('extension_branch_name') ?? '').trim();
1540 await installExtension(url, global, branchName);
1421}1541}
14221542
1423export async function initExtensions() {1543export async function initExtensions() {
@@ -1433,6 +1553,7 @@ export async function initExtensions() {
1433 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);1553 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
1434 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);1554 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
1435 $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);1555 $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
1556 $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick);
14361557
1437 /**1558 /**
1438 * Handles the click event for the third-party extension import button.1559 * Handles the click event for the third-party extension import button.
public/scripts/extensions/assets/index.js+2 -2
@@ -291,7 +291,7 @@ async function installAsset(url, assetType, filename) {
291 try {291 try {
292 if (category === 'extension') {292 if (category === 'extension') {
293 console.debug(DEBUG_PREFIX, 'Installing extension ', url);293 console.debug(DEBUG_PREFIX, 'Installing extension ', url);
294 await installExtension(url);294 await installExtension(url, false);
295 console.debug(DEBUG_PREFIX, 'Extension installed.');295 console.debug(DEBUG_PREFIX, 'Extension installed.');
296 return;296 return;
297 }297 }
@@ -309,7 +309,7 @@ async function installAsset(url, assetType, filename) {
309 console.debug(DEBUG_PREFIX, 'Importing character ', filename);309 console.debug(DEBUG_PREFIX, 'Importing character ', filename);
310 const blob = await result.blob();310 const blob = await result.blob();
311 const file = new File([blob], filename, { type: blob.type });311 const file = new File([blob], filename, { type: blob.type });
312 await processDroppedFiles([file], true);312 await processDroppedFiles([file]);
313 console.debug(DEBUG_PREFIX, 'Character downloaded.');313 console.debug(DEBUG_PREFIX, 'Character downloaded.');
314 }314 }
315 }315 }
public/scripts/popup.js+52 -25
@@ -71,7 +71,8 @@ export const POPUP_RESULT = {
71 * @property {string} id - The id for the html element71 * @property {string} id - The id for the html element
72 * @property {string} label - The label text for the input72 * @property {string} label - The label text for the input
73 * @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label73 * @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label
74 * @property {boolean?} [defaultState=false] - The default state when opening the popup (false if not set)74 * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)
75 * @property {string?} [type='checkbox'] - The type of the input (default is checkbox)
75 */76 */
7677
77/**78/**
@@ -157,7 +158,7 @@ export class Popup {
157158
158 /** @type {POPUP_RESULT|number} */ result;159 /** @type {POPUP_RESULT|number} */ result;
159 /** @type {any} */ value;160 /** @type {any} */ value;
160 /** @type {Map<string,boolean>?} */ inputResults;161 /** @type {Map<string,string|boolean>?} */ inputResults;
161 /** @type {any} */ cropData;162 /** @type {any} */ cropData;
162163
163 /** @type {HTMLElement} */ lastFocus;164 /** @type {HTMLElement} */ lastFocus;
@@ -260,28 +261,53 @@ export class Popup {
260 return;261 return;
261 }262 }
262263
263 const label = document.createElement('label');264 if (!input.type || input.type === 'checkbox') {
264 label.classList.add('checkbox_label', 'justifyCenter');265 const label = document.createElement('label');
265 label.setAttribute('for', input.id);266 label.classList.add('checkbox_label', 'justifyCenter');
266 const inputElement = document.createElement('input');267 label.setAttribute('for', input.id);
267 inputElement.type = 'checkbox';268 const inputElement = document.createElement('input');
268 inputElement.id = input.id;269 inputElement.type = 'checkbox';
269 inputElement.checked = input.defaultState ?? false;270 inputElement.id = input.id;
270 label.appendChild(inputElement);271 inputElement.checked = Boolean(input.defaultState ?? false);
271 const labelText = document.createElement('span');272 label.appendChild(inputElement);
272 labelText.innerText = input.label;273 const labelText = document.createElement('span');
273 labelText.dataset.i18n = input.label;274 labelText.innerText = input.label;
274 label.appendChild(labelText);275 labelText.dataset.i18n = input.label;
275276 label.appendChild(labelText);
276 if (input.tooltip) {277
277 const tooltip = document.createElement('div');278 if (input.tooltip) {
278 tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');279 const tooltip = document.createElement('div');
279 tooltip.title = input.tooltip;280 tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
280 tooltip.dataset.i18n = '[title]' + input.tooltip;281 tooltip.title = input.tooltip;
281 label.appendChild(tooltip);282 tooltip.dataset.i18n = '[title]' + input.tooltip;
282 }283 label.appendChild(tooltip);
284 }
285
286 this.inputControls.appendChild(label);
287 } else if (input.type === 'text') {
288 const label = document.createElement('label');
289 label.classList.add('text_label', 'justifyCenter');
290 label.setAttribute('for', input.id);
291
292 const inputElement = document.createElement('input');
293 inputElement.classList.add('text_pole');
294 inputElement.type = 'text';
295 inputElement.id = input.id;
296 inputElement.value = String(input.defaultState ?? '');
297 inputElement.placeholder = input.tooltip ?? '';
298
299 const labelText = document.createElement('span');
300 labelText.innerText = input.label;
301 labelText.dataset.i18n = input.label;
283302
284 this.inputControls.appendChild(label);303 label.appendChild(labelText);
304 label.appendChild(inputElement);
305
306 this.inputControls.appendChild(label);
307 } else {
308 console.warn('Unknown custom input type. Only checkbox and text are supported.', input);
309 return;
310 }
285 });311 });
286312
287 // Set the default button class313 // Set the default button class
@@ -529,7 +555,8 @@ export class Popup {
529 this.inputResults = new Map(this.customInputs.map(input => {555 this.inputResults = new Map(this.customInputs.map(input => {
530 /** @type {HTMLInputElement} */556 /** @type {HTMLInputElement} */
531 const inputControl = this.dlg.querySelector(`#${input.id}`);557 const inputControl = this.dlg.querySelector(`#${input.id}`);
532 return [inputControl.id, inputControl.checked];558 const value = input.type === 'text' ? inputControl.value : inputControl.checked;
559 return [inputControl.id, value];
533 }));560 }));
534 }561 }
535562
@@ -619,7 +646,7 @@ export class Popup {
619 /** @readonly @type {Popup[]} Remember all popups */646 /** @readonly @type {Popup[]} Remember all popups */
620 popups: [],647 popups: [],
621648
622 /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, boolean>?}?} Last popup result */649 /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, string|boolean>?}?} Last popup result */
623 lastResult: null,650 lastResult: null,
624651
625 /** @returns {boolean} Checks if any modal popup dialog is open */652 /** @returns {boolean} Checks if any modal popup dialog is open */
src/endpoints/extensions.js+134 -20
@@ -30,17 +30,23 @@ async function getManifest(extensionPath) {
30 * @returns {Promise<Object>} - Returns the extension information as an object30 * @returns {Promise<Object>} - Returns the extension information as an object
31 */31 */
32async function checkIfRepoIsUpToDate(extensionPath) {32async function checkIfRepoIsUpToDate(extensionPath) {
33 const git = simpleGit();33 const git = simpleGit({ baseDir: extensionPath });
34 await git.cwd(extensionPath).fetch('origin');34 await git.fetch('origin');
35 const currentBranch = await git.cwd(extensionPath).branch();35 const currentBranch = await git.branch();
36 const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);36 const currentCommitHash = await git.revparse(['HEAD']);
37 const log = await git.cwd(extensionPath).log({37 const log = await git.log({
38 from: currentCommitHash,38 from: currentCommitHash,
39 to: `origin/${currentBranch.current}`,39 to: `origin/${currentBranch.current}`,
40 });40 });
4141
42 // Fetch remote repository information42 // Fetch remote repository information
43 const remotes = await git.cwd(extensionPath).getRemotes(true);43 const remotes = await git.getRemotes(true);
44 if (remotes.length === 0) {
45 return {
46 isUpToDate: true,
47 remoteUrl: '',
48 };
49 }
4450
45 return {51 return {
46 isUpToDate: log.total === 0,52 isUpToDate: log.total === 0,
@@ -76,7 +82,7 @@ router.post('/install', async (request, response) => {
76 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);82 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
77 }83 }
7884
79 const { url, global } = request.body;85 const { url, global, branch } = request.body;
8086
81 if (global && !request.user.profile.admin) {87 if (global && !request.user.profile.admin) {
82 console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);88 console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
@@ -90,8 +96,12 @@ router.post('/install', async (request, response) => {
90 return response.status(409).send(`Directory already exists at ${extensionPath}`);96 return response.status(409).send(`Directory already exists at ${extensionPath}`);
91 }97 }
9298
93 await git.clone(url, extensionPath, { '--depth': 1 });99 const cloneOptions = { '--depth': 1 };
94 console.info(`Extension has been cloned at ${extensionPath}`);100 if (branch) {
101 cloneOptions['--branch'] = branch;
102 }
103 await git.clone(url, extensionPath, cloneOptions);
104 console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);
95105
96 const { version, author, display_name } = await getManifest(extensionPath);106 const { version, author, display_name } = await getManifest(extensionPath);
97107
@@ -114,7 +124,6 @@ router.post('/install', async (request, response) => {
114 * @returns {void}124 * @returns {void}
115 */125 */
116router.post('/update', async (request, response) => {126router.post('/update', async (request, response) => {
117 const git = simpleGit();
118 if (!request.body.extensionName) {127 if (!request.body.extensionName) {
119 return response.status(400).send('Bad Request: extensionName is required in the request body.');128 return response.status(400).send('Bad Request: extensionName is required in the request body.');
120 }129 }
@@ -128,22 +137,23 @@ router.post('/update', async (request, response) => {
128 }137 }
129138
130 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;139 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
131 const extensionPath = path.join(basePath, extensionName);140 const extensionPath = path.join(basePath, sanitize(extensionName));
132141
133 if (!fs.existsSync(extensionPath)) {142 if (!fs.existsSync(extensionPath)) {
134 return response.status(404).send(`Directory does not exist at ${extensionPath}`);143 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
135 }144 }
136145
137 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);146 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
138 const currentBranch = await git.cwd(extensionPath).branch();147 const git = simpleGit({ baseDir: extensionPath });
148 const currentBranch = await git.branch();
139 if (!isUpToDate) {149 if (!isUpToDate) {
140 await git.cwd(extensionPath).pull('origin', currentBranch.current);150 await git.pull('origin', currentBranch.current);
141 console.info(`Extension has been updated at ${extensionPath}`);151 console.info(`Extension has been updated at ${extensionPath}`);
142 } else {152 } else {
143 console.info(`Extension is up to date at ${extensionPath}`);153 console.info(`Extension is up to date at ${extensionPath}`);
144 }154 }
145 await git.cwd(extensionPath).fetch('origin');155 await git.fetch('origin');
146 const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);156 const fullCommitHash = await git.revparse(['HEAD']);
147 const shortCommitHash = fullCommitHash.slice(0, 7);157 const shortCommitHash = fullCommitHash.slice(0, 7);
148158
149 return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });159 return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
@@ -154,6 +164,110 @@ router.post('/update', async (request, response) => {
154 }164 }
155});165});
156166
167router.post('/branches', async (request, response) => {
168 try {
169 const { extensionName, global } = request.body;
170
171 if (!extensionName) {
172 return response.status(400).send('Bad Request: extensionName is required in the request body.');
173 }
174
175 if (global && !request.user.profile.admin) {
176 console.error(`User ${request.user.profile.handle} does not have permission to list branches of global extensions.`);
177 return response.status(403).send('Forbidden: No permission to list branches of global extensions.');
178 }
179
180 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
181 const extensionPath = path.join(basePath, sanitize(extensionName));
182
183 if (!fs.existsSync(extensionPath)) {
184 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
185 }
186
187 const git = simpleGit({ baseDir: extensionPath });
188 // Unshallow the repository if it is shallow
189 const isShallow = await git.revparse(['--is-shallow-repository']) === 'true';
190 if (isShallow) {
191 console.info(`Unshallowing the repository at ${extensionPath}`);
192 await git.fetch('origin', ['--unshallow']);
193 }
194
195 // Fetch all branches
196 await git.remote(['set-branches', 'origin', '*']);
197 await git.fetch('origin');
198 const localBranches = await git.branchLocal();
199 const remoteBranches = await git.branch(['-r', '--list', 'origin/*']);
200 const result = [
201 ...Object.values(localBranches.branches),
202 ...Object.values(remoteBranches.branches),
203 ].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label }));
204
205 return response.send(result);
206 } catch (error) {
207 console.error('Getting branches failed', error);
208 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
209 }
210});
211
212router.post('/switch', async (request, response) => {
213 try {
214 const { extensionName, branch, global } = request.body;
215
216 if (!extensionName || !branch) {
217 return response.status(400).send('Bad Request: extensionName and branch are required in the request body.');
218 }
219
220 if (global && !request.user.profile.admin) {
221 console.error(`User ${request.user.profile.handle} does not have permission to switch branches of global extensions.`);
222 return response.status(403).send('Forbidden: No permission to switch branches of global extensions.');
223 }
224
225 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
226 const extensionPath = path.join(basePath, sanitize(extensionName));
227
228 if (!fs.existsSync(extensionPath)) {
229 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
230 }
231
232 const git = simpleGit({ baseDir: extensionPath });
233 const branches = await git.branchLocal();
234
235 if (String(branch).startsWith('origin/')) {
236 const localBranch = branch.replace('origin/', '');
237 if (branches.all.includes(localBranch)) {
238 console.info(`Branch ${localBranch} already exists locally, checking it out`);
239 await git.checkout(localBranch);
240 return response.sendStatus(204);
241 }
242
243 console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`);
244 await git.checkoutBranch(localBranch, branch);
245 return response.sendStatus(204);
246 }
247
248 if (!branches.all.includes(branch)) {
249 console.error(`Branch ${branch} does not exist locally`);
250 return response.status(404).send(`Branch ${branch} does not exist locally`);
251 }
252
253 // Check if the branch is already checked out
254 const currentBranch = await git.branch();
255 if (currentBranch.current === branch) {
256 console.info(`Branch ${branch} is already checked out`);
257 return response.sendStatus(204);
258 }
259
260 // Checkout the branch
261 await git.checkout(branch);
262 console.info(`Checked out branch ${branch} at ${extensionPath}`);
263
264 return response.sendStatus(204);
265 } catch (error) {
266 console.error('Switching branches failed', error);
267 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
268 }
269});
270
157router.post('/move', async (request, response) => {271router.post('/move', async (request, response) => {
158 try {272 try {
159 const { extensionName, source, destination } = request.body;273 const { extensionName, source, destination } = request.body;
@@ -194,7 +308,7 @@ router.post('/move', async (request, response) => {
194 return response.sendStatus(204);308 return response.sendStatus(204);
195 } catch (error) {309 } catch (error) {
196 console.error('Moving extension failed', error);310 console.error('Moving extension failed', error);
197 return response.status(500).send('Internal Server Error. Try again later.');311 return response.status(500).send('Internal Server Error. Check the server logs for more details.');
198 }312 }
199});313});
200314
@@ -209,7 +323,6 @@ router.post('/move', async (request, response) => {
209 * @returns {void}323 * @returns {void}
210 */324 */
211router.post('/version', async (request, response) => {325router.post('/version', async (request, response) => {
212 const git = simpleGit();
213 if (!request.body.extensionName) {326 if (!request.body.extensionName) {
214 return response.status(400).send('Bad Request: extensionName is required in the request body.');327 return response.status(400).send('Bad Request: extensionName is required in the request body.');
215 }328 }
@@ -223,19 +336,20 @@ router.post('/version', async (request, response) => {
223 return response.status(404).send(`Directory does not exist at ${extensionPath}`);336 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
224 }337 }
225338
339 const git = simpleGit({ baseDir: extensionPath });
226 let currentCommitHash;340 let currentCommitHash;
227 try {341 try {
228 currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);342 currentCommitHash = await git.revparse(['HEAD']);
229 } catch (error) {343 } catch (error) {
230 // it is not a git repo, or has no commits yet, or is a bare repo344 // it is not a git repo, or has no commits yet, or is a bare repo
231 // not possible to update it, most likely can't get the branch name either345 // not possible to update it, most likely can't get the branch name either
232 return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });346 return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });
233 }347 }
234348
235 const currentBranch = await git.cwd(extensionPath).branch();349 const currentBranch = await git.branch();
236 // get only the working branch350 // get only the working branch
237 const currentBranchName = currentBranch.current;351 const currentBranchName = currentBranch.current;
238 await git.cwd(extensionPath).fetch('origin');352 await git.fetch('origin');
239 console.debug(extensionName, currentBranchName, currentCommitHash);353 console.debug(extensionName, currentBranchName, currentCommitHash);
240 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);354 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
241355