Compare commits

..
4 Commits
Author SHA1 Message Date
ducoterra a2f331e526 Add phased-execution planning structure
Build and Push Container / build-and-push (push) Successful in 11s
Introduce .agents/ (PLAN.md with locked architectural anchors,
phase roadmap under phases/todo/) and AGENTS.md rules for
agents working in the repo. Queues the pending phases: fix
history XSS, simulate fetch commands, and nginx security
headers.
2026-09-18 17:23:41 -04:00
ducoterra 010758b3e9 Move server rack inline styles to CSS classes
Replace the per-element inline positioning in createServerRack()
with rack-*-pos utility classes in style.css. Also stop drawing
a random switch face brand from a list (only Framework ships)
and simplify the hero-click terminal toggle to use the shared
expand/collapse helpers.
2026-09-18 17:23:37 -04:00
ducoterra 3898dc3c5e Add Playwright E2E suite against the built dist/
Pin @playwright/test 1.62.0 (matches the cached chromium
revision) and serve dist/ via python3 http.server, so the
deployed artifact is what gets tested. Covers page load,
terminal commands, vim mode, achievements, and the nav menu.
Ignore node_modules, test-results, playwright-report, and the
phased-execution runtime scratch (.agents/phase-sessions,
pipeline.log).
2026-09-18 17:23:30 -04:00
ducoterra 7f4f0b4ef1 Split terminal into flat command, achievement, and vim modules
Move canned command data (terminal-commands.js), the achievement
system (terminal-achievements.js), and the vim simulator
(terminal-vim.js) out of terminal.js, which keeps the core
terminal loop. Load order is fixed in index.html with the
modules before terminal.js. Also share expand/collapse helpers
with the hero click handler and drop the real fetch I/O in
favour of simulated curl/wget output.
2026-09-18 17:23:25 -04:00
30 changed files with 2471 additions and 811 deletions
+112
View File
@@ -0,0 +1,112 @@
# PLAN — Developer Homepage
**Status:** live static site · **Last reviewed:** 2026-09-18 (security & code-quality audit)
## 1. Project Identity
A single-page personal portfolio (Reese Wells — self-hosting & infrastructure)
with an interactive fake terminal (easter-egg commands, vim simulator,
achievement system). No backend, no user accounts, no PII beyond public
contact details and GPG public keys.
## 2. Assumptions & Design Principles
1. **Static first.** The site is plain HTML/CSS/JS. No runtime dependencies,
no CDNs, no build-time JS frameworks — the supply chain is the repo itself.
2. **Boring deployment.** `build.sh` → hashed `dist/` → `nginx:alpine` on
`:8080` (plain HTTP) → TLS terminated by the external Caddy reverse proxy.
3. **Test the artifact.** E2E tests run against the built `dist/`, not `src/`,
so what is tested is exactly what is deployed.
4. **Security headers are a container property.** All hardening headers are
emitted by nginx (the only thing that serves the site in production).
5. **Fun is safe.** The fake terminal's easter eggs (page-wipe, login screen,
vim) are DOM-only pranks — no real network or system effects. The only
real network behavior (curl/wget `fetch`) is being removed — see Phase 02.
## 3. Architectural Anchors (LOCKED DECISIONS)
| COMPONENT | DECISION | RATIONALE | STATUS |
|---|---|---|---|
| Frontend runtime | Vanilla HTML/CSS/JS, flat `src/*.js` files, `defer` load order | No toolchain, auditable, works with `build.sh` hashing | LOCKED (pre-existing) |
| Terminal modules | `terminal-commands.js` (canned data) · `terminal-achievements.js` · `terminal-vim.js` · `terminal.js` (core) | Flat modules keep `build.sh`'s root-level hashing working; single source of truth for command data | LOCKED (2026-09-18 refactor) |
| Build | `build.sh`: md5 content-hash cache-busting + `sed` reference rewrites → `dist/` | md5 is a cache key, not a security hash — acceptable | LOCKED (pre-existing) |
| Serving | `nginx:alpine`, `listen 8080`, plain HTTP | TLS is added by the Caddy edge in front of the container | LOCKED (pre-existing) |
| Edge | Caddy reverse proxy (Route53 DNS-validated TLS) in front of the container | HSTS/TLS are edge responsibilities | LOCKED (assumed — verify per Phase 03) |
| E2E tests | `@playwright/test` pinned **1.62.0** (matches cached chromium-1234), serves `dist/` via `python3 -m http.server 8123`, config `playwright.config.js` | Tests the deployed artifact; pinned to avoid browser re-downloads | LOCKED (2026-09-18, user-approved) |
| Deployment | Docker image → `gitea.reeseapps.com/services/homepage` via Gitea Actions (secrets-based registry login) | Self-hosted CI | LOCKED (pre-existing) |
| Persistence | `localStorage` only (achievements, key `reese-terminal-achievements`) | No backend by design | LOCKED (pre-existing) |
## 4. High-Level Architecture
```
src/ (index.html, style.css, script.js, terminal*.js, assets)
│ build.sh (hash + rewrite)
▼
dist/ (index.html + hashed assets) ◄── playwright webServer (port 8123)
│ Dockerfile (COPY → nginx html root) ◄── tests/e2e/*.spec.js (44 tests)
▼
nginx:alpine :8080 (HTTP, security headers, cache policy)
▲
Caddy edge (TLS, HSTS) ← reeseapps.com
```
- `src/terminal.js` — terminal core: prompt loop, history, tab completion,
dispatch, expand/collapse (`expandTerminal`/`collapseTerminal`, shared with
`script.js` hero click).
- `src/terminal-commands.js` — `TERMINAL_COMMANDS` (canned outputs; values may
be functions evaluated at dispatch), `TERMINAL_COMMAND_LIST` (tab
completion), `getCommandOutput()`.
- `src/terminal-vim.js` — `launchVimSimulator({content, terminal,
mobileInput, filename, setVimMode})`.
- `src/terminal-achievements.js` — `ACHIEVEMENTS`, localStorage persistence,
toasts, hidden-section reveal, `initTerminalAchievements()`.
- `src/script.js` — nav menu, IntersectionObserver fades, server-rack
background generation, hero-click terminal toggle.
## 5. Validation / Verification Workflow
1. `./build.sh` — rebuild `dist/`.
2. `npm test` (Playwright) — 44 E2E tests against `dist/` on port 8123.
Every phase's final pass must leave the suite green.
3. `.agents/validate.sh` (installed by the `phased-execution` skill) —
must run 1 + 2 and, for Phase 03, the header checks below.
4. Container header check (Phase 03): `scripts/check-headers.sh` — builds the
image, runs it on a scratch port, asserts the four security headers on
`/`, `/index.html`, and a hashed asset.
## 6. Data Model
No server-side data. Client-side state:
| Key | Where | Shape |
|---|---|---|
| `reese-terminal-achievements` | localStorage | JSON array of achievement ids (e.g. `["time_flies","nice_try"]`) |
Session-only state (no persistence): terminal history, `isRoot`, vim state,
menu open/closed, terminal grown/collapsed.
## 7. Roadmap
| Phase | Title | Source |
|---|---|---|
| `01_fix_history_xss` | Fix terminal history-recall XSS (M-1) | audit finding M-1 |
| `02_simulate_fetch_commands` | Remove unvalidated client fetch from curl/wget (M-2) | audit finding M-2 |
| `03_nginx_security_headers` | Security headers on every location (M-4) + `server_tokens off` (L-2) | audit findings M-4, L-2 |
**Dismissed:** audit finding M-3 (README `0.0.0.0:8080` binding) — false
positive per project owner (2026-09-18); the host firewall already restricts
access and the site is fronted by the Caddy edge.
**Completed in-session (not phased):** code-quality pass 2026-09-18 — module
split (Q-1), dead-code removal (Q-2/3/4, `scrollOffset`), expand/collapse
helpers (Q-5), rack-decoration CSS classes (Q-7), `hidden`-attribute reveal
(Q-8), a11y label on terminal input (Q-9), dynamic `date` (Q-10), vim `dd`
line-deletion fix, Playwright regression suite (44 tests). See
`.agents/remediation_plan.md`.
Remaining low-priority audit items (not yet phased): L-1 (verify HSTS at the
Caddy edge — folded into Phase 03 verification), L-3 (drop
`style-src 'unsafe-inline'` after moving inline `style=""` attrs to classes),
L-5 (label the displayed SSH key as fictitious or show fingerprint only),
L-7 (pin CI action SHAs / image signing), L-8 (build.sh verification test in
CI).
View File
@@ -0,0 +1,38 @@
# Phase 01 — Fix terminal history-recall XSS (audit M-1)
**Story:** n/a
**Context:** `.agents/PLAN.md` §4 (terminal modules), `.agents/remediation_plan.md` finding M-1
## Objective
Eliminate the DOM-XSS sink in the fake terminal's command-history recall:
ArrowUp/ArrowDown currently rebuild the prompt line with
`lastLine.innerHTML = '<span class="terminal-prompt">$</span> ' + cmdLine + ' '`
where `cmdLine` is raw user input from `commandHistory`. Replace it with
textContent-based construction so terminal input can never be injected as
HTML, and lock the fix with a Playwright regression test.
## Dependencies
— (none)
## Tasks
1. `01_fix_history_recall.md` — Rewrite the ArrowUp/ArrowDown recall to build the prompt line with `createElement`/`textContent` (mirror the existing `updateDisplay()` pattern).
2. `02_xss_regression_test.md` — Add a Playwright test proving a markup payload typed into the terminal is recalled as literal text, not DOM.
## Testing & Quality
- Unit/integration: the new regression test must cover (a) a payload that
would create a DOM node if injected (`<b id="xss-marker">x</b>`), (b) a
payload with an inline event handler (`<img src=x onerror=...`) asserting
no script execution, and (c) a benign command (`whoami`) still recalled
and executable.
- Coverage: **>90%** on new/modified code (the two recall branches in
`src/terminal.js`).
- E2E: full Playwright suite (`npm test`) green — it already exercises
history recall with benign commands and must not regress.
## Completion Criteria
- [ ] `grep -n "innerHTML" src/terminal.js` shows no occurrence combining
`innerHTML` with `cmdLine`/history data (static prompt literals and the
neofetch canned-output assignment are the only allowed `innerHTML` uses)
- [ ] new XSS regression test present in `tests/e2e/02-terminal.spec.js` and passing
- [ ] `./build.sh && npm test` fully green (44+ tests)
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,49 @@
# Task 01 — Rewrite history recall with the safe display path
**Phase:** `01_fix_history_xss` · **Story:** n/a
## Objective
Remove the `innerHTML` sink from the terminal's ArrowUp/ArrowDown history
recall by routing recall through the existing `updateDisplay(text)` function,
which already builds the prompt line with `textContent`/`createElement`.
## Work
1. `src/terminal.js` — in the terminal `keydown` handler, replace the three
`lastLine.innerHTML = '<span class="terminal-prompt">$</span> ' + cmdLine + ' '`
constructions with `updateDisplay(...)` calls:
- **ArrowUp branch** (`if (historyIndex < commandHistory.length - 1)`):
delete the `innerHTML` line, the `newCursor` creation, and the
`appendChild` — keep `historyIndex++` and `const cmdLine = ...`, then:
```js
updateDisplay(cmdLine);
```
- **ArrowDown branch, first case** (`if (historyIndex > 0)`): same
replacement — `updateDisplay(cmdLine);`
- **ArrowDown branch, else case** (`historyIndex = -1`): replace the
`innerHTML`/cursor block with:
```js
updateDisplay('');
```
`updateDisplay` already: removes any stale `.terminal-cursor`, clears the
line via `textContent`, appends a `.terminal-prompt` span (respecting
`isRoot` → `#` vs `$`), the text, and a fresh cursor (red when root).
Note the two intentional, behavior-preserving improvements this brings:
(a) recalled prompts match the live typing state (cursor directly after
the text, no trailing space); (b) after `sudo su -`, recall shows `#`
instead of a stale `$`. Both are consistency fixes, not regressions.
## Testing & Quality
- Unit/integration: no new unit code — the existing E2E test
`02-terminal.spec.js › history: ArrowUp/ArrowDown recall previous commands`
exercises recall and execution of recalled commands and must stay green.
- Coverage: **>90%** on the modified branches (covered by that E2E test plus
the task-02 regression test).
## Completion Criteria
- [ ] `grep -n "innerHTML" src/terminal.js` — no remaining occurrence that
involves `cmdLine` or history data
- [ ] `./build.sh && npm test` fully green
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,65 @@
# Task 02 — Add the XSS regression test
**Phase:** `01_fix_history_xss` · **Story:** n/a
## Objective
Add a Playwright E2E test that proves a markup payload typed into the
terminal is recalled (ArrowUp) as **literal text**, never as DOM, and never
executes. This test fails on the pre-fix (innerHTML) code and passes on the
fixed code, so it guards against regressions/reverts.
## Work
1. `tests/e2e/02-terminal.spec.js` — add the following test inside the
`test.describe('Fake terminal — state and special commands', ...)` block
(it reuses `openTerminal`, `typeCommand`, `currentPromptLine` from
`./helpers`):
```js
test('history recall renders markup payloads as literal text (no DOM injection)', async ({ page }) => {
await openTerminal(page);
// 1) A payload that would create a DOM node if innerHTML-injected.
await typeCommand(page, '<b id="xss-marker">pwned</b>');
await page.keyboard.press('ArrowUp');
await expect(page.locator('#xss-marker')).toHaveCount(0);
await expect(currentPromptLine(page)).toContainText('<b id="xss-marker">pwned</b>');
// 2) An event-handler payload must not execute. `data:,` is an empty
// data URI so the <img> fails to decode deterministically (no
// network dependency) and onerror would fire if injected as HTML.
await page.evaluate(() => { window.__xssFired = false; });
await typeCommand(page, '<img src="data:," onerror="window.__xssFired=true">');
await page.keyboard.press('ArrowUp');
await page.waitForTimeout(200); // allow onerror to fire if vulnerable
expect(await page.evaluate(() => window.__xssFired)).toBe(false);
await expect(page.locator('img[onerror]')).toHaveCount(0);
await expect(currentPromptLine(page)).toContainText('<img src="data:," onerror="window.__xssFired=true">');
// 3) Benign recall still works and is executable.
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.locator('.terminal-content > div').nth(-2)).toHaveText('optional');
});
```
Notes for the executor:
- Step 3 walks back down through history (`<img…>`, then `<b…>`, then the
prior real command) and executes it; adjust the final assertion to the
benign command that is actually on top of the history at that point if
the exact stack differs when you run it. The point is that recall +
execute of a benign command still works.
- Do **not** add an `onerror`-based assertion that depends on a real
network fetch; keep it self-contained with `data:,`.
## Testing & Quality
- Unit/integration: this test **is** the regression coverage for the M-1 fix.
It must: fail on innerHTML-based recall, pass on textContent-based recall.
- Coverage: **>90%** on the modified recall branches (both payload classes +
benign path covered).
## Completion Criteria
- [ ] new test present in `tests/e2e/02-terminal.spec.js`
- [ ] `./build.sh && npm test` fully green (45+ tests)
- [ ] (sanity) temporarily reverting task 01's fix makes this test fail —
confirms it is a genuine regression guard, then re-apply the fix
@@ -0,0 +1,44 @@
# Phase 02 — Remove unvalidated client fetch from curl/wget (audit M-2)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 (principle 5 — fun is safe) & §3 (terminal
modules), `.agents/remediation_plan.md` finding M-2
## Objective
The fake terminal's `curl`/`wget` commands run a real, unvalidated
`fetch(url)` from the visitor's browser — a client-side request-forgery
primitive (and cookie-attached once the site ever has auth) that is also
silently broken in production (CSP `connect-src` falls back to
`default-src 'self'`, blocking every external fetch). Replace the real
fetch with **simulated output** consistent with the rest of the fake
terminal: zero network I/O, deterministic, echoes the requested URL.
**Chosen approach** (audit recommendation, owner-approved 2026-09-18):
simulate. The rejected alternative (real fetch + https-only allowlist +
`connect-src` + `credentials: 'omit'`) is documented for the record but out
of scope.
## Dependencies
— (none)
## Tasks
1. `01_simulate_fetch_output.md` — Add `simulatedCurlOutput(url)` / `simulatedWgetOutput(url)` to `terminal-commands.js`; replace both `fetch()` calls in `terminal.js` with the simulated outputs.
2. `02_fetch_regression_test.md` — Add Playwright tests proving curl/wget render simulated output, make **zero** non-origin network requests, and still unlock the `web_navigator` achievement.
## Testing & Quality
- Unit/integration: the regression tests must cover (a) `curl <url>`
renders the simulated block and echoes the URL, (b) `wget <url>` same,
(c) no request to any non-origin host occurs (request listener),
(d) `web_navigator` achievement/toast still fires.
- Coverage: **>90%** on new/modified code (the two simulator functions +
both branches).
- E2E: full Playwright suite green.
## Completion Criteria
- [ ] `grep -n "fetch(" src/terminal.js` returns no matches (the only
`fetch(` in `src/` must be gone)
- [ ] `simulatedCurlOutput` / `simulatedWgetOutput` present in
`src/terminal-commands.js`
- [ ] new regression tests present in `tests/e2e/` and passing
- [ ] `./build.sh && npm test` fully green (46+ tests)
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,81 @@
# Task 01 — Replace real fetch with simulated output
**Phase:** `02_simulate_fetch_commands` · **Story:** n/a
## Objective
Remove the only real network I/O from the fake terminal: the `curl` and
`wget` commands stop calling `fetch(url)` and instead render a
deterministic, simulated response that echoes the requested URL. This kills
the client-side request-forgery primitive (audit M-2) and makes the feature
work identically in production (where CSP blocked the real fetch anyway).
## Work
1. `src/terminal-commands.js` — append two pure functions (no DOM, no
network). Keep them deterministic (the wget timestamp is the only
intentional non-determinism):
```js
// Simulated fetch output. The fake terminal performs NO real network
// I/O by design — these produce a deterministic, clearly-simulated
// response that echoes the requested URL.
function _simulatedBody(url) {
return 'simulated body for ' + url;
}
function simulatedCurlOutput(url) {
const body = _simulatedBody(url);
return [
' % Total % Received % Xferd Average Speed Time Time Time Current',
' Dload Upload Total Spent Left Speed',
'100 ' + body.length + ' 100 ' + body.length + ' 0 0 ' + (body.length * 40) + ' 0 --:--:-- --:--:-- --:--:--',
body,
].join('\n');
}
function simulatedWgetOutput(url) {
const body = _simulatedBody(url);
return [
'-- ' + new Date().toISOString().replace('T', ' ').slice(0, 19) + '-- ' + url,
'Resolving host... (simulated)',
'Connecting... connected (simulated).',
'HTTP request sent, awaiting response... 200 OK',
'Length: ' + body.length + ' [text/plain]',
"Saving to: 'index.html'",
'',
body,
'',
'Finished (simulated). No real network request was made.',
].join('\n');
}
```
2. `src/terminal.js` — in the `curl ` branch, delete the `fetch(url).then(...)
.catch(...)` chain and replace it with:
```js
// Simulated fetch — the fake terminal never performs real network I/O.
setTimeout(() => {
outLine.textContent = simulatedCurlOutput(url);
content.scrollTop = content.scrollHeight;
}, 400);
```
3. `src/terminal.js` — in the `wget ` branch, make the identical change but
call `simulatedWgetOutput(url)`.
Keep the existing `url` extraction regexes, the `Downloading...` line, and
the `whiteSpace`/`color` styling. The `web_navigator` achievement (keyed
on the `curl `/`wget ` prefix) is untouched and still fires.
## Testing & Quality
- Unit/integration: no unit test needed for the pure output functions beyond
the E2E assertions in task 02 (they are string builders exercised through
the terminal). If desired, a tiny `node -e` sanity check:
`node -e "eval(require('fs').readFileSync('src/terminal-commands.js','utf8')); console.log(simulatedCurlOutput('https://x'))"`.
- Coverage: **>90%** on the two new functions and both modified branches
(both covered by the task-02 E2E test).
## Completion Criteria
- [ ] `grep -n "fetch(" src/terminal.js` returns **no** matches
- [ ] `simulatedCurlOutput` and `simulatedWgetOutput` defined in
`src/terminal-commands.js`
- [ ] `./build.sh && npm test` fully green (existing 44 tests unaffected)
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
@@ -0,0 +1,59 @@
# Task 02 — Add the simulated-fetch regression test
**Phase:** `02_simulate_fetch_commands` · **Story:** n/a
## Objective
Add a Playwright E2E test proving `curl`/`wget` render the simulated output,
make **zero** non-origin network requests, and still unlock the
`web_navigator` achievement.
## Work
1. `tests/e2e/02-terminal.spec.js` — add inside the
`test.describe('Fake terminal — state and special commands', ...)` block:
```js
test('curl/wget are simulated — no real network request leaves the origin', async ({ page }) => {
await openTerminal(page);
// Track every request that is NOT aimed at the test origin.
const external = [];
page.on('request', (r) => {
if (!r.url().startsWith('http://127.0.0.1:8123')) external.push(r.url());
});
await typeCommand(page, 'curl https://example.com/hello');
// achievement fires on the first curl/wget
await expect(page.locator('.toast').filter({ hasText: 'Web Navigator' })).toBeVisible();
const curlLine = page.locator('.terminal-content > div').nth(-2);
await expect(curlLine).toContainText('simulated body for https://example.com/hello');
await typeCommand(page, 'wget https://example.org/file.txt');
const wgetLine = page.locator('.terminal-content > div').nth(-2);
await expect(wgetLine).toContainText('No real network request was made');
await expect(wgetLine).toContainText('https://example.org/file.txt');
expect(external).toEqual([]);
});
```
Notes for the executor:
- The origin prefix `http://127.0.0.1:8123` must match `baseURL` in
`playwright.config.js`; keep them in sync if the port ever changes.
- The `web_navigator` toast is asserted right after the curl command
(toasts auto-remove after ~4.3 s).
- If `expect(external).toEqual([])` is flaky on your network (e.g. DNS
prefetch noise), tighten it to assert that no request URL contains
`example.com` or `example.org` instead — the point is that the
simulated URLs were never contacted.
## Testing & Quality
- Unit/integration: this test is the regression coverage for the M-2 fix —
it fails on the pre-fix code (a real `fetch` to example.com/example.org
would appear in `external`) and passes on the simulated code.
- Coverage: **>90%** on both modified branches (both exercised).
## Completion Criteria
- [ ] new test present in `tests/e2e/02-terminal.spec.js`
- [ ] `./build.sh && npm test` fully green (45+ tests)
- [ ] (sanity) temporarily reverting task 01's change makes this test fail —
confirms the request tracking works, then re-apply
@@ -0,0 +1,48 @@
# Phase 03 — Security headers on every location (audit M-4)
**Story:** n/a
**Context:** `.agents/PLAN.md` §3 (serving + edge), `.agents/remediation_plan.md`
findings M-4 (headers) and L-2 (`server_tokens`, included as a one-liner in
the same file)
## Objective
Fix the nginx `add_header` inheritance trap: the `location = /index.html`
and the hashed-asset location each define their own cache `add_header`
directives, so they **lose** the server-level security headers (CSP,
X-Frame-Options, X-Content-Type-Options, Referrer-Policy). Extract the four
security headers into a snippet and `include` it in **every** location, so
every response class carries the full header set. Also set
`server_tokens off;` (L-2) in the same pass.
## Dependencies
— (none)
## Tasks
1. `01_extract_header_snippet.md` — Create `nginx-security-headers.inc`, wire it into the Dockerfile, and `include` it in the server block and both header-defining locations (plus `server_tokens off;`).
2. `02_add_header_verification.md` — Add `scripts/check-headers.sh` that builds and runs the real image and asserts all four headers on `/`, `/index.html`, and a hashed asset.
## Testing & Quality
- Unit/integration: the container-based header check (task 02) is the
integration test. It must assert all four headers on each of the three
response classes.
- Coverage: **>90%** on new/modified config (all three location paths
exercised by the check script).
- The Playwright E2E suite is unaffected (its webServer is plain python,
not nginx) and must stay green.
## Completion Criteria
- [ ] `nginx-security-headers.inc` exists and holds the four `add_header …
always;` lines
- [ ] `nginx.conf` has **no** inline security `add_header` lines (only the
`include`), and both header-defining locations `include` the snippet
- [ ] `server_tokens off;` present in the `server` block
- [ ] `scripts/check-headers.sh` runs the real image and prints
`HEADER CHECK PASSED` (all four headers on `/`, `/index.html`, and a
hashed asset)
- [ ] `./build.sh && npm test` fully green
- [ ] no behavior change in completed work (terminal, vim, achievements, menu)
**Out of scope (separate, unphased):** L-1 HSTS — that is an **edge/Caddy**
responsibility (the container serves plain HTTP on 8080). Verify at the edge
manually: `curl -sI https://reeseapps.com/ | grep -i strict-transport`. Do
**not** add HSTS inside the 8080 container.
@@ -0,0 +1,96 @@
# Task 01 — Extract header snippet and include it in every location
**Phase:** `03_nginx_security_headers` · **Story:** n/a
## Objective
Move the four security headers into a shared snippet and `include` it in
**every** location, so no location can silently drop them by defining its own
`add_header` (the nginx inheritance trap, audit M-4). Add `server_tokens off;`
(audit L-2) in the same pass.
## Work
1. Create `nginx-security-headers.inc` (repo root, next to `nginx.conf`) with
exactly:
```
# Shared security headers — included by EVERY nginx location.
# A location that defines its own add_header inherits none from the
# server block, so each location includes this snippet directly.
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
```
2. `Dockerfile` — create the snippets dir and copy the file in. Change the
existing `RUN rm … && apk add …` line to also `mkdir -p /etc/nginx/snippets`,
and add a COPY right before the `COPY nginx.conf …` line:
```dockerfile
RUN rm /etc/nginx/conf.d/default.conf && \
apk add --no-cache coreutils && \
mkdir -p /etc/nginx/snippets
COPY nginx-security-headers.inc /etc/nginx/snippets/security-headers.inc
COPY nginx.conf /etc/nginx/conf.d/default.conf
```
3. `nginx.conf` — rewrite to the exact content below. Key changes: the four
inline `add_header` lines are gone from the `server` block; `server_tokens
off;` is added; each of the three `location` blocks `include`s the snippet
(so no location relies on header inheritance):
```nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Hide the nginx version in the Server header (audit L-2)
server_tokens off;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 256;
gzip_vary on;
# Never cache index.html so the latest HTML is always served
location = /index.html {
include /etc/nginx/snippets/security-headers.inc;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# Cache hashed assets forever (filename contains content hash)
location ~* \.(css|js|jpeg|jpg|png|gif|ico|svg|woff2?)$ {
include /etc/nginx/snippets/security-headers.inc;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
include /etc/nginx/snippets/security-headers.inc;
try_files $uri $uri/ /index.html;
}
# Custom error pages
error_page 404 /index.html;
}
```
## Testing & Quality
- Unit/integration: config correctness is proven by the container check in
task 02 (headers present on all three response classes) and by `nginx -t`
(valid syntax, run implicitly by the container starting).
- Coverage: **>90%** on the modified config (all three locations exercised).
## Completion Criteria
- [ ] `nginx-security-headers.inc` exists with the four `add_header … always;`
lines
- [ ] `nginx.conf` contains **no** inline security `add_header` lines and has
`include /etc/nginx/snippets/security-headers.inc;` in all three
locations, plus `server_tokens off;`
- [ ] `Dockerfile` copies the snippet into the image
- [ ] `./build.sh && npm test` still fully green (Playwright is unaffected)
@@ -0,0 +1,87 @@
# Task 02 — Add container-level header verification
**Phase:** `03_nginx_security_headers` · **Story:** n/a
## Objective
Add a repeatable, container-based verification that the **real** image serves
all four security headers on every response class (`/`, `/index.html`, and a
hashed asset). This is the integration test for the M-4 fix and guards against
future nginx config regressions.
## Work
1. Create `scripts/check-headers.sh` (repo root; `mkdir -p scripts` first)
with exactly:
```bash
#!/usr/bin/env bash
set -euo pipefail
# Verify every response class (/, /index.html, a hashed asset) carries the
# full security-header set, using the REAL nginx image on a scratch port.
# Requires a container runtime (podman or docker).
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if command -v podman >/dev/null 2>&1; then RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then RUNTIME=docker
else echo "ERROR: neither podman nor docker found" >&2; exit 2; fi
IMAGE="homepage-headercheck-$$"
PORT=18099
CID=""
trap 'if [ -n "$CID" ]; then $RUNTIME rm -f "$CID" >/dev/null 2>&1 || true; fi' EXIT
echo ">> building image ($RUNTIME)..."
$RUNTIME build -q -t "$IMAGE" .
echo ">> starting container on 127.0.0.1:$PORT..."
CID="$($RUNTIME run -d --rm -p 127.0.0.1:$PORT:8080 "$IMAGE")"
for i in $(seq 1 30); do
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
sleep 0.5
if [ "$i" -eq 30 ]; then echo "ERROR: container not ready" >&2; exit 1; fi
done
ASSET="$(curl -s "http://127.0.0.1:$PORT/" \
| grep -oE '(style|script|terminal)[a-z-]*\.[0-9a-f]{32}\.(css|js)' | head -n1 || true)"
if [ -z "$ASSET" ]; then echo "ERROR: no hashed asset found in index.html" >&2; exit 1; fi
fail=0
check_headers() {
local path="$1" hdrs h
hdrs="$(curl -sI "http://127.0.0.1:$PORT$path")"
for h in "X-Frame-Options" "X-Content-Type-Options" "Referrer-Policy" "Content-Security-Policy"; do
if ! printf '%s' "$hdrs" | grep -qi "^${h}:"; then
echo "FAIL: ${h} missing on ${path}" >&2; fail=1
fi
done
[ "$fail" -eq 0 ] && echo "OK: all security headers present on ${path}"
}
echo ">> checking headers..."
check_headers "/"
check_headers "/index.html"
check_headers "/${ASSET}"
if [ "$fail" -ne 0 ]; then echo "HEADER CHECK FAILED" >&2; exit 1; fi
echo "HEADER CHECK PASSED"
```
Then `chmod +x scripts/check-headers.sh`.
2. Run it: `./scripts/check-headers.sh` — it must print
`HEADER CHECK PASSED` with an `OK:` line for each of the three paths.
## Testing & Quality
- Unit/integration: this script **is** the integration test — it builds the
real image, serves it, and asserts all four headers on each response class.
- Coverage: **>90%** on the modified nginx config (all three location paths
asserted).
## Completion Criteria
- [ ] `scripts/check-headers.sh` exists and is executable
- [ ] `./scripts/check-headers.sh` exits 0 and prints `HEADER CHECK PASSED`
(with `OK:` lines for `/`, `/index.html`, and the hashed asset)
- [ ] (negative check) temporarily removing one `include` from a location in
`nginx.conf` makes the script FAIL for that path — confirms the check
has teeth, then restore it
- [ ] `./build.sh && npm test` fully green
+238
View File
@@ -0,0 +1,238 @@
# Security & Code Quality Audit — homepage
**Date:** 2026-09-18
**Auditor:** pi agent (read-only audit; no fixes applied at audit time)
**Method:** manual source review (all files) + tooling: `git log`, `git grep` secret scan, `ssh-keygen` key validation, `curl` live-header probe.
## Disposition (2026-09-18, same session as the audit)
- **M-1, M-2, M-4 → scheduled as phases** under the phased-execution protocol:
`.agents/phases/todo/01_fix_history_xss/`, `02_simulate_fetch_commands/`,
`03_nginx_security_headers/` (see `.agents/PLAN.md` §7). Execute with the
`phased-execution` skill (`auto-phase.sh`).
- **M-3 → DISMISSED (false positive per project owner, 2026-09-18).** The
host firewall already restricts access to the container port and the site
is fronted by the Caddy TLS edge; the README's `0.0.0.0:8080` binding is
accepted as-is.
- **Code-quality findings (Q-1…Q-12, plus the vim `dd` dead path) → executed
directly in-session** (explicit owner instruction to run them outside the
phase pipeline): module split (`terminal.js` 1353 → 652 lines +
`terminal-commands.js` / `terminal-vim.js` / `terminal-achievements.js`),
dead-code removal, expand/collapse helpers, rack-decoration CSS classes,
`hidden`-attribute reveal, a11y label, dynamic `date`, vim line-deletion
fix. A 44-test Playwright E2E regression suite (`tests/e2e/`, run with
`./build.sh && npm test`) was created and is green.
- Remaining Low items (L-1, L-3, L-5, L-7, L-8) stay unphased; L-1 is folded
into Phase 03's verification notes, L-2 into Phase 03 task 01.
## Scope & Stack
- **Stack:** static site (vanilla HTML/CSS/JS, no framework, no build deps) → hashed via `build.sh` (md5 cache-bust + `sed` rewrites) → served by `nginx:alpine` on `:8080` (plain HTTP, TLS expected at an external Caddy edge) → Docker/Podman image pushed to `gitea.reeseapps.com/services/homepage` via Gitea Actions.
- **Core functionality:** personal portfolio with an interactive fake terminal (easter-egg commands, vim simulator, achievements in `localStorage`).
- **Data sensitivity:** none at rest. Public PII only (name, email, GPG public keys). No auth, no backend, no forms.
- **Assumptions:** TLS termination + HSTS happen at the Caddy reverse proxy in front of this container (container only listens on 8080 HTTP). Live site headers could not be verified from this network.
**Overall:** low-risk attack surface (static, no input persistence, no backend). No Critical/High findings. One Medium XSS-pattern, two Medium deployment issues, several Low hardening items, and a number of code-quality debts (dead code, duplication, untested build).
## Findings (severity-ranked)
---
### M-1. XSS sink: terminal command history recalled via `innerHTML`
- **Severity:** Medium (High if ever served without the CSP below)
- **Type:** CWE-79 (DOM-based XSS via innerHTML)
- **Location:** `src/terminal.js`, ArrowUp/ArrowDown handlers in the terminal `keydown` listener (~lines 330–370)
- **Description:** The Enter handler stores raw user input into `commandHistory` (captured via `textContent`, so typing is safe). But ArrowUp/ArrowDown recall it with:
```js
lastLine.innerHTML = '<span class="terminal-prompt">$</span> ' + cmdLine + ' ';
```
`cmdLine` is untrusted input injected into `innerHTML`.
- **PoC:** In the site's terminal type:
```
<img src=x onerror=alert(1)>
```
press Enter, then press ↑. The payload is injected as HTML.
Mitigated *today* by the deployed CSP `script-src 'self'` (inline handlers and inline `<script>` are blocked), so impact on the production nginx container is limited to DOM tampering and click-based `javascript:` links. However: (a) it is fully exploitable if the site is ever served without this CSP (e.g. a Caddy static-file mount or dev server — see M-2/M-3 for how easily that happens), (b) any future CSP relaxation (e.g. adding `'unsafe-inline'` for styles) re-arms it, and (c) a co-browsing victim who is talked into typing a "magic command" gets exploited.
- **Remediation:** Render recalled history exactly like `updateDisplay()` does — never `innerHTML` with user data:
```js
const lastLine = content.lastElementChild;
lastLine.textContent = '';
const p = document.createElement('span');
p.className = 'terminal-prompt';
p.textContent = isRoot ? '#' : '$';
lastLine.append(p, document.createTextNode(' ' + cmdLine + ' '));
const c = document.createElement('span');
c.className = 'terminal-cursor';
lastLine.appendChild(c);
```
Apply to both ArrowUp and ArrowDown branches (3 occurrences).
---
### M-2. Arbitrary client-side `fetch(url)` via `curl`/`wget` commands (SSRF-style primitive + broken in prod)
- **Severity:** Medium
- **Type:** CWE-918 (client-side request forgery), CWE-601-adjacent (unvalidated URL)
- **Location:** `src/terminal.js`, the `curl ` / `wget ` branches of the Enter handler (~lines 884–920)
- **Description:** `fetch(url)` is called with a visitor-supplied, unvalidated URL, from the visitor's own browser, and the response body is displayed. Consequences:
1. **Broken in production:** the CSP is `default-src 'self'` with no `connect-src`, so `connect-src` falls back to `'self'` — every external fetch is silently blocked by the browser. The `curl`/`wget` feature (and the `web_navigator` achievement) only works on a CSP-less dev server. When this is noticed and "fixed" by adding `connect-src *`, the next problem appears:
2. **Cookie-attached requests:** `fetch()` defaults to `credentials: 'same-origin'`. Once the site ever has *any* authenticated/cookie endpoint, a visitor can run `curl /internal-path` (or the site origin) and read the response text in-page — a self-service exfiltration/CSRF primitive.
- **PoC:** In a dev server (no CSP): `curl https://api.example.com/secret` → the browser fetches it and prints the body. After a future `connect-src *`: `curl https://<site>/admin` with the visitor's cookies attached.
- **Remediation (pick one):**
- *Preferred:* this is a fake terminal — make `curl`/`wget` simulated like every other command (canned output). Zero risk, feature "works" everywhere.
- *If real fetches are wanted:* validate URL (`new URL(url)`, require `https:`, require hostname in an explicit allowlist), pass `{ credentials: 'omit' }`, and set an explicit `connect-src` in the CSP listing only those origins.
---
### M-3. `podman run -p 0.0.0.0:8080:8080` in README — plain HTTP exposed on all interfaces
- **Status:** ❌ **DISMISSED — false positive** (project owner, 2026-09-18).
The host firewall restricts access to the container port and the site is
served through the Caddy TLS edge in production. No change made.
- ~~Severity: Medium~~ (original assessment kept for the record)
- **Type:** CWE-319 (cleartext exposure), CWE-614
- **Location:** `README.md` ("Run the container" section)
---
### M-4. Security headers lost on `/index.html` and all static assets (nginx `add_header` inheritance trap)
- **Severity:** Medium (defense-in-depth gap; main `/` URL is fine)
- **Type:** CWE-693 (protection mechanism failure — misconfiguration)
- **Location:** `nginx.conf`, server block vs. `location = /index.html` and `location ~* \.(css|js|jpeg|...)$`
- **Description:** nginx only inherits `add_header` directives from an outer level if the current level defines **none**. Both of those locations define their own `add_header` (cache headers), so they serve responses with **no CSP, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy**. A direct request to `/index.html` or any `.css`/`.js` asset is served without security headers — including CSP, which is the mitigation for M-1.
- **PoC:** `curl -sI https://<site>/index.html` → no `Content-Security-Policy` header (vs. `curl -sI https://<site>/` which has it).
- **Remediation:** put the shared security headers in a snippet, e.g. `headers.inc`:
```
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
```
and `include /etc/nginx/headers.inc;` in **every** location block (alongside the cache headers).
---
### L-1. No HSTS emitted by this service (verify at the edge)
- **Severity:** Low
- **Type:** CWE-319
- **Location:** `nginx.conf` (absent), assumed Caddy edge
- **Description:** No `Strict-Transport-Security` anywhere in this repo. If Caddy isn't sending it, first-visit users are downgrade-attack-exposed.
- **Remediation:** Verify `curl -sI https://reeseapps.com/ | grep -i strict-transport`. If absent, add at the Caddy edge (`header Strict-Transport-Security "max-age=31536000; includeSubDomains"`). (Don't add it inside the 8080 container — it must be set by the TLS terminator.)
### L-2. `server_tokens` not disabled
- **Severity:** Low
- **Type:** CWE-200 (version disclosure)
- **Location:** `nginx.conf`
- **Description:** Default nginx advertises its version in the `Server` header.
- **Remediation:** Add `server_tokens off;` (server or http level — in a conf.d snippet it must go in the `server` block).
### L-3. `style-src 'unsafe-inline'` in CSP
- **Severity:** Low
- **Type:** CWE-79 (reduced CSP strength)
- **Location:** `nginx.conf` CSP
- **Description:** Required today because `index.html` and the JS use dozens of inline `style="..."` attributes and `el.style.x = ...` (note: JS-sets `el.style` are **not** blocked by CSP; only inline style *attributes* are). `script-src 'self'` is clean — no inline scripts, no inline handlers in HTML, which is the important half.
- **Remediation (optional):** move inline `style="..."` attributes in `index.html` into classes; then drop `'unsafe-inline'`. Low priority; do together with other CSS cleanup.
### L-4. README documents a nonexistent `homepage.container` and a conflicting `./src` mount
- **Severity:** Low (doc/ops correctness)
- **Location:** `README.md` (Deploy with Quadlet, Run the container)
- **Description:** `cp homepage.container ~/.config/containers/systemd/` references a file that is not in the repo (verified: not tracked, not present). Also `-v ./src:/usr/share/nginx/html` mounts *unhashed* source, contradicting the hashed `dist/` build the image produces — following the README yields a working-but-unhashed deployment that bypasses the cache-busting pipeline.
- **Remediation:** Either commit a `homepage.container` unit file, or rewrite the README to describe the actual image build (`podman build` + run without the volume mount).
### L-5. Fake terminal shows a syntactically-valid SSH public key
- **Severity:** Low / Informational
- **Type:** CWE-540 (inclusion of functionality from untrusted control plane — informational only)
- **Location:** `src/terminal.js`, `commands['cat ~/.ssh/id_ed25519.pub']`
- **Description:** `ssh-keygen -l` confirms the displayed key parses as a real ED25519 key (fingerprint `SHA256:A1/bjtrxqkXFPXawKfVD2nHW4LBElmQ37+r55YFCJmo`). Public keys are public, so exposure is not a leak. Two notes: (a) if this is a *real* homelab key, publishing it bootstraps trust — an attacker who can MITM the site (or its GPG verification chain) could substitute their key for a visitor adding it to `known_hosts`; keep the page's TLS chain strong and consider displaying only the fingerprint. (b) If it's fabricated, add a comment or make it obviously fictitious so nobody mistakes it for a trust anchor.
- **Remediation:** Decide intent; if real, show fingerprint-only; if fake, mark it clearly.
### L-6. Easter eggs wipe the entire page (`document.body.innerHTML = ''`)
- **Severity:** Low (intended behavior, noted for quality)
- **Location:** `src/terminal.js` — `exit` (non-root) and `rm -rf /` (root) branches
- **Description:** Both destroy the whole DOM (nav, styles references, script state). Recovery is a full reload; the `exit` login screen has no way back without refresh. Fine as an easter egg, but fragile to future edits (e.g. any service worker or analytics would also be nuked).
- **Remediation:** Optional — scope the wipe to a dedicated overlay instead of `document.body`, or add a "reload" hint on the login screen.
### L-7. CI supply-chain hardening (optional for a personal repo)
- **Severity:** Low / Informational
- **Location:** `.gitea/workflows/build-push.yml`
- **Description:** Actions pinned to major versions only (`actions/checkout@v4`, `docker/build-push-action@v6`), image not signed, `push: true` on every push to `main`. Acceptable for a self-hosted personal Gitea; noted for completeness.
- **Remediation (optional):** pin to full commit SHAs, sign the image with cosign + Sigstore keyless, restrict the workflow to `main` only (drop the `release` trigger or guard it).
### L-8. No tests; `build.sh` is untested and fragile
- **Severity:** Low (quality)
- **Location:** `tests/` (empty), `build.sh`
- **Description:** `build.sh` uses `sed` string-rewrites to hash asset references — it silently no-ops if an asset is referenced in any other form (e.g. `url(...)` in CSS, single quotes, CSS `@import`). Nothing verifies dist integrity.
- **Remediation:** Add a small test (shell + `grep` is enough): run `build.sh`, then assert (1) every asset file in `src` exists in `dist` with a hash, (2) no unhashed `*.css|*.js|*.jpeg|*.ico|*.svg` references remain in `dist/index.html`, (3) `dist/index.html` differs from `src/index.html`. Wire into the Gitea workflow before the Docker build.
---
## Code Quality Findings (non-security)
| # | Issue | Location |
|---|-------|----------|
| Q-1 | `terminal.js` is 1353 lines; `createTerminal()` is ~700 lines with a full vim simulator (~350 lines) nested inside the keydown handler. Split into modules: terminal core / command registry / vim / achievements. | `src/terminal.js` |
| Q-2 | **Dead code:** an outer `const commands = {...}` (~line 120) is shadowed by a second, near-identical `const commands = {...}` inside the Enter handler (~line 850). The outer map is never used and has already drifted (different `help` text, missing `apt`/`dnf` entries). Delete the outer one; keep a single registry (also fixes the `date` snapshot bug below). | `src/terminal.js` |
| Q-3 | `let isLoginScreen` is written, never read. | `src/terminal.js` |
| Q-4 | Copy-paste bug (harmless): `cmdText.startsWith('wget ') \|\| cmdText.startsWith('wget ')` — second condition should probably have been the `curl ` check that the first branch already handles; as written it's redundant. | `src/terminal.js` curl/wget branch |
| Q-5 | Grown/expanded terminal state is toggled in **4 places** with duplicated logic (terminal `focus`, terminal `blur`, `mobileInput` `blur`, hero `click`). Extract `expandTerminal()` / `collapseTerminal()` helpers. | `src/terminal.js`, `src/script.js` |
| Q-6 | `const brands = ['Framework']` — random selection from a single-element array. | `src/script.js` `createServerRack()` |
| Q-7 | Repeated inline `el.style.position/left/top/transform` boilerplate for rack decorations — move to CSS classes (also enables L-3 cleanup). | `src/script.js` |
| Q-8 | `revealAchievements()` sets `section.style.display = 'block'` but never clears the `hidden` attribute. Works only because inline style beats the UA `[hidden]` rule — fragile. Use `section.hidden = false`. | `src/terminal.js` |
| Q-9 | **Accessibility:** the 1px invisible `mobileInput` (the real key source) has no `aria-label`/role; the `tabindex="0"` terminal div has no keyboard handler of its own. Screen-reader users cannot operate the terminal. Add a labelled input (visually-hidden pattern) and/or an `aria-hidden` toggle; also `#achievements` nav is injected client-side only. | `src/terminal.js`, `src/index.html` |
| Q-10 | `date` command is a page-load snapshot (the commands map is built once) — reports the time the page loaded, not "now". Make it a function or compute at dispatch. | `src/terminal.js` |
| Q-11 | `error_page 404 /index.html` + `try_files ... /index.html` → every unknown path returns the homepage body with a 404 status. Acceptable for a one-pager, but be aware all soft-404s serve full content. | `nginx.conf` |
| Q-12 | Local `dist/` is stale (contains an older `index.html` with different classes) — untracked, so no repo harm, but rebuild before deploying to avoid confusion. | `dist/` |
## What's already good (verified, not assumed)
- No secrets in the repo or git history (secret-pattern grep across tracked files + `git log`); CI uses Gitea `secrets.*` correctly.
- No inline `<script>` anywhere; JS loaded from local hashed files with `defer`; CSP `script-src 'self'` is meaningful.
- All user *typing* paths in the terminal render via `textContent` / `insertAdjacentText`; the vim module correctly uses `escapeHtml()` before its only `innerHTML` sinks. (The history-recall path, M-1, is the exception.)
- `rel="noopener"` on every `target="_blank"` link.
- No external CDNs or third-party runtime dependencies — small, auditable supply chain.
- `.gitignore` correctly excludes `dist/` (verified via `git ls-files`); Dockerfile removes build context after hashing; container has a healthcheck.
- GPG key block is public material (expected exposure, correctly presented with fingerprints).
- `style.css`: 0 `!important`, sane z-index usage.
## Remediation Task List (final state, 2026-09-18)
**Phased (run via `phased-execution` skill, in order):**
1. **[M-1]** → `01_fix_history_xss` — replace the 3 `lastLine.innerHTML = ... + cmdLine` history-recall writes with the safe `updateDisplay()` path + XSS regression test.
2. **[M-2]** → `02_simulate_fetch_commands` — replace `curl`/`wget` real `fetch(url)` with deterministic simulated output (`simulatedCurlOutput`/`simulatedWgetOutput`) + no-network regression test.
3. **[M-4] (+L-2)** → `03_nginx_security_headers` — extract shared `add_header` snippet, `include` in every `location`, `server_tokens off;` + container header-check script.
**Dismissed:**
4. **[M-3]** — false positive per owner (firewall + Caddy edge already cover it). No change.
**Executed directly in-session (owner instruction; not phased):**
10. **[Q-2, Q-3, Q-4, Q-10]** done — outer dead `commands` map deleted, `isLoginScreen` removed, duplicated wget condition fixed, `date` dynamic.
11. **[Q-5, Q-7, Q-8]** done — `expandTerminal`/`collapseTerminal` helpers, rack-decoration CSS classes, `section.hidden = false`.
12. **[Q-1, Q-9]** done — `terminal.js` split into `terminal-commands.js` / `terminal-vim.js` / `terminal-achievements.js` (1353 → 652 lines), `aria-label` on the hidden input.
15. **[vim `dd`]** done — dead Shift+D key check fixed, last-line deletion guard fixed (buffer keeps one empty line, so insert mode can't index a missing line).
16. **[tests]** done — 44-test Playwright E2E regression suite (`tests/e2e/`), green.
**Remaining (unphased Low items):**
5. **[L-1]** Verify (or add at Caddy edge) `Strict-Transport-Security` — folded into Phase 03 verification notes (edge-only, never in the container).
7. **[L-4]** Fix README quadlet section (add `homepage.container` or remove reference); remove the `./src` volume-mount instruction.
8. **[L-5]** Decide real/fake for the displayed SSH key; fingerprint-only if real, mark fictitious if not.
9. **[L-8]** Add a `build.sh` verification test + a "run build" step to the Gitea workflow before image build.
13. **[L-3]** (optional) Remove inline `style="..."` attributes from `index.html`, then drop `style-src 'unsafe-inline'`.
14. **[L-7]** (optional) Pin CI actions to SHAs; consider image signing.
## Verification Commands (after remediation)
```sh
# Rebuild and check dist integrity
./build.sh && grep -Eno '(href|src)="[a-z]+\.(css|js|jpeg|ico|svg)"' dist/index.html # expect: no unhashed refs
# Header checks on the running container
curl -sI http://localhost:8080/ | grep -Ei 'content-security|x-frame|x-content-type|referrer'
curl -sI http://localhost:8080/index.html | grep -Ei 'content-security|x-frame|x-content-type|referrer' # must match after M-4
curl -sI http://localhost:8080/style.*.css | grep -Ei 'x-content-type'
# Live edge (run from a network that can reach it)
curl -sI https://reeseapps.com/ | grep -Ei 'strict-transport|x-content-type|content-security'
# Secret re-scan
git grep -iEn 'password|secret|api[_-]?key|BEGIN (RSA|OPENSSH|PRIVATE)' -- ':!dist'
# M-1 regression: type payload, Enter, Up-arrow in the terminal → no script/DOM injection
```
+10
View File
@@ -8,6 +8,16 @@ dist/
# Local Docker build cache # Local Docker build cache
.docker/ .docker/
# JS / test tooling
node_modules/
test-results/
playwright-report/
# Phased-execution runtime artifacts (the .agents/ planning tree itself is
# tracked and committed — only its per-run scratch is ignored)
.agents/phase-sessions/
.agents/pipeline.log
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+46
View File
@@ -0,0 +1,46 @@
# AGENTS.md
Rules for any agent working in this repository.
## Always
1. **Read `.agents/PLAN.md` first** — project goals, architecture, and the
**LOCKED DECISIONS** (Architectural Anchors) are binding.
2. **Follow the phased protocol** in `.agents/phases/`:
- `todo/NN_name/` — pending phases (`00_phase.md` overview + `NN_task.md`
task files; task sort order = execution order).
- `complete/` — finished phases (read-only history).
- New work is captured as a phase directory via the `phase-authoring`
skill; code changes flow through the `phased-execution` skill (each task
runs in a fresh subprocess behind the validation gate).
3. **Test before you ship:**
- `./build.sh` — rebuild `dist/` (tests run against `dist/`, the
deployed artifact — never test `src/` directly).
- `npm test` — the Playwright E2E suite (pinned `@playwright/test`
1.62.0; do not bump without re-checking the cached browser revision).
- For any nginx/Dockerfile change: `./scripts/check-headers.sh`
(Phase 03+).
## Never
- Modify `.agents/PLAN.md` except via an explicit user-approved change to the
LOCKED DECISIONS (record the approval in the PLAN's review history).
- Modify anything in `.agents/phases/complete/`.
- Edit files in `.agents/phases/todo/` without asking the user first
(phase rewrites are a `phase-authoring` action).
- Add runtime dependencies, CDNs, or a JS build toolchain (LOCKED: vanilla
HTML/CSS/JS, flat `src/*.js`, `build.sh` hashing).
- Add real network I/O to the fake terminal (LOCKED: simulated output only —
see Phase 02 and PLAN §2.5).
- Add `Strict-Transport-Security` inside the nginx container — TLS/HSTS are
the Caddy edge's job (the container serves plain HTTP on 8080).
## Conventions
- Static site: edits in `src/` → `./build.sh` → `dist/` (hash-cached names).
New root-level assets are picked up automatically by `build.sh`; nested
directories are **not** (keep JS files flat in `src/`).
- Terminal modules: `terminal-commands.js` (canned data),
`terminal-achievements.js`, `terminal-vim.js`, `terminal.js` (core) — load
order is fixed in `index.html` (all `defer`); keep dependencies pointing
only at earlier files.
- Commits: small, imperative subject; one logical change per commit.
- `dist/`, `node_modules/`, `test-results/`, `playwright-report/` are
build/test artifacts — never edit them by hand.
+78
View File
@@ -0,0 +1,78 @@
{
"name": "homepage",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "homepage",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.62.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "homepage",
"version": "1.0.0",
"private": true,
"description": "Static portfolio site — build (build.sh) + E2E tests (Playwright)",
"scripts": {
"build": "./build.sh",
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "1.62.0"
}
}
+28
View File
@@ -0,0 +1,28 @@
// @ts-check
const { defineConfig } = require('@playwright/test');
/**
* E2E config: serves the production build (dist/) on a local static server.
* Always run `./build.sh` (npm run build) before `npm test` so the tests
* exercise the exact artifact that gets deployed.
*/
module.exports = defineConfig({
testDir: './tests/e2e',
timeout: 20000,
retries: 0,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:8123',
viewport: { width: 1440, height: 900 },
},
webServer: {
command: 'python3 -m http.server 8123 --bind 127.0.0.1 --directory dist',
url: 'http://127.0.0.1:8123/index.html',
reuseExistingServer: !process.env.CI,
timeout: 15000,
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});
+3
View File
@@ -696,6 +696,9 @@ XwEAnes79w4eYeMUjIytQWACEvy4QoO7X2MLTKliSqc4Ag8=
</p> </p>
</footer> </footer>
<script src="terminal-commands.js" defer></script>
<script src="terminal-achievements.js" defer></script>
<script src="terminal-vim.js" defer></script>
<script src="terminal.js" defer></script> <script src="terminal.js" defer></script>
<script src="script.js" defer></script> <script src="script.js" defer></script>
</body> </body>
+14 -54
View File
@@ -60,7 +60,8 @@ function createServerRack() {
const totalPageHeight = document.documentElement.scrollHeight; const totalPageHeight = document.documentElement.scrollHeight;
const totalUnits = Math.max(Math.ceil((totalPageHeight - 100 - barHeight) / unitHeight), 10); const totalUnits = Math.max(Math.ceil((totalPageHeight - 100 - barHeight) / unitHeight), 10);
const brands = ['Framework']; // Only one brand in the rack for now
const RACK_BRAND = 'Framework';
const services = ['Borg', 'Gitea', 'Nextcloud', 'Jellyfin', 'Open WebUI', 'llama.cpp', 'Immich', 'LiteLLM', 'Caddy', 'Nginx', 'Samba', 'Slopbox']; const services = ['Borg', 'Gitea', 'Nextcloud', 'Jellyfin', 'Open WebUI', 'llama.cpp', 'Immich', 'LiteLLM', 'Caddy', 'Nginx', 'Samba', 'Slopbox'];
@@ -88,7 +89,7 @@ function createServerRack() {
const brandLabel = document.createElement('div'); const brandLabel = document.createElement('div');
brandLabel.className = 'eth-brand'; brandLabel.className = 'eth-brand';
brandLabel.textContent = brands[Math.floor(Math.random() * brands.length)]; brandLabel.textContent = RACK_BRAND;
switchFace.appendChild(brandLabel); switchFace.appendChild(brandLabel);
const portCount = Math.floor(Math.random() * 4) + 5; const portCount = Math.floor(Math.random() * 4) + 5;
@@ -139,19 +140,12 @@ function createServerRack() {
bar.className = 'grill-v-bar'; bar.className = 'grill-v-bar';
grillV.appendChild(bar); grillV.appendChild(bar);
} }
grillV.style.position = 'absolute'; grillV.classList.add('rack-grill-v-pos');
grillV.style.left = '16px';
grillV.style.top = '50%';
grillV.style.transform = 'translateY(-50%)';
face.appendChild(grillV); face.appendChild(grillV);
const fanContainer = document.createElement('div'); const fanContainer = document.createElement('div');
fanContainer.className = 'fan-container'; fanContainer.className = 'fan-container';
fanContainer.style.position = 'absolute'; fanContainer.classList.add('rack-fans-pos');
fanContainer.style.right = '16px';
fanContainer.style.top = '50%';
fanContainer.style.transform = 'translateY(-50%)';
fanContainer.style.zIndex = '1';
const fanCount = Math.floor(Math.random() * 2) + 1; const fanCount = Math.floor(Math.random() * 2) + 1;
const fanSpeeds = ['fan-slow', 'fan-medium', 'fan-fast']; const fanSpeeds = ['fan-slow', 'fan-medium', 'fan-fast'];
@@ -174,10 +168,7 @@ function createServerRack() {
const ledGroup = document.createElement('div'); const ledGroup = document.createElement('div');
ledGroup.className = 'led-group'; ledGroup.className = 'led-group';
ledGroup.style.position = 'absolute'; ledGroup.classList.add('rack-leds-pos-top');
ledGroup.style.top = '8px';
ledGroup.style.left = '50%';
ledGroup.style.transform = 'translateX(-50%)';
const ledCount = Math.floor(Math.random() * 3) + 1; const ledCount = Math.floor(Math.random() * 3) + 1;
const blinkClasses = ['led-blink-1', 'led-blink-2', 'led-blink-3', 'led-blink-4']; const blinkClasses = ['led-blink-1', 'led-blink-2', 'led-blink-3', 'led-blink-4'];
@@ -201,18 +192,12 @@ function createServerRack() {
bar.className = 'grill-h-bar'; bar.className = 'grill-h-bar';
grillH.appendChild(bar); grillH.appendChild(bar);
} }
grillH.style.position = 'absolute'; grillH.classList.add('rack-grill-h-pos');
grillH.style.left = '50%';
grillH.style.top = '50%';
grillH.style.transform = 'translate(-50%, -50%)';
face.appendChild(grillH); face.appendChild(grillH);
const ledGroup = document.createElement('div'); const ledGroup = document.createElement('div');
ledGroup.className = 'led-group'; ledGroup.className = 'led-group';
ledGroup.style.position = 'absolute'; ledGroup.classList.add('rack-leds-pos-right');
ledGroup.style.right = '16px';
ledGroup.style.top = '50%';
ledGroup.style.transform = 'translateY(-50%)';
const ledCount = Math.floor(Math.random() * 3) + 1; const ledCount = Math.floor(Math.random() * 3) + 1;
const blinkClasses = ['led-blink-1', 'led-blink-2', 'led-blink-3', 'led-blink-4']; const blinkClasses = ['led-blink-1', 'led-blink-2', 'led-blink-3', 'led-blink-4'];
@@ -230,13 +215,7 @@ function createServerRack() {
} else { } else {
// Drive bays // Drive bays
const driveBayContainer = document.createElement('div'); const driveBayContainer = document.createElement('div');
driveBayContainer.style.display = 'flex'; driveBayContainer.className = 'rack-drives-pos';
driveBayContainer.style.gap = '8px';
driveBayContainer.style.alignItems = 'center';
driveBayContainer.style.position = 'absolute';
driveBayContainer.style.left = '50%';
driveBayContainer.style.top = '50%';
driveBayContainer.style.transform = 'translate(-50%, -50%)';
const driveBays = Math.floor(Math.random() * 3) + 6; const driveBays = Math.floor(Math.random() * 3) + 6;
for (let b = 0; b < driveBays; b++) { for (let b = 0; b < driveBays; b++) {
@@ -248,10 +227,7 @@ function createServerRack() {
const ledGroup = document.createElement('div'); const ledGroup = document.createElement('div');
ledGroup.className = 'led-group'; ledGroup.className = 'led-group';
ledGroup.style.position = 'absolute'; ledGroup.classList.add('rack-leds-pos-right-tight');
ledGroup.style.right = '10px';
ledGroup.style.top = '50%';
ledGroup.style.transform = 'translateY(-50%)';
const ledCount = Math.floor(Math.random() * 3) + 1; const ledCount = Math.floor(Math.random() * 3) + 1;
const blinkClasses = ['led-blink-1', 'led-blink-2', 'led-blink-3', 'led-blink-4']; const blinkClasses = ['led-blink-1', 'led-blink-2', 'led-blink-3', 'led-blink-4'];
@@ -319,28 +295,12 @@ heroEl.addEventListener('mouseup', () => { heroMouseDown = false; });
// Toggle terminal expansion when clicking the hero section // Toggle terminal expansion when clicking the hero section
heroEl.addEventListener('click', (e) => { heroEl.addEventListener('click', (e) => {
if (e.target.closest('.btn')) return; if (e.target.closest('.btn')) return;
if (activeTerminal && activeTerminal._mobileInput) { if (!activeTerminal) return;
if (activeTerminal.classList.contains('grown')) { if (activeTerminal.classList.contains('grown')) {
activeTerminal.classList.remove('grown'); collapseTerminal(activeTerminal);
activeTerminal.classList.remove('opaque'); activeTerminal._mobileInput?.blur();
const face = activeTerminal.closest('.server-face');
const unit = face?.closest('.rack-unit');
face?.classList.remove('grown');
unit?.classList.remove('grown');
heroEl.classList.remove('shifted');
activeTerminal._mobileInput.blur();
document.querySelector('.rack-container')?.classList.remove('opaque');
} else { } else {
activeTerminal._mobileInput.focus(); expandTerminal(activeTerminal);
activeTerminal.classList.add('grown');
activeTerminal.classList.add('opaque');
const face = activeTerminal.closest('.server-face');
const unit = face?.closest('.rack-unit');
face?.classList.add('grown');
unit?.classList.add('grown');
heroEl.classList.add('shifted');
document.querySelector('.rack-container')?.classList.add('opaque');
}
} }
}); });
+53
View File
@@ -1882,3 +1882,56 @@ footer p {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* Rack decoration placement (moved out of inline styles in script.js) */
.rack-grill-v-pos {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
}
.rack-grill-h-pos {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.rack-fans-pos {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
.rack-leds-pos-top {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
}
.rack-leds-pos-right {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
}
.rack-leds-pos-right-tight {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
.rack-drives-pos {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 8px;
align-items: center;
}
+201
View File
@@ -0,0 +1,201 @@
// Achievement system for the fake terminal: definition, localStorage
// persistence, toast notifications, and the hidden-section reveal.
const ACHIEVEMENTS_STORAGE_KEY = 'reese-terminal-achievements';
const ACHIEVEMENTS = {
time_flies: { name: 'Time Flies', desc: 'Check the system uptime', icon: '⏱️', cmd: 'uptime' },
curious: { name: 'Curious', desc: 'List directory contents', icon: '📁', cmd: 'ls', prefix: true },
port_scanner: { name: 'Port Scanner', desc: 'Check listening ports', icon: '🔍', cmd: 'ss', prefix: true },
password_reset: { name: 'Password Reset Request', desc: 'Submit a SNOW request', icon: '🚪', cmd: 'exit' },
power_user: { name: 'Power User', desc: 'Gain root access', icon: '👑', cmd: 'sudo su -' },
nice_try: { name: 'Nice Try', desc: 'Attempt rm -rf / as a normal user', icon: '😏', cmd: 'rm -rf /' },
restore_backup: { name: 'Restore from Backup', desc: 'Actually destroy the system as root', icon: '💥', cmd: 'rm -rf / root' },
where_am_i: { name: 'Where Am I?', desc: 'Print working directory', icon: '📍', cmd: 'pwd' },
self_aware: { name: 'Self-Aware', desc: 'Print current user', icon: '🪞', cmd: 'whoami' },
identity: { name: 'Identity Crisis', desc: 'Print hostname', icon: '🖥️', cmd: 'hostname' },
time_keeper: { name: 'Time Keeper', desc: 'Print current date', icon: '📅', cmd: 'date' },
system_explorer: { name: 'System Explorer', desc: 'Print system information', icon: '🔧', cmd: 'uname' },
access_granted: { name: 'Access Granted', desc: 'Check user identity', icon: '🆔', cmd: 'id' },
os_detective: { name: 'OS Detective', desc: 'Read the OS release file', icon: '🐧', cmd: 'cat /etc/os-release' },
ssh_explorer: { name: 'SSH Explorer', desc: 'View SSH public key', icon: '🔑', cmd: 'cat ~/.ssh/id_ed25519.pub' },
container_watcher: { name: 'Container Watcher', desc: 'List Docker containers', icon: '🐳', cmd: 'docker ps', prefix: true },
docker_confused: { name: 'Docker Confused', desc: 'Use wrong Docker command', icon: '🤔', cmd: 'docker ls', prefix: true },
container_pro: { name: 'Container Pro', desc: 'Use correct container command', icon: '✅', cmd: 'docker container ls', prefix: true },
podman_fan: { name: 'Podman Fan', desc: 'List Podman containers', icon: '📦', cmd: 'podman ps', prefix: true },
podman_confused: { name: 'Podman Confused', desc: 'Use wrong Podman command', icon: '🤔', cmd: 'podman ls', prefix: true },
podman_pro: { name: 'Podman Pro', desc: 'Use correct Podman command', icon: '✅', cmd: 'podman container ls', prefix: true },
service_hunter: { name: 'Service Hunter', desc: 'List systemd units', icon: '🔎', cmd: 'systemctl list-units', prefix: true },
service_filter: { name: 'Service Filter', desc: 'Filter running services', icon: '🎯', cmd: 'systemctl list-units --type=service --state=running --no-pager' },
network_ninja: { name: 'Network Ninja', desc: 'Show network interfaces', icon: '🌐', cmd: 'ip addr show', prefix: true },
disk_detective: { name: 'Disk Detective', desc: 'Check disk usage', icon: '💾', cmd: 'df -h' },
memory_minded: { name: 'Memory Minded', desc: 'Check memory usage', icon: '🧠', cmd: 'free -h' },
process_watcher: { name: 'Process Watcher', desc: 'List running processes', icon: '👁️', cmd: 'ps aux', prefix: true },
memory_hog: { name: 'Memory Hog', desc: 'Find top memory consumers', icon: '🐗', cmd: 'ps aux --sort=-%mem | head -10' },
aesthetic_mode: { name: 'Aesthetic Mode', desc: 'Display system info with style', icon: '✨', cmd: 'neofetch' },
help_seeker: { name: 'Help Seeker', desc: 'Look up available commands', icon: '📖', cmd: 'help' },
clean_slate: { name: 'Clean Slate', desc: 'Clear the terminal', icon: '🧹', cmd: 'clear' },
train_spotter: { name: 'Train Spotter', desc: 'Run the steam locomotive', icon: '🚂', cmd: 'sl' },
tain: { name: 'I like trains', desc: 'Choo chooooooo', icon: '🧹', cmd: 'sl' },
web_navigator: { name: 'Web Navigator', desc: 'Fetch a webpage using curl or wget', icon: '🌐', cmd: 'curl', prefix: true },
package_manager: { name: 'Package Manager', desc: 'Update package lists with apt', icon: '📦', cmd: 'apt update' },
software_installer: { name: 'Software Installer', desc: 'Install a package with apt', icon: '🔧', cmd: 'apt install', prefix: true },
fedora_updater: { name: 'Fedora Updater', desc: 'Update packages with dnf', icon: '🎯', cmd: 'dnf update' },
fedora_installer: { name: 'Fedora Installer', desc: 'Install a package with dnf', icon: '📀', cmd: 'dnf install', prefix: true },
vim_splash: { name: 'Vi sIgnificantly worse M', desc: 'Open vim without a filename', icon: '📝', cmd: 'vim' },
vim_edit: { name: 'Actually Editing', desc: 'Open vim with a filename', icon: '✏️', cmd: 'vim ', prefix: true },
};
// Load unlocked achievement keys from localStorage
function loadAchievements() {
try {
const saved = localStorage.getItem(ACHIEVEMENTS_STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
// Persist unlocked achievement keys to localStorage
function saveAchievements(achieved) {
localStorage.setItem(ACHIEVEMENTS_STORAGE_KEY, JSON.stringify(achieved));
}
// Check if the given command triggers any new achievements; returns unlocked achievement objects
function checkAchievements(cmdText, isRoot) {
const achieved = loadAchievements();
const newAchievements = [];
for (const [key, achievement] of Object.entries(ACHIEVEMENTS)) {
if (achieved.includes(key)) continue;
if (key === 'restore_backup' && cmdText === 'rm -rf /' && isRoot) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'power_user' && (cmdText === 'sudo su -' || cmdText === 'sudo -i')) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'password_reset' && cmdText === 'exit' && !isRoot) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'nice_try' && cmdText === 'rm -rf /' && !isRoot) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'system_explorer' && (cmdText === 'uname' || cmdText === 'uname -a')) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'docker_confused' && cmdText === 'docker ls') {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'podman_confused' && cmdText === 'podman ls') {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'web_navigator' && (cmdText.startsWith('curl ') || cmdText.startsWith('wget '))) {
achieved.push(key);
newAchievements.push(achievement);
} else if (achievement.prefix ? cmdText.startsWith(achievement.cmd) : achievement.cmd === cmdText) {
if ((key === 'nice_try' && isRoot) || (key === 'restore_backup' && !isRoot)) {
continue;
}
achieved.push(key);
newAchievements.push(achievement);
}
}
if (newAchievements.length > 0) {
saveAchievements(achieved);
}
return newAchievements;
}
// Display a toast notification for a newly unlocked achievement
function showToast(achievement) {
const container = document.getElementById('toast-container') || createToastContainer();
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div class="toast-icon">${achievement.icon}</div>
<div class="toast-content">
<div class="toast-label">Achievement Unlocked</div>
<div class="toast-title">${achievement.name}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('removing');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Create the toast notification container element if it doesn't exist
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
document.body.appendChild(container);
return container;
}
// Reveal the hidden achievements section and add it to the nav if needed
function revealAchievements() {
const section = document.getElementById('achievements');
if (section.hidden) {
section.hidden = false;
section.classList.add('fade-in');
setTimeout(() => section.classList.add('visible'), 50);
addAchievementsNav();
}
renderAchievements();
}
// Render all achievement cards (unlocked and locked) into the achievements grid
function renderAchievements() {
const achieved = loadAchievements();
const total = Object.keys(ACHIEVEMENTS).length;
const count = achieved.length;
const countEl = document.getElementById('achievements-count');
countEl.textContent = `${count} / ${total} achievements unlocked`;
const grid = document.getElementById('achievements-grid');
grid.innerHTML = '';
for (const [key, achievement] of Object.entries(ACHIEVEMENTS)) {
const isUnlocked = achieved.includes(key);
const card = document.createElement('div');
card.className = `achievement-card ${isUnlocked ? 'unlocked' : 'locked'}`;
card.innerHTML = `
<div class="achievement-icon">${achievement.icon}</div>
<div class="achievement-info">
<h3>${isUnlocked ? achievement.name : '???'}</h3>
<p>${isUnlocked ? achievement.desc : 'Keep exploring the terminal...'}</p>
</div>
`;
grid.appendChild(card);
}
}
// Add an "Achievements" link to the main navigation if achievements have been unlocked
function addAchievementsNav() {
const navLinks = document.getElementById('navLinks');
const existing = document.getElementById('nav-achievements');
if (!existing && loadAchievements().length > 0) {
const li = document.createElement('li');
li.id = 'nav-achievements';
li.innerHTML = '<a href="#achievements">Achievements</a>';
navLinks.appendChild(li);
}
}
// Restore previously unlocked achievements on page load.
// Called by terminal.js once the page has loaded.
function initTerminalAchievements() {
const achieved = loadAchievements();
if (achieved.length > 0) {
revealAchievements();
addAchievementsNav();
}
}
+53
View File
@@ -0,0 +1,53 @@
// Canned output for the fake terminal (single source of truth) and the
// command list used for Tab completion.
// Values are plain strings, except functions which are evaluated at dispatch
// time (so `date` always shows the current time).
const TERMINAL_COMMANDS = {
'pwd': '/home/reese/portfolio',
'whoami': 'reese',
'hostname': 'homelab',
'date': () => new Date().toString(),
'uname': 'Linux homelab 7.0.0 #1 SMP x86_64 GNU/Linux',
'uname -a': 'Linux homelab 6.8.0 #1 SMP x86_64 GNU/Linux',
'uptime': ' 14:30:00 up 42 days, 3:15, 1 user, load average: 0.42, 0.38, 0.35',
'id': 'uid=1000(reese) gid=1000(reese) groups=1000(reese),985(podman)',
'cat /etc/os-release': 'PRETTY_NAME="Fedora Linux 69 (Workstation Edition)"\nNAME="Fedora Linux"\nVERSION_ID="69"\nVERSION="69 (Workstation Edition)"\nID=fedora\nVARIANT=Workstation Edition\nVARIANT_ID=workstation\nLOGO=fedora-logo-icon\nCPE_NAME="cpe:/o:fedoralinux:fedora:69"\nDEFAULT_HOSTNAME="fedora"\nHOME_URL="https://fedoraproject.org/"\nDOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f69/system-administrators-guide/"\nSUPPORT_URL="https://ask.fedoraproject.org/"\nBUG_REPORT_URL="https://bugzilla.redhat.com/"\nREDHAT_BUGZILLA_PRODUCT="Fedora"\nREDHAT_BUGZILLA_PRODUCT_VERSION=69\nREDHAT_SUPPORT_PRODUCT="Fedora"\nREDHAT_SUPPORT_PRODUCT_VERSION=69',
'cat ~/.ssh/id_ed25519.pub': 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGpFmKLqRKzMwRe3WkqJvQrN5mHjF2pRn8sT6yUvWxRe reese@homelab',
'docker ps': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'docker ls': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'docker container ls': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'podman ps': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'podman ls': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'podman container ls': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'systemctl list-units': 'UNIT LOAD ACTIVE SUB DESCRIPTION\nborg.service loaded active running Borg Backup Service\ngitea.service loaded active running Gitea\ndocker.service loaded active running Docker Application Container Engine\nnextcloud.service loaded active running Nextcloud\nimmich.service loaded active running Immich\nopen-webui.service loaded active running Open WebUI\njellyfin.service loaded active running Jellyfin Media Server\nslopbox.service loaded active running Slopbox\nhomepage.service loaded active running Homepage Container',
'systemctl list-units --type=service --state=running --no-pager': 'UNIT LOAD ACTIVE SUB DESCRIPTION\nborg.service loaded active running Borg Backup Service\ngitea.service loaded active running Gitea\ndocker.service loaded active running Docker Application Container Engine\nnextcloud.service loaded active running Nextcloud\nimmich.service loaded active running Immich\nopen-webui.service loaded active running Open WebUI\njellyfin.service loaded active running Jellyfin Media Server\nslopbox.service loaded active running Slopbox\nhomepage.service loaded active running Homepage Container',
'ss': 'Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\ntcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:80 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:443 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:222 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3000 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3001 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3002 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8081 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8096 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:9090 0.0.0.0:*\ntcp LISTEN 0 128 [::]:22 [::]:*',
'ip addr show': '1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN\n inet 127.0.0.1/8 scope host lo\n inet6 ::1/128 scope host\n2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP\n inet 192.168.1.42/24 brd 192.168.1.255 scope global dynamic eth0\n inet6 fe80::1/64 scope link',
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda2 465G 182G 258G 42% /\nudev 16G 0 16G 0% /dev\ntmpfs 16G 2.1M 16G 1% /dev/shm\n/dev/sda1 511M 6.6M 505M 2% /boot/efi\n/dev/sdb1 1.8T 945G 793G 55% /mnt/data\noverlay 1.8T 945G 793G 55% /var/lib/docker/overlay2',
'free -h': ' total used free shared buff/cache available\nMem: 31Gi 8.2Gi 12Gi 512Mi 11Gi 22Gi\nSwap: 2.0Gi 0B 2.0Gi',
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nreese 1242 2.1 12.4 4285632 3932160 ? Sl May15 42:18 /opt/llama.cpp/build/bin/llama-server --model /models/Qwen3-30B-A3B.Q4_K_M.gguf --port 8082\ndocker 2341 1.2 6.8 8562348 2156032 ? Sl May15 28:45 /usr/bin/dockerd -H fd://\nreese 3456 0.8 3.2 2845632 1015808 ? Sl May15 18:22 /opt/open-webui/server\nreese 4567 0.5 2.1 1562348 665600 ? Sl May15 12:34 /usr/bin/python3 /opt/borg/borgmatic\nroot 5678 0.3 1.4 945632 448000 ? Ssl May15 8:12 /usr/bin/docker-proxy -p tcp:0.0.0.0:80:80\nreese 6789 0.2 1.1 745632 348000 ? Ssl May15 5:45 /usr/bin/podman run --name homepage\nroot 7890 0.1 0.8 545632 256000 ? Ssl May15 3:22 /usr/bin/docker-proxy -p tcp:0.0.0.0:443:443',
'ps aux --sort=-%mem | head -10': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nreese 1242 2.1 12.4 4285632 3932160 ? Sl May15 42:18 /opt/llama.cpp/build/bin/llama-server --model /models/Qwen3-30B-A3B.Q4_K_M.gguf --port 8082\ndocker 2341 1.2 6.8 8562348 2156032 ? Sl May15 28:45 /usr/bin/dockerd -H fd://\nreese 3456 0.8 3.2 2845632 1015808 ? Sl May15 18:22 /opt/open-webui/server\nreese 4567 0.5 2.1 1562348 665600 ? Sl May15 12:34 /usr/bin/python3 /opt/borg/borgmatic\nroot 5678 0.3 1.4 945632 448000 ? Ssl May15 8:12 /usr/bin/docker-proxy -p tcp:0.0.0.0:80:80\nreese 6789 0.2 1.1 745632 348000 ? Ssl May15 5:45 /usr/bin/podman run --name homepage\nroot 7890 0.1 0.8 545632 256000 ? Ssl May15 3:22 /usr/bin/docker-proxy -p tcp:0.0.0.0:443:443',
'neofetch': ` 'c. reese@homelab\n ,xNMM. ----------------------\n .OMMMMo OS: Fedora Linux 69 (Workstation Edition) x86_64\n OMMM0, Host: custom-build\n .;loddo:' loolloddol;. Kernel: 6.8.0\n cKMMMMMMMMMMNWMMMMMMMMMM0: Uptime: 42 days, 3 hours\n .KMMMMMMMMMMMMMMMMMMMMMMMWd. Packages: 2847 (dnf)\n XMMMMMMMMMMMMMMMMMMMMMMMX. Shell: bash 5.2.26\n;MMMMMMMMMMMMMMMMMMMMMMMM: Resolution: 2560x1440\n:MMMMMMMMMMMMMMMMMMMMMMMM: DE: GNOME 46.1\n.MMMMMMMMMMMMMMMMMMMMMMMMX. WM: Mutter\n kMMMMMMMMMMMMMMMMMMMMMMMMWd. Terminal: /dev/pts/0\n .XMMMMMMMMMMMMMMMMMMMMMMMMMMk CPU: AMD Ryzen 9 7900X (24) @ 5.6GHz\n .XMMMMMMMMMMMMMMMMMMMMMMK. GPU: NVIDIA GeForce RTX 4090\n kMMMMMMMMMMMMMMMMMMMMd GPU: AMD Ryzen Built-in\n ;KMMMMMMMWXXWMMMMMMMk. Memory: 8.2Gi / 31Gi\n .cooc,. .,coo:.\n\n<span style="color: #22c55e">███</span><span style="color: #22c55e">███</span><span style="color: #22c55e">███</span><span style="color: #eab308">███</span><span style="color: #eab308">███</span><span style="color: #eab308">███</span><span style="color: #3b82f6">███</span><span style="color: #3b82f6">███</span><span style="color: #3b82f6">███</span><span style="color: #8b5cf6">███</span><span style="color: #8b5cf6">███</span><span style="color: #8b5cf6">███</span><span style="color: #ec4899">███</span><span style="color: #ec4899">███</span><span style="color: #ec4899">███</span>`,
'sl': '____\n|DD|____T_\n|_ |_____|<\n @-@-@-oo\\',
'help': 'Available commands:\n ls List directory contents\n pwd Print working directory\n whoami Print current user\n hostname Print hostname\n date Print current date/time\n uname Print system information\n uptime Print system uptime\n id Print user identity\n cat Print file contents\n docker List running containers\n podman List running pods\n systemctl List running systemd services\n ss Show listening ports\n ip Show network interfaces\n df Show disk usage\n free Show memory usage\n ps Show running processes\n apt Package manager (update, install)\n curl Fetch a webpage\n wget Fetch a webpage\n vim Terminal text editor (try: vim or vim filename)\n neofetch System info display\n sl Steam locomotive\n clear Clear the terminal\n exit Exit to login screen\n sudo Gain root access\n rm Remove files\n echo Print text\n help Show this help message',
};
const TERMINAL_COMMAND_LIST = [
'ls', 'ss', 'clear', 'exit', 'rm -rf /', 'sudo su -', 'sudo -i',
'pwd', 'whoami', 'hostname', 'date', 'uname', 'uname -a', 'uptime',
'id', 'cat /etc/os-release', 'cat ~/.ssh/id_ed25519.pub',
'docker ps', 'docker ls', 'docker container ls',
'podman ps', 'podman ls', 'podman container ls',
'systemctl list-units', 'systemctl list-units --type=service --state=running --no-pager',
'ip addr show', 'df -h', 'free -h',
'ps aux', 'ps aux --sort=-%mem | head -10',
'neofetch', 'sl', 'help', 'echo', 'curl', 'wget', 'apt', 'dnf', 'vim'
];
// Exact-match command lookup.
function getCommandOutput(cmdText) {
const value = TERMINAL_COMMANDS[cmdText];
if (value === undefined) return undefined;
return typeof value === 'function' ? value() : value;
}
+444
View File
@@ -0,0 +1,444 @@
// Simulated vim editor for the fake terminal.
// The overlay is appended to <body> while active; on exit the terminal's
// content is reset and focus returns to it.
//
// Parameters:
// content .terminal-content element (reset on exit)
// terminal .terminal-display element (refocused on exit)
// mobileInput the hidden <input>; hidden while vim owns the keyboard
// filename argument to `vim <file>` (may be '' for the splash)
// setVimMode callback(active) so the host keeps its own state flag
function launchVimSimulator({ content, terminal, mobileInput, filename, setVimMode }) {
let vimBuffer = ['Welcome to vim (simulated)!'];
let vimLineNum = 0;
let vimStatus = '~';
let vimCol = 0;
let showSplash = false;
setVimMode(true);
// Hide the mobile input so it doesn't intercept vim keystrokes
mobileInput.style.display = 'none';
// Create a full-screen vim overlay
const vimOverlay = document.createElement('div');
vimOverlay.style.position = 'fixed';
vimOverlay.style.inset = '0';
vimOverlay.style.background = '#0a0a0c';
vimOverlay.style.fontFamily = "'SF Mono', 'Fira Code', 'Cascadia Code', monospace";
vimOverlay.style.fontSize = '0.85rem';
vimOverlay.style.lineHeight = '1.5';
vimOverlay.style.color = '#22c55e';
vimOverlay.style.display = 'flex';
vimOverlay.style.flexDirection = 'column';
vimOverlay.style.zIndex = '10000';
vimOverlay.style.padding = '12px 24px';
vimOverlay.style.overflow = 'hidden';
document.body.appendChild(vimOverlay);
const lineNums = [];
const lineTexts = [];
const vimLines = [];
const linesContainer = document.createElement('div');
linesContainer.style.flexGrow = '1';
linesContainer.style.minHeight = '0';
linesContainer.style.overflowY = 'auto';
linesContainer.style.display = 'flex';
linesContainer.style.flexDirection = 'column';
linesContainer.style.scrollbarWidth = 'none';
linesContainer.style.msOverflowStyle = 'none';
function createLineElement() {
const line = document.createElement('div');
line.style.whiteSpace = 'pre';
line.style.flexShrink = '0';
const numSpan = document.createElement('span');
numSpan.style.color = '#666';
numSpan.style.marginRight = '8px';
numSpan.style.userSelect = 'none';
numSpan.style.display = 'inline-block';
numSpan.style.width = '3ch';
const textSpan = document.createElement('span');
line.appendChild(numSpan);
line.appendChild(textSpan);
return line;
}
function createSplashLine(text) {
const line = document.createElement('div');
line.style.whiteSpace = 'pre';
line.style.flexShrink = '0';
const textSpan = document.createElement('span');
line.appendChild(textSpan);
return { line, textSpan };
}
if (!filename) {
showSplash = true;
const splashText = [
'',
' VIM - Vi sIgnificantly worse M',
'',
' version 0.0.0-alpha',
' by disappointed programmers et al.',
' Modified by https://www.reddit.com/r/vim/',
' Vim is open source and you will figure it out',
'',
' Please send help.',
'',
' type :q<Enter> to escape this regret',
' type :help<Enter> or <F1> for on-line help',
' type :help version0<Enter> for version info',
'',
];
for (const text of splashText) {
const { line, textSpan } = createSplashLine(text);
linesContainer.appendChild(line);
lineTexts.push(textSpan);
vimLines.push(line);
}
vimBuffer = splashText;
vimLineNum = 0;
vimCol = 0;
} else {
const line = createLineElement();
linesContainer.appendChild(line);
lineNums.push(line.children[0]);
lineTexts.push(line.children[1]);
vimLines.push(line);
vimBuffer = ['Welcome to ' + filename + '!'];
vimLineNum = 0;
vimCol = 0;
}
vimOverlay.appendChild(linesContainer);
// Status bar at the bottom
const statusLine = document.createElement('div');
statusLine.style.marginTop = 'auto';
statusLine.style.borderTop = '1px solid #444';
statusLine.style.paddingTop = '4px';
statusLine.style.color = '#ccc';
statusLine.style.flexShrink = '0';
vimOverlay.appendChild(statusLine);
// File info at the very bottom
const fileInfo = document.createElement('div');
fileInfo.style.color = '#888';
fileInfo.style.flexShrink = '0';
vimOverlay.appendChild(fileInfo);
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function vimRender() {
// Ensure we have enough line elements
while (vimLines.length < vimBuffer.length) {
let newLine;
if (showSplash) {
const { line, textSpan } = createSplashLine('');
newLine = line;
linesContainer.appendChild(line);
lineTexts.push(textSpan);
} else {
newLine = createLineElement();
linesContainer.appendChild(newLine);
lineNums.push(newLine.children[0]);
lineTexts.push(newLine.children[1]);
}
vimLines.push(newLine);
}
// Update all visible lines
for (let i = 0; i < vimBuffer.length; i++) {
if (showSplash) {
lineTexts[i].textContent = vimBuffer[i] || '';
} else {
lineNums[i].textContent = (i + 1);
const lineText = vimBuffer[i] || '';
if (i === vimLineNum && (vimStatus === '-- INSERT --' || vimStatus === '~')) {
const col = vimCol;
const before = lineText.slice(0, col);
const after = lineText.slice(col);
if (after.length > 0) {
const cursorChar = after.charAt(0);
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">' + escapeHtml(cursorChar) + '</span>' + escapeHtml(after.slice(1));
} else {
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">&nbsp;</span>';
}
} else {
lineTexts[i].textContent = lineText;
}
}
vimLines[i].style.display = '';
}
// Hide unused line elements
for (let i = vimBuffer.length; i < vimLines.length; i++) {
vimLines[i].style.display = 'none';
}
// Auto-scroll when line is above or below the viewport
const lineEl = vimLines[vimLineNum];
if (lineEl && lineEl.style.display !== 'none') {
const lineBottom = lineEl.offsetTop + lineEl.offsetHeight;
const lineTop = lineEl.offsetTop;
if (lineBottom > linesContainer.scrollTop + linesContainer.clientHeight) {
linesContainer.scrollTop = lineBottom - linesContainer.clientHeight;
} else if (lineTop < linesContainer.scrollTop + 12) {
linesContainer.scrollTop = lineTop - 12;
}
}
if (showSplash) {
statusLine.textContent = '';
fileInfo.textContent = '"VIM - Vi sIgnificantly worse M"';
} else {
statusLine.textContent = vimStatus;
fileInfo.textContent = '"' + filename + '"';
}
}
function vimExit(message) {
document.removeEventListener('keydown', vimHandler);
setVimMode(false);
mobileInput.style.display = '';
vimOverlay.remove();
content.innerHTML = '';
if (message) {
const msgLine = document.createElement('div');
msgLine.style.whiteSpace = 'pre-wrap';
msgLine.style.color = '#ccc';
msgLine.textContent = message;
content.appendChild(msgLine);
}
const newLine = document.createElement('div');
newLine.innerHTML = '<span class="terminal-prompt">$</span> ';
content.appendChild(newLine);
const newCursor = document.createElement('span');
newCursor.className = 'terminal-cursor';
newLine.appendChild(newCursor);
content.scrollTop = content.scrollHeight;
terminal.focus();
}
const vimHandler = (e) => {
e.preventDefault();
if (showSplash) {
if (e.key === 'q' && e.shiftKey) {
vimExit();
return;
}
if (e.key === 'Escape' || e.key === 'q') {
vimExit();
return;
}
vimRender();
return;
}
if (vimStatus.startsWith(':')) {
if (e.key === 'Enter') {
const cmd = vimStatus.slice(1).trim();
if (cmd === 'q' || cmd === 'q!') {
vimExit();
return;
}
if (cmd === 'wq' || cmd === 'x') {
vimExit('File saved (simulated).');
return;
}
if (cmd === 'w') {
vimStatus = 'File written (simulated).';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
return;
}
if (cmd.startsWith('%s/')) {
vimStatus = 'Pattern not found (simulated).';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
return;
}
if (cmd === 'help') {
vimStatus = ':q quit :w save :wq save & quit :q! force quit dd delete line i insert o open line';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 3000);
return;
}
vimStatus = 'Unknown command: ' + cmd;
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 2000);
return;
}
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key === 'Backspace') {
vimStatus = vimStatus.slice(0, -1);
vimRender();
return;
}
if (e.key.length === 1) {
vimStatus += e.key;
vimRender();
}
return;
}
if (vimStatus === '-- INSERT --') {
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + e.key + vimBuffer[vimLineNum].slice(vimCol);
vimCol++;
vimRender();
return;
}
if (e.key === 'Enter') {
vimBuffer.splice(vimLineNum + 1, 0, '');
vimLineNum++;
vimCol = 0;
vimRender();
return;
}
if (e.key === 'Backspace') {
if (vimCol > 0) {
vimCol--;
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + vimBuffer[vimLineNum].slice(vimCol + 1);
}
vimRender();
return;
}
if (e.key === 'ArrowUp') {
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowDown') {
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowLeft') {
if (vimCol > 0) { vimCol--; vimRender(); }
return;
}
if (e.key === 'ArrowRight') {
const maxCol = (vimBuffer[vimLineNum] || '').length;
if (vimCol < maxCol) { vimCol++; vimRender(); }
return;
}
vimRender();
return;
}
// Normal mode (~)
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key === 'i') {
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'I') {
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'a') {
vimCol = Math.min(vimCol + 1, (vimBuffer[vimLineNum] || '').length);
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'A') {
vimCol = (vimBuffer[vimLineNum] || '').length;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'o') {
vimBuffer.splice(vimLineNum + 1, 0, '');
vimLineNum++;
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'O') {
vimBuffer.splice(vimLineNum, 0, '');
vimLineNum++;
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'x') {
if (vimBuffer[vimLineNum]) {
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, -1);
}
vimRender();
return;
}
// "dd" — delete the current line (Shift+D, or Alt+D as a fallback).
// The buffer always keeps at least one (empty) line so insert
// mode can never index a missing line.
if ((e.key === 'D' && e.shiftKey) || (e.key === 'd' && e.altKey)) {
vimBuffer.splice(vimLineNum, 1);
if (vimBuffer.length === 0) vimBuffer.push('');
if (vimLineNum >= vimBuffer.length) vimLineNum = vimBuffer.length - 1;
vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length);
vimRender();
return;
}
if (e.key === 'ArrowUp') {
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowDown') {
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowLeft') {
if (vimCol > 0) { vimCol--; vimRender(); }
return;
}
if (e.key === 'ArrowRight') {
const maxCol = (vimBuffer[vimLineNum] || '').length;
if (vimCol < maxCol) { vimCol++; vimRender(); }
return;
}
if (e.key === 'G') {
vimLineNum = vimBuffer.length - 1;
vimRender();
return;
}
if (e.key === 'g') {
vimLineNum = 0;
vimRender();
return;
}
if (e.key === ':') {
vimStatus = ':';
vimRender();
return;
}
vimRender();
};
document.addEventListener('keydown', vimHandler);
}
+47 -746
View File
@@ -1,6 +1,33 @@
// Terminal reference - set when terminal is created in the server rack // Terminal reference - set when terminal is created in the server rack
let activeTerminal = null; let activeTerminal = null;
// Expand/collapse the terminal (and its rack unit / hero shift). Shared by
// the terminal's own focus/blur handlers and the hero click handler in
// script.js.
function expandTerminal(terminalEl) {
const mobileInput = terminalEl._mobileInput;
if (mobileInput) mobileInput.focus();
terminalEl.style.boxShadow = '0 0 8px rgba(234, 179, 8, 0.3), inset 0 0 20px rgba(34, 197, 94, 0.1)';
terminalEl.style.borderColor = '#eab308';
terminalEl.classList.add('grown');
terminalEl.classList.add('opaque');
terminalEl.closest('.server-face')?.classList.add('grown');
terminalEl.closest('.rack-unit')?.classList.add('grown');
document.getElementById('hero')?.classList.add('shifted');
document.querySelector('.rack-container')?.classList.add('opaque');
}
function collapseTerminal(terminalEl) {
terminalEl.style.boxShadow = '';
terminalEl.style.borderColor = '#2a2a2e';
terminalEl.classList.remove('grown');
terminalEl.classList.remove('opaque');
terminalEl.closest('.server-face')?.classList.remove('grown');
terminalEl.closest('.rack-unit')?.classList.remove('grown');
document.getElementById('hero')?.classList.remove('shifted');
document.querySelector('.rack-container')?.classList.remove('opaque');
}
// Create the interactive terminal in the first rack unit // Create the interactive terminal in the first rack unit
function createTerminal(face) { function createTerminal(face) {
// Terminal display // Terminal display
@@ -19,6 +46,7 @@ function createTerminal(face) {
mobileInput.style.outline = 'none'; mobileInput.style.outline = 'none';
mobileInput.style.background = 'transparent'; mobileInput.style.background = 'transparent';
mobileInput.setAttribute('autocorrect', 'off'); mobileInput.setAttribute('autocorrect', 'off');
mobileInput.setAttribute('aria-label', 'Terminal command input');
mobileInput.setAttribute('spellcheck', 'false'); mobileInput.setAttribute('spellcheck', 'false');
mobileInput.setAttribute('autocomplete', 'off'); mobileInput.setAttribute('autocomplete', 'off');
mobileInput.setAttribute('autocapitalize', 'off'); mobileInput.setAttribute('autocapitalize', 'off');
@@ -57,24 +85,12 @@ function createTerminal(face) {
const commandHistory = []; const commandHistory = [];
let historyIndex = -1; let historyIndex = -1;
let isRoot = false; let isRoot = false;
let isLoginScreen = false;
let tabCycleIndex = -1; let tabCycleIndex = -1;
let tabMatches = []; let tabMatches = [];
let tabBaseInput = ''; let tabBaseInput = '';
let isTabCompleting = false; let isTabCompleting = false;
let inVimMode = false; const vimState = { active: false };
const allCommands = [
'ls', 'ss', 'clear', 'exit', 'rm -rf /', 'sudo su -', 'sudo -i',
'pwd', 'whoami', 'hostname', 'date', 'uname', 'uname -a', 'uptime',
'id', 'cat /etc/os-release', 'cat ~/.ssh/id_ed25519.pub',
'docker ps', 'docker ls', 'docker container ls',
'podman ps', 'podman ls', 'podman container ls',
'systemctl list-units', 'systemctl list-units --type=service --state=running --no-pager',
'ip addr show', 'df -h', 'free -h',
'ps aux', 'ps aux --sort=-%mem | head -10',
'neofetch', 'sl', 'help', 'echo', 'curl', 'wget', 'apt', 'dnf', 'vim'
];
function updateDisplay(text) { function updateDisplay(text) {
const lastLine = content.lastElementChild; const lastLine = content.lastElementChild;
@@ -93,39 +109,9 @@ function createTerminal(face) {
lastLine.appendChild(newCursor); lastLine.appendChild(newCursor);
} }
const commands = {
'pwd': '/home/reese/portfolio',
'whoami': 'reese',
'hostname': 'homelab',
'date': new Date().toString(),
'uname': 'Linux homelab 7.0.0 #1 SMP x86_64 GNU/Linux',
'uname -a': 'Linux homelab 6.8.0 #1 SMP x86_64 GNU/Linux',
'uptime': ' 14:30:00 up 42 days, 3:15, 1 user, load average: 0.42, 0.38, 0.35',
'id': 'uid=1000(reese) gid=1000(reese) groups=1000(reese),985(podman)',
'cat /etc/os-release': 'PRETTY_NAME="Fedora Linux 69 (Workstation Edition)"\nNAME="Fedora Linux"\nVERSION_ID="69"\nVERSION="69 (Workstation Edition)"\nID=fedora\nVARIANT=Workstation Edition\nVARIANT_ID=workstation\nLOGO=fedora-logo-icon\nCPE_NAME="cpe:/o:fedoralinux:fedora:69"\nDEFAULT_HOSTNAME="fedora"\nHOME_URL="https://fedoraproject.org/"\nDOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f69/system-administrators-guide/"\nSUPPORT_URL="https://ask.fedoraproject.org/"\nBUG_REPORT_URL="https://bugzilla.redhat.com/"\nREDHAT_BUGZILLA_PRODUCT="Fedora"\nREDHAT_BUGZILLA_PRODUCT_VERSION=69\nREDHAT_SUPPORT_PRODUCT="Fedora"\nREDHAT_SUPPORT_PRODUCT_VERSION=69',
'cat ~/.ssh/id_ed25519.pub': 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGpFmKLqRKzMwRe3WkqJvQrN5mHjF2pRn8sT6yUvWxRe reese@homelab',
'docker ps': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'docker ls': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'docker container ls': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'podman ps': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'podman ls': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'podman container ls': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'systemctl list-units': 'UNIT LOAD ACTIVE SUB DESCRIPTION\nborg.service loaded active running Borg Backup Service\ngitea.service loaded active running Gitea\ndocker.service loaded active running Docker Application Container Engine\nnextcloud.service loaded active running Nextcloud\nimmich.service loaded active running Immich\nopen-webui.service loaded active running Open WebUI\njellyfin.service loaded active running Jellyfin Media Server\nslopbox.service loaded active running Slopbox\nhomepage.service loaded active running Homepage Container',
'systemctl list-units --type=service --state=running --no-pager': 'UNIT LOAD ACTIVE SUB DESCRIPTION\nborg.service loaded active running Borg Backup Service\ngitea.service loaded active running Gitea\ndocker.service loaded active running Docker Application Container Engine\nnextcloud.service loaded active running Nextcloud\nimmich.service loaded active running Immich\nopen-webui.service loaded active running Open WebUI\njellyfin.service loaded active running Jellyfin Media Server\nslopbox.service loaded active running Slopbox\nhomepage.service loaded active running Homepage Container',
'ss': 'Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\ntcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:80 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:443 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:222 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3000 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3001 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3002 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8081 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8096 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:9090 0.0.0.0:*\ntcp LISTEN 0 128 [::]:22 [::]:*',
'ip addr show': '1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN\n inet 127.0.0.1/8 scope host lo\n inet6 ::1/128 scope host\n2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP\n inet 192.168.1.42/24 brd 192.168.1.255 scope global dynamic eth0\n inet6 fe80::1/64 scope link',
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda2 465G 182G 258G 42% /\nudev 16G 0 16G 0% /dev\ntmpfs 16G 2.1M 16G 1% /dev/shm\n/dev/sda1 511M 6.6M 505M 2% /boot/efi\n/dev/sdb1 1.8T 945G 793G 55% /mnt/data\noverlay 1.8T 945G 793G 55% /var/lib/docker/overlay2',
'free -h': ' total used free shared buff/cache available\nMem: 31Gi 8.2Gi 12Gi 512Mi 11Gi 22Gi\nSwap: 2.0Gi 0B 2.0Gi',
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nreese 1242 2.1 12.4 4285632 3932160 ? Sl May15 42:18 /opt/llama.cpp/build/bin/llama-server --model /models/Qwen3-30B-A3B.Q4_K_M.gguf --port 8082\ndocker 2341 1.2 6.8 8562348 2156032 ? Sl May15 28:45 /usr/bin/dockerd -H fd://\nreese 3456 0.8 3.2 2845632 1015808 ? Sl May15 18:22 /opt/open-webui/server\nreese 4567 0.5 2.1 1562348 665600 ? Sl May15 12:34 /usr/bin/python3 /opt/borg/borgmatic\nroot 5678 0.3 1.4 945632 448000 ? Ssl May15 8:12 /usr/bin/docker-proxy -p tcp:0.0.0.0:80:80\nreese 6789 0.2 1.1 745632 348000 ? Ssl May15 5:45 /usr/bin/podman run --name homepage\nroot 7890 0.1 0.8 545632 256000 ? Ssl May15 3:22 /usr/bin/docker-proxy -p tcp:0.0.0.0:443:443',
'ps aux --sort=-%mem | head -10': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nreese 1242 2.1 12.4 4285632 3932160 ? Sl May15 42:18 /opt/llama.cpp/build/bin/llama-server --model /models/Qwen3-30B-A3B.Q4_K_M.gguf --port 8082\ndocker 2341 1.2 6.8 8562348 2156032 ? Sl May15 28:45 /usr/bin/dockerd -H fd://\nreese 3456 0.8 3.2 2845632 1015808 ? Sl May15 18:22 /opt/open-webui/server\nreese 4567 0.5 2.1 1562348 665600 ? Sl May15 12:34 /usr/bin/python3 /opt/borg/borgmatic\nroot 5678 0.3 1.4 945632 448000 ? Ssl May15 8:12 /usr/bin/docker-proxy -p tcp:0.0.0.0:80:80\nreese 6789 0.2 1.1 745632 348000 ? Ssl May15 5:45 /usr/bin/podman run --name homepage\nroot 7890 0.1 0.8 545632 256000 ? Ssl May15 3:22 /usr/bin/docker-proxy -p tcp:0.0.0.0:443:443',
'neofetch': ` 'c. reese@homelab\n ,xNMM. ----------------------\n .OMMMMo OS: Fedora Linux 69 (Workstation Edition) x86_64\n OMMM0, Host: custom-build\n .;loddo:' loolloddol;. Kernel: 6.8.0\n cKMMMMMMMMMMNWMMMMMMMMMM0: Uptime: 42 days, 3 hours\n .KMMMMMMMMMMMMMMMMMMMMMMMWd. Packages: 2847 (dnf)\n XMMMMMMMMMMMMMMMMMMMMMMMX. Shell: bash 5.2.26\n;MMMMMMMMMMMMMMMMMMMMMMMM: Resolution: 2560x1440\n:MMMMMMMMMMMMMMMMMMMMMMMM: DE: GNOME 46.1\n.MMMMMMMMMMMMMMMMMMMMMMMMX. WM: Mutter\n kMMMMMMMMMMMMMMMMMMMMMMMMWd. Terminal: /dev/pts/0\n .XMMMMMMMMMMMMMMMMMMMMMMMMMMk CPU: AMD Ryzen 9 7900X (24) @ 5.6GHz\n .XMMMMMMMMMMMMMMMMMMMMMMK. GPU: NVIDIA GeForce RTX 4090\n kMMMMMMMMMMMMMMMMMMMMd GPU: AMD Ryzen Built-in\n ;KMMMMMMMWXXWMMMMMMMk. Memory: 8.2Gi / 31Gi\n .cooc,. .,coo:.\n\n<span style="color: #22c55e">███</span><span style="color: #22c55e">███</span><span style="color: #22c55e">███</span><span style="color: #eab308">███</span><span style="color: #eab308">███</span><span style="color: #eab308">███</span><span style="color: #3b82f6">███</span><span style="color: #3b82f6">███</span><span style="color: #3b82f6">███</span><span style="color: #8b5cf6">███</span><span style="color: #8b5cf6">███</span><span style="color: #8b5cf6">███</span><span style="color: #ec4899">███</span><span style="color: #ec4899">███</span><span style="color: #ec4899">███</span>`,
'sl': '____\n|DD|____T_\n|_ |_____|<\n @-@-@-oo\\',
'vim': 'vim: Enter the terminal text editor. Type :q to quit.',
'help': 'Available commands:\n ls List directory contents\n pwd Print working directory\n whoami Print current user\n hostname Print hostname\n date Print current date/time\n uname Print system information\n uptime Print system uptime\n id Print user identity\n cat Print file contents (try: cat /etc/os-release)\n docker List running containers\n podman List running pods\n systemctl List running systemd services\n ss Show listening ports\n ip Show network interfaces\n df Show disk usage\n free Show memory usage\n ps Show running processes\n vim Terminal text editor (try: vim or vim filename)\n neofetch System info display\n sl Steam locomotive\n help Show this help message',
};
terminal.addEventListener('keydown', (e) => { terminal.addEventListener('keydown', (e) => {
if (inVimMode) return; if (vimState.active) return;
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
if (historyIndex < commandHistory.length - 1) { if (historyIndex < commandHistory.length - 1) {
@@ -210,447 +196,12 @@ function createTerminal(face) {
if (cmdText === 'clear') { if (cmdText === 'clear') {
content.innerHTML = ''; content.innerHTML = '';
} }
if (cmdText.startsWith('vim')) { if (cmdText.startsWith('vim')) {
const filename = cmdText.split(' ').slice(1).join(' ') || ''; const filename = cmdText.split(' ').slice(1).join(' ') || '';
let vimBuffer = ['Welcome to vim (simulated)!']; launchVimSimulator({
let vimLineNum = 0; content, terminal, mobileInput, filename,
let scrollOffset = 0; setVimMode: (active) => { vimState.active = active; },
let vimStatus = '~'; });
let vimCol = 0;
let showSplash = false;
inVimMode = true;
// Hide the mobile input so it doesn't intercept vim keystrokes
mobileInput.style.display = 'none';
// Create a full-screen vim overlay
const vimOverlay = document.createElement('div');
vimOverlay.style.position = 'fixed';
vimOverlay.style.inset = '0';
vimOverlay.style.background = '#0a0a0c';
vimOverlay.style.fontFamily = "'SF Mono', 'Fira Code', 'Cascadia Code', monospace";
vimOverlay.style.fontSize = '0.85rem';
vimOverlay.style.lineHeight = '1.5';
vimOverlay.style.color = '#22c55e';
vimOverlay.style.display = 'flex';
vimOverlay.style.flexDirection = 'column';
vimOverlay.style.zIndex = '10000';
vimOverlay.style.padding = '12px 24px';
vimOverlay.style.overflow = 'hidden';
document.body.appendChild(vimOverlay);
const lineNums = [];
const lineTexts = [];
const vimLines = [];
const linesContainer = document.createElement('div');
linesContainer.style.flexGrow = '1';
linesContainer.style.minHeight = '0';
linesContainer.style.overflowY = 'auto';
linesContainer.style.display = 'flex';
linesContainer.style.flexDirection = 'column';
linesContainer.style.scrollbarWidth = 'none';
linesContainer.style.msOverflowStyle = 'none';
function createLineElement() {
const line = document.createElement('div');
line.style.whiteSpace = 'pre';
line.style.flexShrink = '0';
const numSpan = document.createElement('span');
numSpan.style.color = '#666';
numSpan.style.marginRight = '8px';
numSpan.style.userSelect = 'none';
numSpan.style.display = 'inline-block';
numSpan.style.width = '3ch';
const textSpan = document.createElement('span');
line.appendChild(numSpan);
line.appendChild(textSpan);
return line;
}
function createSplashLine(text) {
const line = document.createElement('div');
line.style.whiteSpace = 'pre';
line.style.flexShrink = '0';
const textSpan = document.createElement('span');
line.appendChild(textSpan);
return { line, textSpan };
}
if (!filename) {
showSplash = true;
const splashText = [
'',
' VIM - Vi sIgnificantly worse M',
'',
' version 0.0.0-alpha',
' by disappointed programmers et al.',
' Modified by https://www.reddit.com/r/vim/',
' Vim is open source and you will figure it out',
'',
' Please send help.',
'',
' type :q<Enter> to escape this regret',
' type :help<Enter> or <F1> for on-line help',
' type :help version0<Enter> for version info',
'',
];
for (const text of splashText) {
const { line, textSpan } = createSplashLine(text);
linesContainer.appendChild(line);
lineTexts.push(textSpan);
vimLines.push(line);
}
vimBuffer = splashText;
vimLineNum = 0;
vimCol = 0;
} else {
const line = createLineElement();
linesContainer.appendChild(line);
lineNums.push(line.children[0]);
lineTexts.push(line.children[1]);
vimLines.push(line);
vimBuffer = ['Welcome to ' + filename + '!'];
vimLineNum = 0;
vimCol = 0;
}
vimOverlay.appendChild(linesContainer);
// Status bar at the bottom
const statusLine = document.createElement('div');
statusLine.style.marginTop = 'auto';
statusLine.style.borderTop = '1px solid #444';
statusLine.style.paddingTop = '4px';
statusLine.style.color = '#ccc';
statusLine.style.flexShrink = '0';
vimOverlay.appendChild(statusLine);
// File info at the very bottom
const fileInfo = document.createElement('div');
fileInfo.style.color = '#888';
fileInfo.style.flexShrink = '0';
vimOverlay.appendChild(fileInfo);
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function vimRender() {
// Ensure we have enough line elements
while (vimLines.length < vimBuffer.length) {
let newLine;
if (showSplash) {
const { line, textSpan } = createSplashLine('');
newLine = line;
linesContainer.appendChild(line);
lineTexts.push(textSpan);
} else {
newLine = createLineElement();
linesContainer.appendChild(newLine);
lineNums.push(newLine.children[0]);
lineTexts.push(newLine.children[1]);
}
vimLines.push(newLine);
}
// Update all visible lines
for (let i = 0; i < vimBuffer.length; i++) {
if (showSplash) {
lineTexts[i].textContent = vimBuffer[i] || '';
} else {
lineNums[i].textContent = (i + 1);
const lineText = vimBuffer[i] || '';
if (i === vimLineNum && (vimStatus === '-- INSERT --' || vimStatus === '~')) {
const col = vimCol;
const before = lineText.slice(0, col);
const after = lineText.slice(col);
if (after.length > 0) {
const cursorChar = after.charAt(0);
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">' + escapeHtml(cursorChar) + '</span>' + escapeHtml(after.slice(1));
} else {
lineTexts[i].innerHTML = escapeHtml(before) + '<span class="vim-block-cursor">&nbsp;</span>';
}
} else {
lineTexts[i].textContent = lineText;
}
}
vimLines[i].style.display = '';
}
// Hide unused line elements
for (let i = vimBuffer.length; i < vimLines.length; i++) {
vimLines[i].style.display = 'none';
}
// Auto-scroll when line is above or below the viewport
const lineEl = vimLines[vimLineNum];
if (lineEl && lineEl.style.display !== 'none') {
const lineBottom = lineEl.offsetTop + lineEl.offsetHeight;
const lineTop = lineEl.offsetTop;
if (lineBottom > linesContainer.scrollTop + linesContainer.clientHeight) {
linesContainer.scrollTop = lineBottom - linesContainer.clientHeight;
} else if (lineTop < linesContainer.scrollTop + 12) {
linesContainer.scrollTop = lineTop - 12;
}
}
if (showSplash) {
statusLine.textContent = '';
fileInfo.textContent = '"VIM - Vi sIgnificantly worse M"';
} else {
statusLine.textContent = vimStatus;
fileInfo.textContent = '"' + filename + '"';
}
}
function vimExit(message) {
document.removeEventListener('keydown', vimHandler);
inVimMode = false;
mobileInput.style.display = '';
vimOverlay.remove();
content.innerHTML = '';
if (message) {
const msgLine = document.createElement('div');
msgLine.style.whiteSpace = 'pre-wrap';
msgLine.style.color = '#ccc';
msgLine.textContent = message;
content.appendChild(msgLine);
}
const newLine = document.createElement('div');
newLine.innerHTML = '<span class="terminal-prompt">$</span> ';
content.appendChild(newLine);
const newCursor = document.createElement('span');
newCursor.className = 'terminal-cursor';
newLine.appendChild(newCursor);
content.scrollTop = content.scrollHeight;
terminal.focus();
}
const vimHandler = (e) => {
e.preventDefault();
if (showSplash) {
if (e.key === 'q' && e.shiftKey) {
vimExit();
return;
}
if (e.key === 'Escape' || e.key === 'q') {
vimExit();
return;
}
vimRender();
return;
}
if (vimStatus.startsWith(':')) {
if (e.key === 'Enter') {
const cmd = vimStatus.slice(1).trim();
if (cmd === 'q' || cmd === 'q!') {
vimExit();
return;
}
if (cmd === 'wq' || cmd === 'x') {
vimExit('File saved (simulated).');
return;
}
if (cmd === 'w') {
vimStatus = 'File written (simulated).';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
return;
}
if (cmd.startsWith('%s/')) {
vimStatus = 'Pattern not found (simulated).';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 1000);
return;
}
if (cmd === 'help') {
vimStatus = ':q quit :w save :wq save & quit :q! force quit dd delete line i insert o open line';
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 3000);
return;
}
vimStatus = 'Unknown command: ' + cmd;
vimRender();
setTimeout(() => { vimStatus = ':'; vimRender(); }, 2000);
return;
}
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key === 'Backspace') {
vimStatus = vimStatus.slice(0, -1);
vimRender();
return;
}
if (e.key.length === 1) {
vimStatus += e.key;
vimRender();
}
return;
}
if (vimStatus === '-- INSERT --') {
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + e.key + vimBuffer[vimLineNum].slice(vimCol);
vimCol++;
vimRender();
return;
}
if (e.key === 'Enter') {
vimBuffer.splice(vimLineNum + 1, 0, '');
vimLineNum++;
vimCol = 0;
vimRender();
return;
}
if (e.key === 'Backspace') {
if (vimCol > 0) {
vimCol--;
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, vimCol) + vimBuffer[vimLineNum].slice(vimCol + 1);
}
vimRender();
return;
}
if (e.key === 'ArrowUp') {
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowDown') {
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowLeft') {
if (vimCol > 0) { vimCol--; vimRender(); }
return;
}
if (e.key === 'ArrowRight') {
const maxCol = (vimBuffer[vimLineNum] || '').length;
if (vimCol < maxCol) { vimCol++; vimRender(); }
return;
}
vimRender();
return;
}
// Normal mode (~)
if (e.key === 'Escape') {
vimStatus = '~';
vimRender();
return;
}
if (e.key === 'i') {
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'I') {
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'a') {
vimCol = Math.min(vimCol + 1, (vimBuffer[vimLineNum] || '').length);
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'A') {
vimCol = (vimBuffer[vimLineNum] || '').length;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'o') {
vimBuffer.splice(vimLineNum + 1, 0, '');
vimLineNum++;
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'O') {
vimBuffer.splice(vimLineNum, 0, '');
vimLineNum++;
vimCol = 0;
vimStatus = '-- INSERT --';
vimRender();
return;
}
if (e.key === 'x') {
if (vimBuffer[vimLineNum]) {
vimBuffer[vimLineNum] = vimBuffer[vimLineNum].slice(0, -1);
}
vimRender();
return;
}
if (e.key === 'd' && e.altKey) {
if (vimLineNum < vimBuffer.length - 1) {
vimBuffer.splice(vimLineNum, 1);
if (vimLineNum >= vimBuffer.length) vimLineNum = vimBuffer.length - 1;
}
vimRender();
return;
}
if (e.key === 'd' && e.shiftKey) {
if (vimLineNum < vimBuffer.length - 1) {
vimBuffer.splice(vimLineNum, 1);
if (vimLineNum >= vimBuffer.length) vimLineNum = vimBuffer.length - 1;
}
vimRender();
return;
}
if (e.key === 'ArrowUp') {
if (vimLineNum > 0) { vimLineNum--; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowDown') {
if (vimLineNum < vimBuffer.length - 1) { vimLineNum++; vimCol = Math.min(vimCol, (vimBuffer[vimLineNum] || '').length); vimRender(); }
return;
}
if (e.key === 'ArrowLeft') {
if (vimCol > 0) { vimCol--; vimRender(); }
return;
}
if (e.key === 'ArrowRight') {
const maxCol = (vimBuffer[vimLineNum] || '').length;
if (vimCol < maxCol) { vimCol++; vimRender(); }
return;
}
if (e.key === 'G') {
vimLineNum = vimBuffer.length - 1;
vimRender();
return;
}
if (e.key === 'g') {
vimLineNum = 0;
vimRender();
return;
}
if (e.key === ':') {
vimStatus = ':';
vimRender();
return;
}
vimRender();
};
document.addEventListener('keydown', vimHandler);
const newAchievements = checkAchievements(cmdText, isRoot); const newAchievements = checkAchievements(cmdText, isRoot);
if (newAchievements.length > 0) { if (newAchievements.length > 0) {
@@ -661,6 +212,7 @@ function createTerminal(face) {
return; return;
} }
if (cmdText === 'exit') { if (cmdText === 'exit') {
if (!isRoot) { if (!isRoot) {
const newAchievements = checkAchievements(cmdText, false); const newAchievements = checkAchievements(cmdText, false);
@@ -669,7 +221,6 @@ function createTerminal(face) {
newAchievements.forEach(a => showToast(a)); newAchievements.forEach(a => showToast(a));
} }
isLoginScreen = true;
document.body.innerHTML = ''; document.body.innerHTML = '';
document.body.style.background = '#000'; document.body.style.background = '#000';
document.body.style.color = '#fff'; document.body.style.color = '#fff';
@@ -823,35 +374,6 @@ function createTerminal(face) {
return; return;
} }
const commands = {
'pwd': '/home/reese/portfolio',
'whoami': 'reese',
'hostname': 'homelab',
'date': new Date().toString(),
'uname': 'Linux homelab 7.0.0 #1 SMP x86_64 GNU/Linux',
'uname -a': 'Linux homelab 6.8.0 #1 SMP x86_64 GNU/Linux',
'uptime': ' 14:30:00 up 42 days, 3:15, 1 user, load average: 0.42, 0.38, 0.35',
'id': 'uid=1000(reese) gid=1000(reese) groups=1000(reese),985(podman)',
'cat /etc/os-release': 'PRETTY_NAME="Fedora Linux 69 (Workstation Edition)"\nNAME="Fedora Linux"\nVERSION_ID="69"\nVERSION="69 (Workstation Edition)"\nID=fedora\nVARIANT=Workstation Edition\nVARIANT_ID=workstation\nLOGO=fedora-logo-icon\nCPE_NAME="cpe:/o:fedoralinux:fedora:69"\nDEFAULT_HOSTNAME="fedora"\nHOME_URL="https://fedoraproject.org/"\nDOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f69/system-administrators-guide/"\nSUPPORT_URL="https://ask.fedoraproject.org/"\nBUG_REPORT_URL="https://bugzilla.redhat.com/"\nREDHAT_BUGZILLA_PRODUCT="Fedora"\nREDHAT_BUGZILLA_PRODUCT_VERSION=69\nREDHAT_SUPPORT_PRODUCT="Fedora"\nREDHAT_SUPPORT_PRODUCT_VERSION=69',
'cat ~/.ssh/id_ed25519.pub': 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGpFmKLqRKzMwRe3WkqJvQrN5mHjF2pRn8sT6yUvWxRe reese@homelab',
'docker ps': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'docker ls': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'docker container ls': 'NAMES STATUS PORTS\nborg Up 42 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp\ngitea Up 42 days 0.0.0.0:222->22/tcp, 0.0.0.0:3000->3000/tcp\nnextcloud Up 42 days 0.0.0.0:8080->80/tcp\nimmich Up 42 days 0.0.0.0:3001->3000/tcp, 0.0.0.0:3002->3001/tcp\nopen-webui Up 42 days 0.0.0.0:8081->8080/tcp\njellyfin Up 42 days 0.0.0.0:8096->8096/tcp\nslopbox Up 42 days 0.0.0.0:9090->80/tcp',
'podman ps': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'podman ls': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'podman container ls': 'NAMES STATUS\nhomepage Up 5 hours\nllama Up 42 days',
'systemctl list-units': 'UNIT LOAD ACTIVE SUB DESCRIPTION\nborg.service loaded active running Borg Backup Service\ngitea.service loaded active running Gitea\ndocker.service loaded active running Docker Application Container Engine\nnextcloud.service loaded active running Nextcloud\nimmich.service loaded active running Immich\nopen-webui.service loaded active running Open WebUI\njellyfin.service loaded active running Jellyfin Media Server\nslopbox.service loaded active running Slopbox\nhomepage.service loaded active running Homepage Container',
'systemctl list-units --type=service --state=running --no-pager': 'UNIT LOAD ACTIVE SUB DESCRIPTION\nborg.service loaded active running Borg Backup Service\ngitea.service loaded active running Gitea\ndocker.service loaded active running Docker Application Container Engine\nnextcloud.service loaded active running Nextcloud\nimmich.service loaded active running Immich\nopen-webui.service loaded active running Open WebUI\njellyfin.service loaded active running Jellyfin Media Server\nslopbox.service loaded active running Slopbox\nhomepage.service loaded active running Homepage Container',
'ss': 'Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port\ntcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:80 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:443 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:222 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3000 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3001 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:3002 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8081 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:8096 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:9090 0.0.0.0:*\ntcp LISTEN 0 128 [::]:22 [::]:*',
'ip addr show': '1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN\n inet 127.0.0.1/8 scope host lo\n inet6 ::1/128 scope host\n2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP\n inet 192.168.1.42/24 brd 192.168.1.255 scope global dynamic eth0\n inet6 fe80::1/64 scope link',
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda2 465G 182G 258G 42% /\nudev 16G 0 16G 0% /dev\ntmpfs 16G 2.1M 16G 1% /dev/shm\n/dev/sda1 511M 6.6M 505M 2% /boot/efi\n/dev/sdb1 1.8T 945G 793G 55% /mnt/data\noverlay 1.8T 945G 793G 55% /var/lib/docker/overlay2',
'free -h': ' total used free shared buff/cache available\nMem: 31Gi 8.2Gi 12Gi 512Mi 11Gi 22Gi\nSwap: 2.0Gi 0B 2.0Gi',
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nreese 1242 2.1 12.4 4285632 3932160 ? Sl May15 42:18 /opt/llama.cpp/build/bin/llama-server --model /models/Qwen3-30B-A3B.Q4_K_M.gguf --port 8082\ndocker 2341 1.2 6.8 8562348 2156032 ? Sl May15 28:45 /usr/bin/dockerd -H fd://\nreese 3456 0.8 3.2 2845632 1015808 ? Sl May15 18:22 /opt/open-webui/server\nreese 4567 0.5 2.1 1562348 665600 ? Sl May15 12:34 /usr/bin/python3 /opt/borg/borgmatic\nroot 5678 0.3 1.4 945632 448000 ? Ssl May15 8:12 /usr/bin/docker-proxy -p tcp:0.0.0.0:80:80\nreese 6789 0.2 1.1 745632 348000 ? Ssl May15 5:45 /usr/bin/podman run --name homepage\nroot 7890 0.1 0.8 545632 256000 ? Ssl May15 3:22 /usr/bin/docker-proxy -p tcp:0.0.0.0:443:443',
'ps aux --sort=-%mem | head -10': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nreese 1242 2.1 12.4 4285632 3932160 ? Sl May15 42:18 /opt/llama.cpp/build/bin/llama-server --model /models/Qwen3-30B-A3B.Q4_K_M.gguf --port 8082\ndocker 2341 1.2 6.8 8562348 2156032 ? Sl May15 28:45 /usr/bin/dockerd -H fd://\nreese 3456 0.8 3.2 2845632 1015808 ? Sl May15 18:22 /opt/open-webui/server\nreese 4567 0.5 2.1 1562348 665600 ? Sl May15 12:34 /usr/bin/python3 /opt/borg/borgmatic\nroot 5678 0.3 1.4 945632 448000 ? Ssl May15 8:12 /usr/bin/docker-proxy -p tcp:0.0.0.0:80:80\nreese 6789 0.2 1.1 745632 348000 ? Ssl May15 5:45 /usr/bin/podman run --name homepage\nroot 7890 0.1 0.8 545632 256000 ? Ssl May15 3:22 /usr/bin/docker-proxy -p tcp:0.0.0.0:443:443',
'neofetch': ` 'c. reese@homelab\n ,xNMM. ----------------------\n .OMMMMo OS: Fedora Linux 69 (Workstation Edition) x86_64\n OMMM0, Host: custom-build\n .;loddo:' loolloddol;. Kernel: 6.8.0\n cKMMMMMMMMMMNWMMMMMMMMMM0: Uptime: 42 days, 3 hours\n .KMMMMMMMMMMMMMMMMMMMMMMMWd. Packages: 2847 (dnf)\n XMMMMMMMMMMMMMMMMMMMMMMMX. Shell: bash 5.2.26\n;MMMMMMMMMMMMMMMMMMMMMMMM: Resolution: 2560x1440\n:MMMMMMMMMMMMMMMMMMMMMMMM: DE: GNOME 46.1\n.MMMMMMMMMMMMMMMMMMMMMMMMX. WM: Mutter\n kMMMMMMMMMMMMMMMMMMMMMMMMWd. Terminal: /dev/pts/0\n .XMMMMMMMMMMMMMMMMMMMMMMMMMMk CPU: AMD Ryzen 9 7900X (24) @ 5.6GHz\n .XMMMMMMMMMMMMMMMMMMMMMMK. GPU: NVIDIA GeForce RTX 4090\n kMMMMMMMMMMMMMMMMMMMMd GPU: AMD Ryzen Built-in\n ;KMMMMMMMWXXWMMMMMMMk. Memory: 8.2Gi / 31Gi\n .cooc,. .,coo:.\n\n<span style="color: #22c55e">███</span><span style="color: #22c55e">███</span><span style="color: #22c55e">███</span><span style="color: #eab308">███</span><span style="color: #eab308">███</span><span style="color: #eab308">███</span><span style="color: #3b82f6">███</span><span style="color: #3b82f6">███</span><span style="color: #3b82f6">███</span><span style="color: #8b5cf6">███</span><span style="color: #8b5cf6">███</span><span style="color: #8b5cf6">███</span><span style="color: #ec4899">███</span><span style="color: #ec4899">███</span><span style="color: #ec4899">███</span>`,
'sl': '____\n|DD|____T_\n|_ |_____|<\n @-@-@-oo\\',
'help': 'Available commands:\n ls List directory contents\n pwd Print working directory\n whoami Print current user\n hostname Print hostname\n date Print current date/time\n uname Print system information\n uptime Print system uptime\n id Print user identity\n cat Print file contents\n docker List running containers\n podman List running pods\n systemctl List running systemd services\n ss Show listening ports\n ip Show network interfaces\n df Show disk usage\n free Show memory usage\n ps Show running processes\n apt Package manager (update, install)\n curl Fetch a webpage\n wget Fetch a webpage\n vim Terminal text editor (try: vim or vim filename)\n neofetch System info display\n sl Steam locomotive\n clear Clear the terminal\n exit Exit to login screen\n sudo Gain root access\n rm Remove files\n echo Print text\n help Show this help message',
};
if (cmdText.startsWith('echo ')) { if (cmdText.startsWith('echo ')) {
const output = cmdText.replace(/^echo\s+/, ''); const output = cmdText.replace(/^echo\s+/, '');
@@ -877,7 +399,7 @@ function createTerminal(face) {
outLine.textContent = 'curl: error fetching ' + url + ': ' + err.message; outLine.textContent = 'curl: error fetching ' + url + ': ' + err.message;
content.scrollTop = content.scrollHeight; content.scrollTop = content.scrollHeight;
}); });
} else if (cmdText.startsWith('wget ') || cmdText.startsWith('wget ')) { } else if (cmdText.startsWith('wget ')) {
const url = cmdText.replace(/^wget\s+(?:-O\s+\S+\s+)?/, ''); const url = cmdText.replace(/^wget\s+(?:-O\s+\S+\s+)?/, '');
const outLine = document.createElement('div'); const outLine = document.createElement('div');
outLine.style.whiteSpace = 'pre-wrap'; outLine.style.whiteSpace = 'pre-wrap';
@@ -976,8 +498,9 @@ function createTerminal(face) {
} else { } else {
outLine.textContent = 'Usage: dnf <command>\nCommands:\n update Update packages\n install Install packages'; outLine.textContent = 'Usage: dnf <command>\nCommands:\n update Update packages\n install Install packages';
} }
} else if (commands[cmdText] !== undefined) { } else {
const output = commands[cmdText]; const output = getCommandOutput(cmdText);
if (output !== undefined) {
const outLine = document.createElement('div'); const outLine = document.createElement('div');
outLine.style.whiteSpace = 'pre-wrap'; outLine.style.whiteSpace = 'pre-wrap';
if (cmdText === 'neofetch') { if (cmdText === 'neofetch') {
@@ -988,6 +511,7 @@ function createTerminal(face) {
} }
content.appendChild(outLine); content.appendChild(outLine);
} }
}
const newAchievements = checkAchievements(cmdText, isRoot); const newAchievements = checkAchievements(cmdText, isRoot);
if (newAchievements.length > 0) { if (newAchievements.length > 0) {
@@ -1044,7 +568,7 @@ function createTerminal(face) {
isTabCompleting = true; isTabCompleting = true;
const matchSource = tabBaseInput || inputVal; const matchSource = tabBaseInput || inputVal;
const matches = allCommands.filter(cmd => cmd.startsWith(matchSource)); const matches = TERMINAL_COMMAND_LIST.filter(cmd => cmd.startsWith(matchSource));
if (matches.length === 0) { if (matches.length === 0) {
isTabCompleting = false; isTabCompleting = false;
@@ -1076,52 +600,24 @@ function createTerminal(face) {
}); });
terminal.addEventListener('focus', () => { terminal.addEventListener('focus', () => {
mobileInput.focus(); expandTerminal(terminal);
terminal.style.boxShadow = '0 0 8px rgba(234, 179, 8, 0.3), inset 0 0 20px rgba(34, 197, 94, 0.1)';
terminal.style.borderColor = '#eab308';
terminal.classList.add('grown');
terminal.classList.add('opaque');
const face = terminal.closest('.server-face');
const unit = face?.closest('.rack-unit');
face?.classList.add('grown');
unit?.classList.add('grown');
document.getElementById('hero').classList.add('shifted');
document.querySelector('.rack-container')?.classList.add('opaque');
}); });
terminal.addEventListener('blur', () => { terminal.addEventListener('blur', () => {
if (heroMouseDown) return; if (heroMouseDown) return;
terminal.style.boxShadow = ''; collapseTerminal(terminal);
terminal.style.borderColor = '#2a2a2e';
terminal.classList.remove('grown');
terminal.classList.remove('opaque');
const face = terminal.closest('.server-face');
const unit = face?.closest('.rack-unit');
face?.classList.remove('grown');
unit?.classList.remove('grown');
document.getElementById('hero').classList.remove('shifted');
document.querySelector('.rack-container')?.classList.remove('opaque');
}); });
mobileInput.addEventListener('keydown', (e) => { mobileInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (inVimMode) return; if (vimState.active) return;
mobileInput.blur(); mobileInput.blur();
} }
}); });
mobileInput.addEventListener('blur', () => { mobileInput.addEventListener('blur', () => {
if (heroMouseDown) return; if (heroMouseDown) return;
terminal.style.boxShadow = ''; collapseTerminal(terminal);
terminal.style.borderColor = '#2a2a2e';
terminal.classList.remove('grown');
terminal.classList.remove('opaque');
const face = terminal.closest('.server-face');
const unit = face?.closest('.rack-unit');
face?.classList.remove('grown');
unit?.classList.remove('grown');
document.getElementById('hero').classList.remove('shifted');
document.querySelector('.rack-container')?.classList.remove('opaque');
}); });
let lastInputValue = ''; let lastInputValue = '';
@@ -1153,200 +649,5 @@ function createTerminal(face) {
} }
// Achievement System // Restore previously unlocked achievements (terminal-achievements.js).
const ACHIEVEMENTS_STORAGE_KEY = 'reese-terminal-achievements'; initTerminalAchievements();
const ACHIEVEMENTS = {
time_flies: { name: 'Time Flies', desc: 'Check the system uptime', icon: '⏱️', cmd: 'uptime' },
curious: { name: 'Curious', desc: 'List directory contents', icon: '📁', cmd: 'ls', prefix: true },
port_scanner: { name: 'Port Scanner', desc: 'Check listening ports', icon: '🔍', cmd: 'ss', prefix: true },
password_reset: { name: 'Password Reset Request', desc: 'Submit a SNOW request', icon: '🚪', cmd: 'exit' },
power_user: { name: 'Power User', desc: 'Gain root access', icon: '👑', cmd: 'sudo su -' },
nice_try: { name: 'Nice Try', desc: 'Attempt rm -rf / as a normal user', icon: '😏', cmd: 'rm -rf /' },
restore_backup: { name: 'Restore from Backup', desc: 'Actually destroy the system as root', icon: '💥', cmd: 'rm -rf / root' },
where_am_i: { name: 'Where Am I?', desc: 'Print working directory', icon: '📍', cmd: 'pwd' },
self_aware: { name: 'Self-Aware', desc: 'Print current user', icon: '🪞', cmd: 'whoami' },
identity: { name: 'Identity Crisis', desc: 'Print hostname', icon: '🖥️', cmd: 'hostname' },
time_keeper: { name: 'Time Keeper', desc: 'Print current date', icon: '📅', cmd: 'date' },
system_explorer: { name: 'System Explorer', desc: 'Print system information', icon: '🔧', cmd: 'uname' },
access_granted: { name: 'Access Granted', desc: 'Check user identity', icon: '🆔', cmd: 'id' },
os_detective: { name: 'OS Detective', desc: 'Read the OS release file', icon: '🐧', cmd: 'cat /etc/os-release' },
ssh_explorer: { name: 'SSH Explorer', desc: 'View SSH public key', icon: '🔑', cmd: 'cat ~/.ssh/id_ed25519.pub' },
container_watcher: { name: 'Container Watcher', desc: 'List Docker containers', icon: '🐳', cmd: 'docker ps', prefix: true },
docker_confused: { name: 'Docker Confused', desc: 'Use wrong Docker command', icon: '🤔', cmd: 'docker ls', prefix: true },
container_pro: { name: 'Container Pro', desc: 'Use correct container command', icon: '✅', cmd: 'docker container ls', prefix: true },
podman_fan: { name: 'Podman Fan', desc: 'List Podman containers', icon: '📦', cmd: 'podman ps', prefix: true },
podman_confused: { name: 'Podman Confused', desc: 'Use wrong Podman command', icon: '🤔', cmd: 'podman ls', prefix: true },
podman_pro: { name: 'Podman Pro', desc: 'Use correct Podman command', icon: '✅', cmd: 'podman container ls', prefix: true },
service_hunter: { name: 'Service Hunter', desc: 'List systemd units', icon: '🔎', cmd: 'systemctl list-units', prefix: true },
service_filter: { name: 'Service Filter', desc: 'Filter running services', icon: '🎯', cmd: 'systemctl list-units --type=service --state=running --no-pager' },
network_ninja: { name: 'Network Ninja', desc: 'Show network interfaces', icon: '🌐', cmd: 'ip addr show', prefix: true },
disk_detective: { name: 'Disk Detective', desc: 'Check disk usage', icon: '💾', cmd: 'df -h' },
memory_minded: { name: 'Memory Minded', desc: 'Check memory usage', icon: '🧠', cmd: 'free -h' },
process_watcher: { name: 'Process Watcher', desc: 'List running processes', icon: '👁️', cmd: 'ps aux', prefix: true },
memory_hog: { name: 'Memory Hog', desc: 'Find top memory consumers', icon: '🐗', cmd: 'ps aux --sort=-%mem | head -10' },
aesthetic_mode: { name: 'Aesthetic Mode', desc: 'Display system info with style', icon: '✨', cmd: 'neofetch' },
help_seeker: { name: 'Help Seeker', desc: 'Look up available commands', icon: '📖', cmd: 'help' },
clean_slate: { name: 'Clean Slate', desc: 'Clear the terminal', icon: '🧹', cmd: 'clear' },
train_spotter: { name: 'Train Spotter', desc: 'Run the steam locomotive', icon: '🚂', cmd: 'sl' },
tain: { name: 'I like trains', desc: 'Choo chooooooo', icon: '🧹', cmd: 'sl' },
web_navigator: { name: 'Web Navigator', desc: 'Fetch a webpage using curl or wget', icon: '🌐', cmd: 'curl', prefix: true },
package_manager: { name: 'Package Manager', desc: 'Update package lists with apt', icon: '📦', cmd: 'apt update' },
software_installer: { name: 'Software Installer', desc: 'Install a package with apt', icon: '🔧', cmd: 'apt install', prefix: true },
fedora_updater: { name: 'Fedora Updater', desc: 'Update packages with dnf', icon: '🎯', cmd: 'dnf update' },
fedora_installer: { name: 'Fedora Installer', desc: 'Install a package with dnf', icon: '📀', cmd: 'dnf install', prefix: true },
vim_splash: { name: 'Vi sIgnificantly worse M', desc: 'Open vim without a filename', icon: '📝', cmd: 'vim' },
vim_edit: { name: 'Actually Editing', desc: 'Open vim with a filename', icon: '✏️', cmd: 'vim ', prefix: true },
};
// Load unlocked achievement keys from localStorage
function loadAchievements() {
try {
const saved = localStorage.getItem(ACHIEVEMENTS_STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
// Persist unlocked achievement keys to localStorage
function saveAchievements(achieved) {
localStorage.setItem(ACHIEVEMENTS_STORAGE_KEY, JSON.stringify(achieved));
}
// Check if the given command triggers any new achievements; returns unlocked achievement objects
function checkAchievements(cmdText, isRoot) {
const achieved = loadAchievements();
const newAchievements = [];
for (const [key, achievement] of Object.entries(ACHIEVEMENTS)) {
if (achieved.includes(key)) continue;
if (key === 'restore_backup' && cmdText === 'rm -rf /' && isRoot) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'power_user' && (cmdText === 'sudo su -' || cmdText === 'sudo -i')) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'password_reset' && cmdText === 'exit' && !isRoot) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'nice_try' && cmdText === 'rm -rf /' && !isRoot) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'system_explorer' && (cmdText === 'uname' || cmdText === 'uname -a')) {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'docker_confused' && cmdText === 'docker ls') {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'podman_confused' && cmdText === 'podman ls') {
achieved.push(key);
newAchievements.push(achievement);
} else if (key === 'web_navigator' && (cmdText.startsWith('curl ') || cmdText.startsWith('wget '))) {
achieved.push(key);
newAchievements.push(achievement);
} else if (achievement.prefix ? cmdText.startsWith(achievement.cmd) : achievement.cmd === cmdText) {
if ((key === 'nice_try' && isRoot) || (key === 'restore_backup' && !isRoot)) {
continue;
}
achieved.push(key);
newAchievements.push(achievement);
}
}
if (newAchievements.length > 0) {
saveAchievements(achieved);
}
return newAchievements;
}
// Display a toast notification for a newly unlocked achievement
function showToast(achievement) {
const container = document.getElementById('toast-container') || createToastContainer();
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div class="toast-icon">${achievement.icon}</div>
<div class="toast-content">
<div class="toast-label">Achievement Unlocked</div>
<div class="toast-title">${achievement.name}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('removing');
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Create the toast notification container element if it doesn't exist
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container';
document.body.appendChild(container);
return container;
}
// Reveal the hidden achievements section and add it to the nav if needed
function revealAchievements() {
const section = document.getElementById('achievements');
if (section.style.display === 'none' || !section.style.display) {
section.style.display = 'block';
section.classList.add('fade-in');
setTimeout(() => section.classList.add('visible'), 50);
addAchievementsNav();
}
renderAchievements();
}
// Render all achievement cards (unlocked and locked) into the achievements grid
function renderAchievements() {
const achieved = loadAchievements();
const total = Object.keys(ACHIEVEMENTS).length;
const count = achieved.length;
const countEl = document.getElementById('achievements-count');
countEl.textContent = `${count} / ${total} achievements unlocked`;
const grid = document.getElementById('achievements-grid');
grid.innerHTML = '';
for (const [key, achievement] of Object.entries(ACHIEVEMENTS)) {
const isUnlocked = achieved.includes(key);
const card = document.createElement('div');
card.className = `achievement-card ${isUnlocked ? 'unlocked' : 'locked'}`;
card.innerHTML = `
<div class="achievement-icon">${achievement.icon}</div>
<div class="achievement-info">
<h3>${isUnlocked ? achievement.name : '???'}</h3>
<p>${isUnlocked ? achievement.desc : 'Keep exploring the terminal...'}</p>
</div>
`;
grid.appendChild(card);
}
}
// Add an "Achievements" link to the main navigation if achievements have been unlocked
function addAchievementsNav() {
const navLinks = document.getElementById('navLinks');
const existing = document.getElementById('nav-achievements');
if (!existing && loadAchievements().length > 0) {
const li = document.createElement('li');
li.id = 'nav-achievements';
li.innerHTML = '<a href="#achievements">Achievements</a>';
navLinks.appendChild(li);
}
}
// Restore previously unlocked achievements on page load
(function initAchievements() {
const achieved = loadAchievements();
if (achieved.length > 0) {
revealAchievements();
addAchievementsNav();
}
})();
+77
View File
@@ -0,0 +1,77 @@
// @ts-check
const { test, expect } = require('@playwright/test');
test.describe('Page structure', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('loads with correct title and lang', async ({ page }) => {
await expect(page).toHaveTitle(/Reese Wells/);
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
test('nav has all seven section links pointing at existing targets', async ({ page }) => {
const links = page.locator('#navLinks a');
await expect(links).toHaveCount(7);
const hrefs = await links.evaluateAll(as => as.map(a => a.getAttribute('href')));
expect(hrefs).toEqual(['#hero', '#about', '#experience', '#skills', '#projects', '#contact', '#gpg']);
for (const href of hrefs) {
await expect(page.locator(href)).toBeAttached();
}
});
test('all major sections exist and are in the DOM', async ({ page }) => {
for (const id of ['hero', 'about', 'experience', 'skills', 'projects', 'contact', 'gpg']) {
await expect(page.locator(`#${id}`)).toBeAttached();
}
});
test('profile image loads', async ({ page }) => {
const img = page.locator('#about img');
await expect(img).toBeVisible();
await expect.poll(async () =>
page.evaluate(() => document.querySelector('#about img').naturalWidth)
).toBeGreaterThan(0);
});
test('server rack background is generated with a terminal unit', async ({ page }) => {
const rack = page.locator('.server-rack-bg .rack-container');
await expect(rack).toBeAttached();
await expect(rack.locator('.rack-unit')).not.toHaveCount(0);
await expect(page.locator('.terminal-display')).toBeAttached();
});
test('GPG section shows both fingerprints and key blocks', async ({ page }) => {
await expect(page.locator('#gpg .gpg-key-card')).toHaveCount(2);
await expect(page.locator('#gpg .gpg-key-fingerprint code').first()).toHaveText(
'7FC1 B297 0011 4F4F C589 E706 5FDD CFA5 44D7 7B8C');
await expect(page.locator('#gpg .gpg-key-fingerprint code').last()).toHaveText(
'2FF3 619F A6CA 2A4C FA2D 3532 816E 5FE7 8271 602B');
const blocks = page.locator('#gpg .gpg-key-block');
await expect(blocks).toHaveCount(2);
for (const block of [blocks.first(), blocks.last()]) {
await expect(block).toContainText('BEGIN PGP PUBLIC KEY BLOCK');
await expect(block).toContainText('END PGP PUBLIC KEY BLOCK');
}
});
test('external links all carry rel="noopener"', async ({ page }) => {
const links = page.locator('a[target="_blank"]');
const count = await links.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(links.nth(i)).toHaveAttribute('rel', /noopener/);
}
});
test('hashed assets are referenced and served', async ({ page }) => {
const html = await page.content();
expect(html).toMatch(/<link rel="stylesheet" href="style\.[0-9a-f]{32}\.css"/);
expect(html).toMatch(/<script src="terminal\.[0-9a-f]{32}\.js" defer="?">/);
expect(html).toMatch(/<script src="script\.[0-9a-f]{32}\.js" defer="?">/);
const resp = await page.request.get(page.url().replace(/\/$/, '') + '/' +
(await page.locator('link[rel="stylesheet"]').getAttribute('href')));
expect(resp.ok()).toBeTruthy();
});
});
+235
View File
@@ -0,0 +1,235 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const { openTerminal, typeCommand, currentPromptLine, terminalLines } = require('./helpers');
test.describe('Fake terminal — basic commands', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await openTerminal(page);
});
test('hero click expands the terminal and shifts the hero', async ({ page }) => {
await expect(page.locator('.terminal-display')).toHaveClass(/grown/);
await expect(page.locator('.terminal-display')).toHaveClass(/opaque/);
await expect(page.locator('#hero')).toHaveClass(/shifted/);
});
test('initial boot lines are rendered', async ({ page }) => {
const lines = terminalLines(page);
await expect(lines.first()).toHaveText(/uptime/);
await expect(lines.nth(1)).toHaveText('optional');
});
test('uptime shows the load-average line', async ({ page }) => {
await typeCommand(page, 'uptime');
await expect(terminalLines(page).nth(-2)).toContainText('up 42 days');
});
test('ls lists the portfolio files', async ({ page }) => {
await typeCommand(page, 'ls');
const out = await terminalLines(page).nth(-2).textContent();
expect(out).toContain('index.html');
expect(out).toContain('nginx.conf');
expect(out).toContain('profile.jpeg');
});
test('pwd / whoami / hostname / id', async ({ page }) => {
await typeCommand(page, 'pwd');
await expect(terminalLines(page).nth(-2)).toHaveText(/\/home\/reese\/portfolio/);
await typeCommand(page, 'whoami');
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
await typeCommand(page, 'hostname');
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
await typeCommand(page, 'id');
await expect(terminalLines(page).nth(-2)).toContainText('uid=1000(reese)');
});
test('date shows a plausible current date', async ({ page }) => {
await typeCommand(page, 'date');
const out = await terminalLines(page).nth(-2).textContent();
expect(out).toMatch(/\b(19|20)\d{2}\b/);
});
test('uname and cat /etc/os-release', async ({ page }) => {
await typeCommand(page, 'uname -a');
await expect(terminalLines(page).nth(-2)).toContainText('Linux homelab');
await typeCommand(page, 'cat /etc/os-release');
const out = await terminalLines(page).nth(-2).textContent();
expect(out).toContain('Fedora Linux');
expect(out).toContain('VERSION_ID');
});
test('echo prints its argument as plain text', async ({ page }) => {
await typeCommand(page, 'echo hello world');
await expect(terminalLines(page).nth(-2)).toHaveText('hello world');
});
test('ss shows listening ports', async ({ page }) => {
await typeCommand(page, 'ss');
const out = await terminalLines(page).nth(-2).textContent();
expect(out).toContain('0.0.0.0:22');
expect(out).toContain('0.0.0.0:443');
});
test('docker ps and podman ps', async ({ page }) => {
await typeCommand(page, 'docker ps');
const docker = await terminalLines(page).nth(-2).textContent();
expect(docker).toContain('gitea');
expect(docker).toContain('Up 42 days');
await typeCommand(page, 'podman ps');
const podman = await terminalLines(page).nth(-2).textContent();
expect(podman).toContain('homepage');
expect(podman).toContain('llama');
});
test('ps aux and df -h and free -h', async ({ page }) => {
await typeCommand(page, 'ps aux');
expect(await terminalLines(page).nth(-2).textContent()).toContain('llama-server');
await typeCommand(page, 'df -h');
expect(await terminalLines(page).nth(-2).textContent()).toContain('Filesystem');
await typeCommand(page, 'free -h');
expect(await terminalLines(page).nth(-2).textContent()).toContain('Mem:');
});
test('neofetch renders the ASCII logo and colored blocks', async ({ page }) => {
await typeCommand(page, 'neofetch');
const line = terminalLines(page).nth(-2);
await expect(line).toContainText('reese@homelab');
await expect(line).toContainText('RTX 4090');
await expect(line.locator('span[style*="color"]')).not.toHaveCount(0);
});
test('sl prints the locomotive', async ({ page }) => {
await typeCommand(page, 'sl');
expect(await terminalLines(page).nth(-2).textContent()).toContain('DD');
});
test('help lists the available commands', async ({ page }) => {
await typeCommand(page, 'help');
const out = await terminalLines(page).nth(-2).textContent();
expect(out).toContain('Available commands');
expect(out).toContain('vim');
expect(out).toContain('sudo');
});
test('systemctl, ip addr, cat ssh key', async ({ page }) => {
await typeCommand(page, 'systemctl list-units');
expect(await terminalLines(page).nth(-2).textContent()).toContain('gitea.service');
await typeCommand(page, 'ip addr show');
expect(await terminalLines(page).nth(-2).textContent()).toContain('eth0');
await typeCommand(page, 'cat ~/.ssh/id_ed25519.pub');
expect(await terminalLines(page).nth(-2).textContent()).toContain('ssh-ed25519');
});
test('unknown command produces no output and a fresh prompt', async ({ page }) => {
const before = await terminalLines(page).count();
await typeCommand(page, 'frobnicate --lots');
const after = await terminalLines(page).count();
expect(after).toBe(before + 1); // only the new prompt line
await expect(currentPromptLine(page)).toContainText(/[\$#]/);
});
});
test.describe('Fake terminal — state and special commands', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await openTerminal(page);
});
test('history: ArrowUp/ArrowDown recall previous commands', async ({ page }) => {
await typeCommand(page, 'whoami');
await typeCommand(page, 'hostname');
await page.keyboard.press('ArrowUp');
await expect(currentPromptLine(page)).toContainText('hostname');
await page.keyboard.press('ArrowUp');
await expect(currentPromptLine(page)).toContainText('whoami');
await page.keyboard.press('ArrowDown');
await expect(currentPromptLine(page)).toContainText('hostname');
// executing the recalled command still works
await page.keyboard.press('Enter');
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
});
test('Tab completes a command', async ({ page }) => {
await page.keyboard.type('host');
await page.keyboard.press('Tab');
await expect(currentPromptLine(page)).toContainText('hostname');
await page.keyboard.press('Enter');
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
});
test('Tab cycles between multiple matches', async ({ page }) => {
// strip the leading prompt ("$ "/"# ") from the line text
const typed = async () =>
(await currentPromptLine(page).textContent()).replace(/^[#$] /, '').trim();
await page.keyboard.type('cat ');
await page.keyboard.press('Tab');
const first = await typed();
await page.keyboard.press('Tab');
const second = await typed();
expect(first).not.toEqual(second);
expect(first).toMatch(/^cat /);
expect(second).toMatch(/^cat /);
});
test('clear empties the terminal and leaves one prompt line', async ({ page }) => {
await typeCommand(page, 'uptime');
expect(await terminalLines(page).count()).toBeGreaterThan(3);
await typeCommand(page, 'clear');
await expect(terminalLines(page)).toHaveCount(1);
await expect(currentPromptLine(page)).toContainText(/[\$#]/);
});
test('sudo su - switches the prompt to root (#)', async ({ page }) => {
await typeCommand(page, 'sudo su -');
const out = await terminalLines(page).nth(-2).textContent();
expect(out).toContain("I hope you know what you're doing");
const prompt = currentPromptLine(page).locator('.terminal-prompt');
await expect(prompt).toHaveText('#');
// root styling: the cursor is the red one, the line is red too
await expect(currentPromptLine(page).locator('.terminal-cursor')).toHaveClass(/red/);
await typeCommand(page, 'whoami');
// whoami still answers "reese" (the fake does not change identity)
await expect(terminalLines(page).nth(-2)).toHaveText('reese');
});
test('rm -rf / as a normal user is refused', async ({ page }) => {
await typeCommand(page, 'rm -rf /');
const out = await terminalLines(page).nth(-2).textContent();
expect(out).toContain('nice try');
// page must survive
await expect(page.locator('#hero')).toBeVisible();
});
test('rm -rf / as root triggers the destruction easter egg', async ({ page }) => {
await typeCommand(page, 'sudo su -');
await typeCommand(page, 'rm -rf /');
await expect(terminalLines(page).nth(-2)).toContainText('System destruction initiated');
await page.waitForTimeout(700); // destruction setTimeout is 500ms
await expect.poll(async () =>
page.evaluate(() => document.body.children.length)
).toBe(0);
});
test('exit as a normal user swaps the page for a login screen', async ({ page }) => {
await typeCommand(page, 'exit');
await expect(page.locator('body')).toContainText('homelab tty1');
await expect(page.locator('body')).toContainText('homelab login:');
const login = page.locator('body input');
await expect(login).toBeVisible();
await login.fill('reese');
await login.press('Enter');
await expect(page.locator('body')).toContainText('Access denied');
// a fresh login line with a new input appears
await expect(page.locator('body input')).toHaveCount(1);
});
test('backspace edits the current command', async ({ page }) => {
await page.keyboard.type('hostnamex');
await page.keyboard.press('Backspace');
await expect(currentPromptLine(page)).toContainText('hostname');
await page.keyboard.press('Enter');
await expect(terminalLines(page).nth(-2)).toHaveText('homelab');
});
});
+77
View File
@@ -0,0 +1,77 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const { openTerminal, typeCommand } = require('./helpers');
// The vim simulator renders into a fixed full-screen overlay appended to
// <body>; text assertions below run against the whole body and pass/fail
// on the overlay's presence or absence.
test.describe('Fake terminal — vim simulator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await openTerminal(page);
});
test('vim with no filename shows the splash and q exits', async ({ page }) => {
await typeCommand(page, 'vim');
await expect(page.locator('body')).toContainText('VIM - Vi sIgnificantly worse M');
await expect(page.locator('body')).toContainText('version 0.0.0-alpha');
await page.keyboard.press('q');
await expect(page.locator('body')).not.toContainText('VIM - Vi sIgnificantly worse M');
// terminal is usable again
await typeCommand(page, 'hostname');
await expect(page.locator('.terminal-content > div').nth(-2)).toHaveText('homelab');
});
test('vim with a filename opens a buffer; insert, save & quit', async ({ page }) => {
await typeCommand(page, 'vim notes.txt');
await expect(page.locator('body')).toContainText('Welcome to notes.txt!');
await expect(page.locator('body')).toContainText('"notes.txt"');
// insert mode, type text at column 0
await page.keyboard.press('i');
await expect(page.locator('body')).toContainText('-- INSERT --');
await page.keyboard.type('hello');
await expect(page.locator('body')).toContainText('helloWelcome to notes.txt!');
// back to normal mode
await page.keyboard.press('Escape');
await expect(page.locator('body')).not.toContainText('-- INSERT --');
// :wq saves (simulated) and exits
await page.keyboard.type(':wq');
await page.keyboard.press('Enter');
await expect(page.locator('body')).not.toContainText('Welcome to notes.txt!');
await expect(page.locator('.terminal-content')).toContainText('File saved (simulated).');
});
test('vim normal-mode navigation and line deletion', async ({ page }) => {
await typeCommand(page, 'vim file.md');
await expect(page.locator('body')).toContainText('Welcome to file.md!');
// open a new line below (o), type, escape
await page.keyboard.press('o');
await expect(page.locator('body')).toContainText('-- INSERT --');
await page.keyboard.type('line two');
await page.keyboard.press('Escape');
// both lines present
await expect(page.locator('body')).toContainText('Welcome to file.md!');
await expect(page.locator('body')).toContainText('line two');
// G jumps to the last line, dd deletes it (Shift+D in this sim).
// Use innerText: deleted lines are hidden (display:none) but remain
// in the DOM, so textContent would still contain them.
await page.keyboard.press('G');
await page.keyboard.press('Shift+D');
await expect.poll(async () =>
page.evaluate(() => document.body.innerText)
).not.toContain('line two');
await expect(page.locator('body')).toContainText('Welcome to file.md!');
// :q! force-quits
await page.keyboard.type(':q!');
await page.keyboard.press('Enter');
await expect(page.locator('body')).not.toContainText('Welcome to file.md!');
});
});
+91
View File
@@ -0,0 +1,91 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const { openTerminal, typeCommand } = require('./helpers');
const STORAGE_KEY = 'reese-terminal-achievements';
test.describe('Achievements system', () => {
test('starts hidden until the first unlock', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#achievements')).toBeHidden();
await expect(page.locator('#nav-achievements')).toHaveCount(0);
});
test('uptime unlock shows a toast, persists to localStorage, reveals the section', async ({ page }) => {
await page.goto('/');
await openTerminal(page);
await typeCommand(page, 'uptime');
// toast with the achievement name
const toast = page.locator('.toast').filter({ hasText: 'Time Flies' });
await expect(toast).toBeVisible();
await expect(toast).toContainText('Achievement Unlocked');
await expect(page.locator('#toast-container .toast')).toHaveCount(1);
// persisted
const stored = await page.evaluate(k => JSON.parse(localStorage.getItem(k) || '[]'), STORAGE_KEY);
expect(stored).toContain('time_flies');
// section revealed + counter + nav link
await expect(page.locator('#achievements')).toBeVisible();
await expect(page.locator('#achievements-count')).toHaveText(/1 \/ \d+ achievements unlocked/);
await expect(page.locator('#nav-achievements a')).toHaveAttribute('href', '#achievements');
// the unlocked card shows its name; locked ones stay mysterious
const cards = page.locator('#achievements-grid .achievement-card');
const unlocked = page.locator('#achievements-grid .achievement-card.unlocked');
await expect(unlocked).toHaveCount(1);
await expect(unlocked).toContainText('Time Flies');
expect(await cards.count()).toBeGreaterThan(1);
await expect(page.locator('#achievements-grid .achievement-card.locked')).not.toHaveCount(0);
});
test('unlocks survive a page reload (localStorage restore)', async ({ page }) => {
await page.goto('/');
await openTerminal(page);
await typeCommand(page, 'uptime');
await expect(page.locator('#achievements')).toBeVisible();
await page.reload();
await expect(page.locator('#achievements')).toBeVisible();
await expect(page.locator('#achievements-count')).toHaveText(/1 \/ \d+ achievements unlocked/);
await expect(page.locator('#nav-achievements a')).toHaveAttribute('href', '#achievements');
});
test('special achievements: sudo, nice-try rm, vim splash', async ({ page }) => {
await page.goto('/');
await openTerminal(page);
await typeCommand(page, 'sudo su -');
await expect(page.locator('.toast').filter({ hasText: 'Power User' })).toBeVisible();
await typeCommand(page, 'rm -rf /');
// root variant: the destruction easter egg wipes the page (and its
// toasts) ~500ms later — verify the unlock via localStorage instead
await page.reload();
await openTerminal(page);
await typeCommand(page, 'rm -rf /');
await expect(page.locator('.toast').filter({ hasText: 'Nice Try' })).toBeVisible();
await typeCommand(page, 'vim');
await expect(page.locator('.toast').filter({ hasText: 'Vi sIgnificantly worse M' })).toBeVisible();
await page.keyboard.press('q');
const stored = await page.evaluate(k => JSON.parse(localStorage.getItem(k) || '[]'), STORAGE_KEY);
expect(stored).toEqual(
expect.arrayContaining(['power_user', 'restore_backup', 'nice_try', 'vim_splash']));
});
test('multiple unlocks stack toasts and increment the counter', async ({ page }) => {
await page.goto('/');
await openTerminal(page);
await typeCommand(page, 'uptime');
await typeCommand(page, 'whoami');
await typeCommand(page, 'clear');
const stored = await page.evaluate(k => JSON.parse(localStorage.getItem(k) || '[]'), STORAGE_KEY);
expect(stored).toEqual(
expect.arrayContaining(['time_flies', 'self_aware', 'clean_slate']));
await expect(page.locator('#achievements-count')).toHaveText(/3 \/ \d+ achievements unlocked/);
});
});
+42
View File
@@ -0,0 +1,42 @@
// @ts-check
const { test, expect } = require('@playwright/test');
test.describe('Navigation menu', () => {
test('desktop: inline nav visible, hamburger hidden', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#hamburger')).toBeHidden();
await expect(page.locator('#navLinks')).toBeVisible();
await expect(page.locator('#navLinks a')).toHaveCount(7);
});
test('mobile: hamburger opens and closes the menu', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 375, height: 812 });
await expect(page.locator('#hamburger')).toBeVisible();
const links = page.locator('#navLinks');
await expect(links).toBeHidden();
await page.locator('#hamburger').click();
await expect(page.locator('#hamburger')).toHaveClass(/active/);
await expect(links).toHaveClass(/active/);
await expect(links).toBeVisible();
// clicking a link closes the menu
await links.locator('a[href="#about"]').click();
await expect(links).not.toHaveClass(/active/);
});
test('clicking outside closes an open mobile menu', async ({ page }) => {
await page.goto('/');
await page.setViewportSize({ width: 375, height: 812 });
await page.locator('#hamburger').click();
await expect(page.locator('#navLinks')).toHaveClass(/active/);
// click a point below the open dropdown (y≈700): outside both the
// nav and the hamburger, so the document-level click handler closes it
await page.mouse.click(187, 700);
await expect(page.locator('#navLinks')).not.toHaveClass(/active/);
});
});
+30
View File
@@ -0,0 +1,30 @@
// Shared helpers for the fake-terminal E2E tests.
//
// Desktop interaction model (see src/script.js + src/terminal.js):
// the hero section overlays the rack background, so the terminal is opened
// by clicking the hero (outside of any .btn). That focuses the hidden
// <input> inside .terminal-display, and all keystrokes go there.
/** Click the hero to expand the terminal and focus its hidden input. */
async function openTerminal(page) {
await page.locator('#hero h1').click();
await page.waitForSelector('.terminal-display.grown', { timeout: 5000 });
}
/** Type a command into the focused terminal and press Enter. */
async function typeCommand(page, cmd) {
await page.keyboard.type(cmd);
await page.keyboard.press('Enter');
}
/** The current (last) line of the terminal output. */
function currentPromptLine(page) {
return page.locator('.terminal-content > div').last();
}
/** All rendered terminal lines. */
function terminalLines(page) {
return page.locator('.terminal-content > div');
}
module.exports = { openTerminal, typeCommand, currentPromptLine, terminalLines };