Merge pull request #3933 from SillyTavern/feat/ext-installer-branch Add branch selection on extension installer
Signed| @@ -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) { |
| @@ -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 = ''; |
| 665 | 666 | ||
| 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, | |||
| 701 | 702 | ||
| 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 | } |
| 946 | 948 | ||
| 949 | async 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 | |||
| 947 | async function onMoveClick() { | 987 | async 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 | } |
| 1057 | 1097 | ||
| 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 | */ | ||
| 1109 | async 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 | */ | ||
| 1141 | async 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 URL | 1170 | * @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 | */ |
| 1064 | export async function installExtension(url, global) { | 1174 | export async function installExtension(url, global, branch = '') { |
| 1065 | console.debug('Extension installation started', url); | 1175 | console.debug('Extension installation started', url); |
| 1066 | 1176 | ||
| 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 | }); |
| 1077 | 1188 | ||
| @@ -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 | }; | ||
| 1409 | 1527 | ||
| 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(); |
| 1413 | 1532 | ||
| 1414 | if (!input) { | 1533 | if (!input) { |
| @@ -1417,7 +1536,8 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { | |||
| 1417 | } | 1536 | } |
| 1418 | 1537 | ||
| 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 | } |
| 1422 | 1542 | ||
| 1423 | export async function initExtensions() { | 1543 | export 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); | ||
| 1436 | 1557 | ||
| 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. |
| @@ -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 | } |
| @@ -71,7 +71,8 @@ export const POPUP_RESULT = { | |||
| 71 | * @property {string} id - The id for the html element | 71 | * @property {string} id - The id for the html element |
| 72 | * @property {string} label - The label text for the input | 72 | * @property {string} label - The label text for the input |
| 73 | * @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label | 73 | * @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 | */ |
| 76 | 77 | ||
| 77 | /** | 78 | /** |
| @@ -157,7 +158,7 @@ export class Popup { | |||
| 157 | 158 | ||
| 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; |
| 162 | 163 | ||
| 163 | /** @type {HTMLElement} */ lastFocus; | 164 | /** @type {HTMLElement} */ lastFocus; |
| @@ -260,13 +261,14 @@ export class Popup { | |||
| 260 | return; | 261 | return; |
| 261 | } | 262 | } |
| 262 | 263 | ||
| 264 | if (!input.type || input.type === 'checkbox') { | ||
| 263 | const label = document.createElement('label'); | 265 | const label = document.createElement('label'); |
| 264 | label.classList.add('checkbox_label', 'justifyCenter'); | 266 | label.classList.add('checkbox_label', 'justifyCenter'); |
| 265 | label.setAttribute('for', input.id); | 267 | label.setAttribute('for', input.id); |
| 266 | const inputElement = document.createElement('input'); | 268 | const inputElement = document.createElement('input'); |
| 267 | inputElement.type = 'checkbox'; | 269 | inputElement.type = 'checkbox'; |
| 268 | inputElement.id = input.id; | 270 | inputElement.id = input.id; |
| 269 | inputElement.checked = input.defaultState ?? false; | 271 | inputElement.checked = Boolean(input.defaultState ?? false); |
| 270 | label.appendChild(inputElement); | 272 | label.appendChild(inputElement); |
| 271 | const labelText = document.createElement('span'); | 273 | const labelText = document.createElement('span'); |
| 272 | labelText.innerText = input.label; | 274 | labelText.innerText = input.label; |
| @@ -282,6 +284,30 @@ export class Popup { | |||
| 282 | } | 284 | } |
| 283 | 285 | ||
| 284 | this.inputControls.appendChild(label); | 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; | ||
| 302 | |||
| 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 | }); |
| 286 | 312 | ||
| 287 | // Set the default button class | 313 | // 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 | } |
| 535 | 562 | ||
| @@ -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: [], |
| 621 | 648 | ||
| 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, |
| 624 | 651 | ||
| 625 | /** @returns {boolean} Checks if any modal popup dialog is open */ | 652 | /** @returns {boolean} Checks if any modal popup dialog is open */ |
| @@ -30,17 +30,23 @@ async function getManifest(extensionPath) { | |||
| 30 | * @returns {Promise<Object>} - Returns the extension information as an object | 30 | * @returns {Promise<Object>} - Returns the extension information as an object |
| 31 | */ | 31 | */ |
| 32 | async function checkIfRepoIsUpToDate(extensionPath) { | 32 | async 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 | }); |
| 41 | 41 | ||
| 42 | // Fetch remote repository information | 42 | // 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 | } | ||
| 44 | 50 | ||
| 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 | } |
| 78 | 84 | ||
| 79 | const { url, global } = request.body; | 85 | const { url, global, branch } = request.body; |
| 80 | 86 | ||
| 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 | } |
| 92 | 98 | ||
| 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`); | ||
| 95 | 105 | ||
| 96 | const { version, author, display_name } = await getManifest(extensionPath); | 106 | const { version, author, display_name } = await getManifest(extensionPath); |
| 97 | 107 | ||
| @@ -114,7 +124,6 @@ router.post('/install', async (request, response) => { | |||
| 114 | * @returns {void} | 124 | * @returns {void} |
| 115 | */ | 125 | */ |
| 116 | router.post('/update', async (request, response) => { | 126 | router.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 | } |
| 129 | 138 | ||
| 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)); |
| 132 | 141 | ||
| 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 | } |
| 136 | 145 | ||
| 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); |
| 148 | 158 | ||
| 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 | }); |
| 156 | 166 | ||
| 167 | router.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 | |||
| 212 | router.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 | |||
| 157 | router.post('/move', async (request, response) => { | 271 | router.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 | }); |
| 200 | 314 | ||
| @@ -209,7 +323,6 @@ router.post('/move', async (request, response) => { | |||
| 209 | * @returns {void} | 323 | * @returns {void} |
| 210 | */ | 324 | */ |
| 211 | router.post('/version', async (request, response) => { | 325 | router.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 | } |
| 225 | 338 | ||
| 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 repo | 344 | // 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 either | 345 | // 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 | } |
| 234 | 348 | ||
| 235 | const currentBranch = await git.cwd(extensionPath).branch(); | 349 | const currentBranch = await git.branch(); |
| 236 | // get only the working branch | 350 | // 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); |
| 241 | 355 | ||