diff --git a/src/lib/terminal/commands/index.ts b/src/lib/terminal/commands/index.ts index 3ed877a..a90d9b4 100644 --- a/src/lib/terminal/commands/index.ts +++ b/src/lib/terminal/commands/index.ts @@ -3,6 +3,7 @@ import { allSitePages } from '#lib/site/pages'; import { blankLine, errorLine, line, span } from '../output'; import { infoCommands } from './info'; import { systemCommands } from './system'; +import { pacmanCommand } from './pacman'; import type { Command, CommandContext } from './types'; export type { Command, CommandContext } from './types'; @@ -38,7 +39,14 @@ const socialShortcuts: Command[] = profile.socials.map((social) => ({ run: (_args, context) => context.openUrl(social.url) })); -export const commands: Command[] = [helpCommand, ...infoCommands, ...systemCommands, ...pageShortcuts, ...socialShortcuts]; +export const commands: Command[] = [ + helpCommand, + ...infoCommands, + ...systemCommands, + pacmanCommand, + ...pageShortcuts, + ...socialShortcuts +]; export function findCommand(name: string): Command | undefined { const lowerName = name.toLowerCase(); diff --git a/src/lib/terminal/commands/pacman.ts b/src/lib/terminal/commands/pacman.ts new file mode 100644 index 0000000..f738e52 --- /dev/null +++ b/src/lib/terminal/commands/pacman.ts @@ -0,0 +1,132 @@ +import { profile } from '#lib/content'; +import { blankLine, errorLine, line, span, type OutputBlock } from '../output'; +import type { Command, CommandContext } from './types'; + +const stepDelay = 350; +const progressBarWidth = 24; + +const jokePackages: Record = { + girlfriend: [errorLine('target not found: girlfriend')], + money: [errorLine('target not found: money'), line(span('hint: ', 'yellow'), 'try the Ko-fi icon in the top bar')], + sleep: [ + line(span(':: ', 'blue', { bold: true }), 'sleep and energy-drinks are in conflict. Remove energy-drinks? [y/N] n'), + errorLine('unresolvable package conflicts detected') + ], + windows: [errorLine('windows is not available for Arch Linux. Nice try.')], + job: [errorLine('job requires entry-level-position>=5-years-experience, which cannot be satisfied')], + internship: [errorLine('internship requires internship-experience, which requires internship')] +}; + +function packageVersion(name: string): string { + const seed = [...name].reduce((sum, character) => sum + character.charCodeAt(0), 0); + return `${(seed % 9) + 1}.${seed % 13}.${seed % 7}-1`; +} + +function packageSize(name: string): string { + return `${(name.length * 1.37 + 0.42).toFixed(2)} MiB`; +} + +function progressLine(index: number, total: number, action: string, name: string): OutputBlock { + const counter = `(${index}/${total})`; + return line( + span(`${counter} ${action} ${name}`.padEnd(38), 'foreground'), + span(`[${'#'.repeat(progressBarWidth)}]`, 'green'), + span(' 100%', 'muted') + ); +} + +function printOverTime(context: CommandContext, blocks: OutputBlock[]) { + blocks.forEach((block, index) => setTimeout(() => context.print(block), index * stepDelay)); +} + +function installPackages(context: CommandContext, packages: string[]) { + const jokeName = packages.find((name) => name in jokePackages); + if (jokeName) { + printOverTime(context, [line(span('resolving dependencies...', 'muted')), ...jokePackages[jokeName]]); + return; + } + + const total = packages.length; + printOverTime(context, [ + line(span('resolving dependencies...', 'muted')), + line(span('looking for conflicting packages...', 'muted')), + blankLine, + line(span(`Packages (${total}) `, 'foreground', { bold: true }), packages.map((name) => `${name}-${packageVersion(name)}`).join(' ')), + blankLine, + line(span('Total Installed Size: ', 'foreground', { bold: true }), packageSize(packages.join(''))), + blankLine, + line(span(':: ', 'blue', { bold: true }), 'Proceed with installation? [Y/n] y'), + ...packages.map((name, index) => progressLine(index + 1, total, 'installing', name)), + line(span(':: ', 'blue', { bold: true }), 'Running post-transaction hooks...'), + progressLine(1, 1, 'Reloading', profile.username), + blankLine, + line(span('(this is a website, nothing was actually installed, but it felt good right?)', 'muted')) + ]); +} + +function systemUpgrade(context: CommandContext) { + printOverTime(context, [ + line(span(':: ', 'blue', { bold: true }), 'Synchronizing package databases...'), + line(' core is up to date'), + line(' extra is up to date'), + line(span(':: ', 'blue', { bold: true }), 'Starting full system upgrade...'), + line(' there is nothing to do'), + line(span('btw, I use Arch.', 'accent')) + ]); +} + +function listInstalled(context: CommandContext) { + const installed = Object.values(profile.skills) + .flat() + .map((skill) => skill.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')); + context.print(...installed.map((name) => line(span(name, 'foreground', { bold: true }), ' ', span(packageVersion(name), 'green')))); +} + +function removePackages(context: CommandContext, packages: string[]) { + const name = packages[0]; + context.print(line(span('checking dependencies...', 'muted')), errorLine(`failed to prepare transaction: ${name} is required by ${profile.username}`)); +} + +function printHelp(context: CommandContext) { + context.print( + line('usage: pacman [...]'), + line('operations:'), + line(' pacman -S install packages'), + line(' pacman -Syu upgrade the system'), + line(' pacman -Q list installed packages'), + line(' pacman -R remove packages') + ); +} + +function runPacman(args: string[], context: CommandContext) { + const [operation, ...packages] = args; + if (!operation) { + context.print(errorLine('no operation specified (use -h for help)')); + return; + } + if (operation === '-h' || operation === '--help') return printHelp(context); + if (operation.startsWith('-Sy') && packages.length === 0) return systemUpgrade(context); + if (operation.startsWith('-Q')) return listInstalled(context); + + if (packages.length === 0) { + context.print(errorLine('no targets specified (use -h for help)')); + return; + } + if (operation.startsWith('-S')) return installPackages(context, packages.map((name) => name.toLowerCase())); + if (operation.startsWith('-R')) return removePackages(context, packages); + context.print(errorLine(`invalid option '${operation}'`)); +} + +export const pacmanCommand: Command = { + name: 'pacman', + hidden: true, + description: 'Arch Linux package manager', + run: runPacman +}; + +export function runAsRoot(args: string[], context: CommandContext): boolean { + if (args[0] !== 'pacman') return false; + context.print(line(span(`[sudo] password for ${profile.username}: `, 'muted'), '********')); + runPacman(args.slice(1), context); + return true; +} diff --git a/src/lib/terminal/commands/system.ts b/src/lib/terminal/commands/system.ts index 7a11753..9c054d2 100644 --- a/src/lib/terminal/commands/system.ts +++ b/src/lib/terminal/commands/system.ts @@ -2,6 +2,7 @@ import { profile } from '#lib/content'; import { themes } from '#lib/theme/themes'; import { allSitePages, findSitePage, sitePages } from '#lib/site/pages'; import { blankLine, errorLine, line, span } from '../output'; +import { runAsRoot } from './pacman'; import type { Command } from './types'; function cowsay(message: string): string[] { @@ -122,8 +123,16 @@ export const systemCommands: Command[] = [ name: 'sudo', hidden: true, description: 'Elevated privileges', - run: (_args, context) => - context.print(errorLine(`${profile.username} is not in the sudoers file. This incident will be reported.`)) + run: (args, context) => { + if (runAsRoot(args, context)) return; + context.print(errorLine(`${profile.username} is not in the sudoers file. This incident will be reported.`)); + } + }, + { + name: 'btop', + aliases: ['htop', 'top'], + description: 'Open the system monitor', + run: (_args, context) => context.openApp('btop') }, { name: 'pwd',