Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ repo clone <name> # print a git clone command
The catalog is static and sourced from `public/data/projects.json`, which keeps
the website usable without GitHub API credentials at runtime.

## Terminal Aliases

The terminal supports persistent, shell-style command aliases:

```bash
alias h='history 10' # create a shortcut
alias sys='repo --systems' # alias commands with arguments
alias # list saved aliases
unalias h # remove a custom alias
```

Aliases are stored in browser `localStorage`. Built-in aliases such as
`exit='close'` are read-only.

## Build

```bash
Expand Down
86 changes: 86 additions & 0 deletions src/lib/terminal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const COMMAND_LIST = [
"snake",
"stats",
"theme",
"unalias",
"uptime",
"weather",
"which",
Expand All @@ -49,6 +50,91 @@ export const COMMAND_LIST = [
"exit",
];

export const DEFAULT_ALIASES = Object.freeze({
exit: "close",
});

export function isValidAliasName(name) {
return /^[a-z][a-z0-9_-]*$/i.test(String(name || ""));
}

export function parseAliasDefinition(input) {
const source = String(input || "").trim();
const match = source.match(/^([a-z][a-z0-9_-]*)=(.+)$/i);
if (!match) {
return {
error: "Usage: alias name='command'",
};
}

const name = match[1].toLowerCase();
let command = match[2].trim();
const quote = command[0];
if ((quote === "'" || quote === '"') && command.endsWith(quote)) {
command = command.slice(1, -1).trim();
}

if (!command) {
return {
error: "Alias command cannot be empty.",
};
}

return { name, command };
}

export function formatAliasOutput(aliases) {
const entries = Object.entries(aliases || {}).sort(([a], [b]) =>
a.localeCompare(b),
);

if (entries.length === 0) {
return "No aliases configured.\nCreate one with: alias h='history 10'";
}

return entries
.map(([name, command]) => {
const escaped = String(command).replace(/'/g, "'\\''");
return `alias ${name}='${escaped}'`;
})
.join("\n");
}

export function expandAliasCommand(input, aliases, maxDepth = 8) {
let command = String(input || "").trim();
const seen = [];

for (let depth = 0; depth < maxDepth; depth++) {
const match = command.match(/^(\S+)(.*)$/);
if (!match) {
return { command, expanded: seen.length > 0 };
}

const name = match[1].toLowerCase();
const target = aliases?.[name];
if (!target) {
return { command, expanded: seen.length > 0 };
}

if (seen.includes(name)) {
return {
command,
expanded: seen.length > 0,
error: `Alias loop detected: ${[...seen, name].join(" -> ")}`,
};
}

seen.push(name);
command = `${target}${match[2] || ""}`.trim();
}

return {
command,
expanded: seen.length > 0,
error: "Alias expansion exceeded maximum depth.",
};
}

export function buildProjectsListOutput(projects) {
if (!Array.isArray(projects) || projects.length === 0) {
return "No projects are configured yet.";
Expand Down
157 changes: 152 additions & 5 deletions src/scripts/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
flipText,
safeCalc,
renderBigTime,
DEFAULT_ALIASES,
isValidAliasName,
parseAliasDefinition,
formatAliasOutput,
expandAliasCommand,
} from "../lib/terminal/index.js";

// Global variables for managing input and command history
Expand All @@ -41,6 +46,9 @@ let countdownActive = false;
let countdownInterval = null;
let editorState = null; // null | { phase: "title" } | { phase: "body", title, lines } | { phase: "login" }
let isAdmin = false;
let customAliases = {};

const ALIAS_STORAGE_KEY = "terminal-aliases";

async function hashPassword(password) {
const data = new TextEncoder().encode(password);
Expand Down Expand Up @@ -97,6 +105,100 @@ function saveCustomWhoami(text) {
// ignore
}
}

function loadCustomAliases() {
try {
const raw = localStorage.getItem(ALIAS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : {};
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}

return Object.fromEntries(
Object.entries(parsed).filter(
([name, command]) =>
isValidAliasName(name) &&
typeof command === "string" &&
command.trim(),
),
);
} catch {
return {};
}
}

function saveCustomAliases() {
try {
localStorage.setItem(ALIAS_STORAGE_KEY, JSON.stringify(customAliases));
} catch {
// ignore
}
}

function getAliasMap() {
return { ...DEFAULT_ALIASES, ...customAliases };
}

function displayAliases() {
appendOutput(formatAliasOutput(getAliasMap()), "info-text");
}

function setAlias(args) {
const parsed = parseAliasDefinition(args);
if (parsed.error) {
appendOutput(parsed.error, "error-text");
return;
}

if (parsed.name === "alias" || parsed.name === "unalias") {
appendOutput(`alias: '${parsed.name}' is reserved.`, "error-text");
return;
}

if (DEFAULT_ALIASES[parsed.name]) {
appendOutput(
`alias: '${parsed.name}' is a built-in alias and cannot be overwritten.`,
"error-text",
);
return;
}

customAliases[parsed.name] = parsed.command;
saveCustomAliases();
appendOutput(
formatAliasOutput({ [parsed.name]: parsed.command }),
"success-text",
);
}

function removeAlias(args) {
const name = String(args || "")
.trim()
.toLowerCase();

if (!name) {
appendOutput("Usage: unalias [name]", "info-text");
return;
}

if (DEFAULT_ALIASES[name]) {
appendOutput(
`unalias: '${name}' is a built-in alias and cannot be removed.`,
"error-text",
);
return;
}

if (!customAliases[name]) {
appendOutput(`unalias: '${name}' not found.`, "error-text");
return;
}

delete customAliases[name];
saveCustomAliases();
appendOutput(`Removed alias '${name}'.`, "success-text");
}

function getAllPosts() {
const staticPosts = terminalData.posts || [];
const localPosts = loadLocalPosts();
Expand Down Expand Up @@ -157,6 +259,7 @@ try {
} catch (e) {
// ignore
}
customAliases = loadCustomAliases();
let historyIndex = commandHistory.length;
let currentInputBuffer = "";
let cursor; // Global cursor element
Expand Down Expand Up @@ -243,6 +346,7 @@ const commandList = [
"snake",
"stats",
"theme",
"unalias",
"uptime",
"weather",
"which",
Expand Down Expand Up @@ -665,7 +769,7 @@ function displayOnboardingCommands() {
const hints = document.createElement("div");
hints.className = "keyboard-hints log-text";
hints.textContent =
"Tip: Tab to autocomplete ↑↓ history Ctrl+L clear Ctrl+C cancel";
"Tip: Tab autocomplete ↑↓ history alias h='history 10' Ctrl+L clear";

const container = document.createElement("div");
container.className = "onboarding-block";
Expand Down Expand Up @@ -801,6 +905,13 @@ function executeCommand(command, options = {}) {
cliOutput.insertBefore(commandLine, inputLine);
}

const aliasExpansion = expandAliasCommand(command, getAliasMap());
if (aliasExpansion.error) {
appendOutput(aliasExpansion.error, "error-text");
return;
}
command = aliasExpansion.command;

const normalizedCommand = command.toLowerCase();
switch (normalizedCommand) {
case "help":
Expand Down Expand Up @@ -840,6 +951,7 @@ function executeCommand(command, options = {}) {
hostname show hostname
which find a command
alias manage aliases
unalias remove aliases
cd change directory
rss RSS feed URL
sudo sudo mode
Expand Down Expand Up @@ -910,7 +1022,10 @@ Currently seeking opportunities in software engineering.`,
appendOutput("jjalangtry.com", "info-text");
break;
case "alias":
appendOutput("alias exit='close'", "info-text");
displayAliases();
break;
case "unalias":
appendOutput("Usage: unalias [name]", "info-text");
break;
case "skills":
displaySkills();
Expand Down Expand Up @@ -1185,6 +1300,12 @@ Currently seeking opportunities in software engineering.`,
}
}
break;
} else if (normalizedCommand.startsWith("alias ")) {
setAlias(command.substring(6).trim());
break;
} else if (normalizedCommand.startsWith("unalias ")) {
removeAlias(command.substring(8).trim());
break;
} else if (normalizedCommand.startsWith("blog ")) {
const slug = command.substring(5).trim();
if (!slug) {
Expand Down Expand Up @@ -2910,6 +3031,13 @@ function displayManPage(cmd) {

function getHelpDetails() {
return {
alias: {
desc: "Create, list, and persist command aliases.",
usage: "alias [name='command']",
examples: ["alias", "alias h='history 10'", "alias sys='repo --systems'"],
notes:
"Aliases expand the first word of a command before execution and are saved in localStorage. Built-in aliases such as exit='close' are read-only.",
},
banner: {
desc: "Display the ASCII art banner for the terminal.",
usage: "banner",
Expand Down Expand Up @@ -3154,6 +3282,13 @@ function getHelpDetails() {
notes:
"Without arguments, toggles to the opposite theme. Preference is saved in your browser.",
},
unalias: {
desc: "Remove a custom command alias.",
usage: "unalias [name]",
examples: ["unalias h", "unalias sys"],
notes:
"Removes custom aliases saved in localStorage. Built-in aliases cannot be removed.",
},
uptime: {
desc: "Display how long the current terminal session has been active.",
usage: "uptime",
Expand Down Expand Up @@ -4037,7 +4172,13 @@ function executePipeline(input) {
echoLine.textContent = `guest@jjalangtry.com:~$ ${input}`;
cliOutput.insertBefore(echoLine, inputLine);

const firstCmd = filtered[0].trim().toLowerCase();
const firstExpansion = expandAliasCommand(filtered[0], getAliasMap());
if (firstExpansion.error) {
appendOutput(firstExpansion.error, "error-text");
return;
}

const firstCmd = firstExpansion.command.trim().toLowerCase();
if (
firstCmd === "repos" ||
firstCmd.startsWith("weather ") ||
Expand All @@ -4053,15 +4194,21 @@ function executePipeline(input) {
// Capture output from first command
captureMode = true;
capturedLines = [];
executeCommand(filtered[0], { skipEcho: true });
executeCommand(firstExpansion.command, { skipEcho: true });
captureMode = false;

// Combine captured text
let output = capturedLines.map((l) => l.text).join("\n");

// Process pipe segments
for (let i = 1; i < filtered.length; i++) {
const pipeCmd = filtered[i].trim();
const pipeExpansion = expandAliasCommand(filtered[i].trim(), getAliasMap());
if (pipeExpansion.error) {
appendOutput(pipeExpansion.error, "error-text");
return;
}

const pipeCmd = pipeExpansion.command;
const pipeCmdLower = pipeCmd.toLowerCase();

if (pipeCmdLower.startsWith("grep ")) {
Expand Down
Loading
Loading