/[sudobot]/branches/7.x/src/commands/settings/UpdateCommand.ts
ViewVC logotype

Contents of /branches/7.x/src/commands/settings/UpdateCommand.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (show annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 20177 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 /**
2 * This file is part of SudoBot.
3 *
4 * Copyright (C) 2021-2023 OSN Developers.
5 *
6 * SudoBot is free software; you can redistribute it and/or modify it
7 * under the terms of the GNU Affero General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * SudoBot is distributed in the hope that it will be useful, but
12 * WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU Affero General Public License for more details.
15 *
16 * You should have received a copy of the GNU Affero General Public License
17 * along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20 import axios from "axios";
21 import { spawnSync } from "child_process";
22 import {
23 ActionRowBuilder,
24 ButtonBuilder,
25 ButtonInteraction,
26 ButtonStyle,
27 Colors,
28 ComponentType,
29 StringSelectMenuBuilder,
30 StringSelectMenuInteraction,
31 StringSelectMenuOptionBuilder
32 } from "discord.js";
33 import { existsSync } from "fs";
34 import { cp, mkdir, rename, rm } from "fs/promises";
35 import path, { basename, join } from "path";
36 import semver from "semver";
37 import Command, { AnyCommandContext, CommandMessage, CommandReturn, ValidationRule } from "../../core/Command";
38 import { downloadFile } from "../../utils/download";
39 import { log, logError, logInfo, logWarn } from "../../utils/logger";
40 import { sudoPrefix } from "../../utils/utils";
41
42 export default class UpdateCommand extends Command {
43 public readonly name = "update";
44 public readonly validationRules: ValidationRule[] = [];
45 public readonly permissions = [];
46 public readonly description = "Updates the bot to the latest version.";
47 public readonly systemAdminOnly = true;
48 protected readonly RELEASE_API_URL = "https://api.github.com/repos/onesoft-sudo/sudobot/releases/latest";
49 protected readonly UNSTABLE_DOWNLOAD_URL = "https://github.com/onesoft-sudo/sudobot/archive/refs/heads/main.zip";
50 public updateChannel?: "stable" | "unstable";
51 public readonly beta = true;
52
53 async execute(message: CommandMessage, context: AnyCommandContext): Promise<CommandReturn> {
54 const unsatisfiedRequirement = this.checkRequirements();
55
56 if (unsatisfiedRequirement) {
57 await this.error(
58 message,
59 `The \`${unsatisfiedRequirement}\` program is not installed in the current system. Please install it if you want to use this command.`
60 );
61 return;
62 }
63
64 await this.deferIfInteraction(message);
65
66 try {
67 const response = await axios.get(this.RELEASE_API_URL);
68 const tagName = response.data?.tag_name;
69 const version = tagName.replace(/^v/, "");
70 const stableDownloadURL = `https://github.com/onesoft-sudo/sudobot/archive/refs/tags/${tagName}.zip`;
71 const updateAvailable = semver.gt(version, this.client.metadata.data.version);
72 this.updateChannel = updateAvailable ? "stable" : "unstable";
73
74 await this.deferredReply(message, {
75 embeds: [
76 {
77 author: {
78 name: "System Update",
79 icon_url: this.client.user?.displayAvatarURL() ?? undefined
80 },
81 description:
82 "Are you sure you want to continue? This will download an update, install it, push schema changes to the database, and then restart the bot system. Make sure you have a backup in case if a data loss occurs.",
83 color: 0x007bff
84 }
85 ],
86 components: this.actionRow({ updateAvailable, version })
87 });
88
89 const updateChannelCollector = message.channel!.createMessageComponentCollector({
90 componentType: ComponentType.StringSelect,
91 filter: (interaction: StringSelectMenuInteraction) => {
92 if (interaction.user.id === message.member!.user.id && interaction.customId === "system_update_channel") {
93 return true;
94 }
95
96 interaction
97 .reply({
98 ephemeral: true,
99 content: `That's not under your control.`
100 })
101 .catch(logError);
102
103 return false;
104 },
105 time: 120_000
106 });
107
108 const confirmationCollector = message.channel!.createMessageComponentCollector({
109 componentType: ComponentType.Button,
110 filter: (interaction: ButtonInteraction) => {
111 if (interaction.user.id === message.member!.user.id && interaction.customId.startsWith(`system_update__`)) {
112 return true;
113 }
114
115 interaction
116 .reply({
117 ephemeral: true,
118 content: `That's not under your control.`
119 })
120 .catch(logError);
121
122 return false;
123 },
124 time: 120_000
125 });
126
127 confirmationCollector.on("collect", async interaction => {
128 if (!interaction.isButton()) {
129 return;
130 }
131
132 if (interaction.customId === "system_update__cancel") {
133 confirmationCollector.stop();
134 updateChannelCollector.stop();
135
136 await interaction.update({
137 embeds: [
138 {
139 author: {
140 name: "System Update",
141 icon_url: this.client.user?.displayAvatarURL() ?? undefined
142 },
143 description: "Update cancelled.",
144 color: 0xf14a60
145 }
146 ],
147 components: this.actionRow({ updateAvailable, version, disabled: true })
148 });
149
150 return;
151 }
152
153 if (!this.updateChannel) {
154 await interaction.reply({
155 content: "Please select an update channel first!"
156 });
157
158 return;
159 }
160
161 await interaction.update({
162 embeds: [
163 {
164 author: {
165 name: "System Update",
166 icon_url: this.client.user?.displayAvatarURL() ?? undefined
167 },
168 description: `${this.emoji("loading")} Update in progress...`,
169 color: 0x007bff
170 }
171 ],
172 components: this.actionRow({ updateAvailable, version, disabled: true })
173 });
174
175 let success = false;
176
177 try {
178 success = await this.update({
179 stableDownloadURL,
180 version
181 });
182 } catch (e) {}
183
184 await interaction.message.edit({
185 embeds: [
186 {
187 author: {
188 name: "System Update",
189 icon_url: this.client.user?.displayAvatarURL() ?? undefined
190 },
191 description: success
192 ? `${this.emoji("check")} Successfully installed the update. Restarting now.`
193 : `${this.emoji("error")} An error has occurred while performing the update.`,
194 color: Colors.Green
195 }
196 ]
197 });
198
199 process.exit(this.client.configManager.systemConfig.restart_exit_code);
200 });
201
202 updateChannelCollector.on("collect", async interaction => {
203 if (!interaction.isStringSelectMenu()) {
204 return;
205 }
206
207 const updateChannel = interaction.component.options[0].value;
208
209 if (!["stable", "unstable"].includes(updateChannel)) {
210 return;
211 }
212
213 this.updateChannel = updateChannel as (typeof this)["updateChannel"];
214 await interaction.deferUpdate();
215 });
216 } catch (e) {
217 logError(e);
218 await this.error(message, "An unknown error has occurred while trying to fetch information about the updates.");
219 }
220 }
221
222 actionRow({ version, updateAvailable, disabled = false }: { version: string; updateAvailable: boolean; disabled?: boolean }) {
223 return [
224 new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
225 new StringSelectMenuBuilder()
226 .addOptions(
227 new StringSelectMenuOptionBuilder({
228 label: "Latest Stable",
229 description: `${version} • ${updateAvailable ? "Update available" : "Up to date"}`,
230 value: "stable",
231 default: updateAvailable
232 }).setEmoji("⚙"),
233 new StringSelectMenuOptionBuilder({
234 label: "Latest Unstable",
235 description: `main • Unstable versions may break things unexpectedly`,
236 value: "unstable",
237 default: !updateAvailable
238 }).setEmoji("⚒️")
239 )
240 .setCustomId("system_update_channel")
241 .setMaxValues(1)
242 .setMinValues(1)
243 .setPlaceholder("Select an update channel")
244 .setDisabled(disabled)
245 ),
246 new ActionRowBuilder<ButtonBuilder>().addComponents(
247 new ButtonBuilder()
248 .setCustomId("system_update__cancel")
249 .setLabel("Cancel")
250 .setStyle(ButtonStyle.Danger)
251 .setDisabled(disabled),
252 new ButtonBuilder()
253 .setCustomId("system_update__continue")
254 .setLabel("Continue")
255 .setStyle(ButtonStyle.Success)
256 .setDisabled(disabled)
257 )
258 ];
259 }
260
261 downloadUpdate({ stableDownloadURL, version }: { stableDownloadURL: string; version: string }): Promise<{
262 filePath?: string;
263 storagePath?: string;
264 error?: Error;
265 }> {
266 const url = this.updateChannel === "stable" ? stableDownloadURL : this.UNSTABLE_DOWNLOAD_URL;
267 const tmpdir = sudoPrefix("tmp", true);
268 const dirname = `update-${this.updateChannel === "stable" ? version : "unstable"}`;
269
270 try {
271 return downloadFile({
272 url,
273 path: tmpdir,
274 name: `${dirname}.zip`
275 });
276 } catch (error) {
277 logError(error);
278 logError("Failed to download the update package. Aborting");
279 return Promise.resolve({ error: error as Error });
280 }
281 }
282
283 checkRequirements() {
284 const paths = process.env.PATH?.split(process.platform === "win32" ? ";" : ":") ?? [];
285
286 for (const path of paths) {
287 if (process.platform === "win32" && existsSync(join(path, "powershell.exe"))) {
288 return null;
289 }
290
291 if (existsSync(join(path, "unzip"))) {
292 return null;
293 }
294 }
295
296 return process.platform === "win32" ? "powershell.exe" : "unzip";
297 }
298
299 async unpackUpdate({ filePath, storagePath, version }: { version: string; filePath: string; storagePath: string }) {
300 const dirname = `update-${this.updateChannel === "stable" ? version : "unstable"}`;
301 const unpackedDirectory = join(storagePath!, dirname);
302
303 try {
304 const cwd = process.cwd();
305
306 if (existsSync(unpackedDirectory)) {
307 await rm(unpackedDirectory, { recursive: true });
308 }
309
310 await mkdir(unpackedDirectory);
311 process.chdir(unpackedDirectory);
312
313 const { status, error } = spawnSync(
314 process.platform === "win32"
315 ? `powershell -command "Expand-Archive -Force '${filePath}' '${unpackedDirectory}'"`
316 : `unzip ../${basename(filePath)}`,
317 {
318 shell: true,
319 encoding: "utf-8",
320 stdio: "inherit",
321 cwd: unpackedDirectory
322 }
323 );
324
325 if (status !== 0 || error) {
326 throw error;
327 }
328
329 process.chdir(cwd);
330 return { unpackedDirectory };
331 } catch (error) {
332 logError(error);
333 logError("Failed to unpack the update package. Aborting.");
334 return { error };
335 }
336 }
337
338 private createDirectoryBackupPair(name: string) {
339 return [path.join(__dirname, "../../../", name), path.join(__dirname, `../../../.backup/${name}`)] as const;
340 }
341
342 async installUpdate({ unpackedDirectory, version }: { unpackedDirectory: string; version: string }) {
343 const { error, dirpairs } = await this.backupCurrentSystem(["build", "src", "prisma", "scripts"]);
344
345 if (error) {
346 return false;
347 }
348
349 const installFiles = ["src", "prisma", "scripts", "package.json", "tsconfig.json"];
350
351 try {
352 for (const installFile of installFiles) {
353 const src = path.join(
354 unpackedDirectory,
355 `sudobot-${this.updateChannel === "stable" ? version : "main"}`,
356 installFile
357 );
358 const dest = path.join(__dirname, "../../../", installFile);
359
360 log(`Installing ${src} to ${dest}`);
361
362 await rm(dest, {
363 recursive: true
364 });
365
366 await cp(src, dest, {
367 recursive: true
368 });
369 }
370 } catch (error) {
371 logError(error);
372 logError("Failed to install the update package. Attempting to rollback the changes");
373 await this.rollbackUpdate(dirpairs!);
374 return false;
375 }
376
377 return await this.buildNewInstallation(dirpairs!);
378 }
379
380 async backupCurrentSystem(dirsToBackup: string[]) {
381 const dirpairs = dirsToBackup.map(dir => this.createDirectoryBackupPair(dir));
382
383 try {
384 await this.createBackupDirectoryIfNeeded();
385
386 for (const [src, dest] of dirpairs) {
387 log(`Copying ${src} to ${dest}`);
388
389 await cp(src, dest, {
390 recursive: true
391 });
392 }
393 } catch (error) {
394 logError(error);
395 logError("Failed to backup the current bot system. Attempting to revert changes");
396 await this.rollbackUpdate(dirpairs);
397 return { error };
398 }
399
400 return { dirpairs };
401 }
402
403 async createBackupDirectoryIfNeeded() {
404 const backupDir = path.join(__dirname, `../../../.backup`);
405
406 if (!existsSync(backupDir)) {
407 await mkdir(backupDir);
408 }
409
410 return backupDir;
411 }
412
413 async rollbackUpdate(dirpairs: Array<readonly [string, string]>) {
414 try {
415 const backupDir = await this.createBackupDirectoryIfNeeded();
416
417 for (const [src, dest] of dirpairs) {
418 if (!existsSync(dest)) {
419 log(`No backup found for ${src}`);
420 continue;
421 }
422
423 log(`Restoring ${src} from ${dest}`);
424
425 if (existsSync(src)) {
426 log(`Saving current state of ${src}`);
427 await rename(src, path.join(backupDir, `${basename(src)}.current`)).catch(log);
428 }
429
430 await rename(dest, src);
431 }
432
433 logInfo("Rolled back the update successfully");
434 } catch (error) {
435 logError(error);
436 logError("Error rolling back the update");
437 return false;
438 }
439
440 return true;
441 }
442
443 async cleanup({ unpackedDirectory, downloadedFile }: { unpackedDirectory: string; downloadedFile: string }) {
444 await rm(unpackedDirectory, { recursive: true });
445 await rm(downloadedFile);
446 }
447
448 buildNewInstallation(dirpairs: Array<readonly [string, string]>) {
449 const { status: buildStatus } = spawnSync(`npm run build`, {
450 stdio: "inherit",
451 cwd: path.join(__dirname, "../../.."),
452 encoding: "utf-8",
453 shell: true
454 });
455
456 if (buildStatus !== 0) {
457 logError("Failed to build the update. Rolling back");
458 return this.rollbackUpdate(dirpairs);
459 }
460
461 const { status: dbPushStatus } = spawnSync(`npx prisma db push`, {
462 stdio: "inherit",
463 cwd: path.join(__dirname, "../../.."),
464 encoding: "utf-8",
465 shell: true
466 });
467
468 if (dbPushStatus !== 0) {
469 logError("Failed to push database schema changes. Rolling back");
470 return this.rollbackUpdate(dirpairs);
471 }
472
473 const { status: slashCommandStatus } = spawnSync(`npm run deploy`, {
474 stdio: "inherit",
475 cwd: path.join(__dirname, "../../.."),
476 encoding: "utf-8",
477 shell: true
478 });
479
480 if (slashCommandStatus !== 0) {
481 logWarn("Failed to update application commands. Please manually update them.");
482 }
483
484 return true;
485 }
486
487 async update({ stableDownloadURL, version }: { stableDownloadURL: string; version: string }) {
488 const updateChannel = this.updateChannel;
489
490 if (!updateChannel) {
491 return false;
492 }
493
494 if (
495 existsSync(path.join(__dirname, "../../..", ".git")) &&
496 (existsSync("/usr/bin/git") || existsSync("/usr/local/bin/git") || existsSync("/bin/git"))
497 ) {
498 const { error, dirpairs } = await this.backupCurrentSystem(["build", "src", "prisma", "scripts"]);
499
500 if (error) {
501 return false;
502 }
503
504 const { status: gitStatus } = spawnSync(`git pull`, {
505 stdio: "inherit",
506 cwd: path.join(__dirname, "../../.."),
507 encoding: "utf-8",
508 shell: true
509 });
510
511 if (!gitStatus) {
512 return false;
513 }
514
515 const buildSucceeded = await this.buildNewInstallation(dirpairs!);
516
517 if (!buildSucceeded) {
518 await this.rollbackUpdate(dirpairs!).catch(logError);
519 return false;
520 }
521
522 return true;
523 }
524
525 const { error: downloadError, filePath, storagePath } = await this.downloadUpdate({ stableDownloadURL, version });
526
527 if (downloadError) {
528 return false;
529 }
530
531 const { error: unpackError, unpackedDirectory } = await this.unpackUpdate({
532 filePath: filePath!,
533 storagePath: storagePath!,
534 version
535 });
536
537 if (unpackError) {
538 return false;
539 }
540
541 const successfullyInstalled = await this.installUpdate({ unpackedDirectory: unpackedDirectory!, version });
542
543 if (!successfullyInstalled) {
544 return false;
545 }
546
547 await this.cleanup({ unpackedDirectory: unpackedDirectory!, downloadedFile: filePath! }).catch(log);
548 return true;
549 }
550 }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26