diff --git a/app/Http/Controllers/Cli/LogController.php b/app/Http/Controllers/Cli/LogController.php new file mode 100644 index 000000000..3178140b8 --- /dev/null +++ b/app/Http/Controllers/Cli/LogController.php @@ -0,0 +1,51 @@ +check()) { + $user = auth()->user(); + assert($user instanceof User); + + if (TerminalRenderer::wantsCli($request)) { + $limit = min(max($request->integer('limit', 20), 1), 50); + $commits = Log::Following->query()->builder($user)->take($limit)->get(); + $color = TerminalRenderer::isColorSupported($request); + $force16 = TerminalRenderer::is16Color($request); + $content = $this->renderer->feed($commits, $color, $force16, title: 'Log: following'); + + return response($content)->header('Content-Type', 'text/plain; charset=utf-8'); + } + + return to_route('log.show', ['log' => Log::Following]); + } + + if (TerminalRenderer::wantsCli($request)) { + $color = TerminalRenderer::isColorSupported($request); + + return response($this->renderer->landing($color))->header('Content-Type', 'text/plain; charset=utf-8'); + } + + return to_route('home'); + } +} diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index c99fed9a9..c442f6e7d 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -13,17 +13,67 @@ use App\Models\Commit; use App\Models\User; use App\Queries\CommitAncestorsQuery; +use App\Queries\CommitBuilder; use App\Queries\CommitRepliesQuery; +use App\Services\Terminal\TerminalRenderer; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Response as HttpResponse; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; final readonly class CommitController { - public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUser, User $user, Commit $commit, CommitAncestorsQuery $ancestors, CommitRepliesQuery $replies): RedirectResponse|Response + public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUser, User $user, Commit $commit, CommitAncestorsQuery $ancestors, CommitRepliesQuery $replies, TerminalRenderer $renderer): RedirectResponse|Response|HttpResponse { + if (TerminalRenderer::wantsCli($request)) { + $color = TerminalRenderer::isColorSupported($request); + $force16 = TerminalRenderer::is16Color($request); + + $commit->loadCount(['replies' => fn (CommitBuilder $query): CommitBuilder => $query->approved()]) + ->loadCount('stars') + ->load('user'); + + if (! $currentUser instanceof User) { + return response($renderer->commitPreview($commit, $color, $force16)) + ->header('Content-Type', 'text/plain; charset=utf-8'); + } + + /** @var Collection $ancestorCollection */ + $ancestorCollection = new Collection(); + $current = $commit; + + while ($current->parent_id !== null) { + $parent = Commit::query() + ->approved() + ->with('user') + ->withCount('stars') + ->withCount(['replies' => fn (CommitBuilder $query): CommitBuilder => $query->approved()]) + ->find($current->parent_id); + + if (! $parent instanceof Commit) { + break; + } + + $ancestorCollection->prepend($parent); + $current = $parent; + } + + $repliesList = $commit->replies() + ->approved() + ->with('user') + ->withCount('stars') + ->withCount(['replies' => fn (CommitBuilder $query): CommitBuilder => $query->approved()]) + ->latest() + ->get(); + + $content = $renderer->thread($commit, $ancestorCollection, $repliesList, $color, $force16); + + return response($content)->header('Content-Type', 'text/plain; charset=utf-8'); + } + if (! $currentUser instanceof User) { return $this->preview($user, $commit); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index c55c203eb..2d723cba6 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -6,16 +6,28 @@ use App\Enums\Log; use App\Models\User; +use App\Services\Terminal\TerminalRenderer; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response as HttpResponse; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response; final readonly class HomeController { - public function show(Request $request): RedirectResponse|Response + public function show(Request $request, TerminalRenderer $renderer): RedirectResponse|Response|HttpResponse { + if (TerminalRenderer::wantsCli($request)) { + $color = TerminalRenderer::isColorSupported($request); + + if (! Auth::check()) { + return response($renderer->landing($color))->header('Content-Type', 'text/plain; charset=utf-8'); + } + + return redirect()->intended(route('log.show', Log::Following, absolute: false)); + } + if (! app()->isLocal() || ! config('app.auto_login')) { return Inertia::render('welcome'); } diff --git a/app/Http/Controllers/LogController.php b/app/Http/Controllers/LogController.php index 436f05852..8893430b6 100644 --- a/app/Http/Controllers/LogController.php +++ b/app/Http/Controllers/LogController.php @@ -9,15 +9,32 @@ use App\Models\Commit; use App\Models\User; use App\Queries\SuggestedCommitsQuery; +use App\Services\Terminal\TerminalRenderer; use Illuminate\Container\Attributes\CurrentUser; +use Illuminate\Http\Response as HttpResponse; use Illuminate\Support\Collection; use Inertia\Inertia; use Inertia\Response; final readonly class LogController { - public function show(ShowLogRequest $request, #[CurrentUser] User $user, Log $log, SuggestedCommitsQuery $suggested): Response + public function show(ShowLogRequest $request, #[CurrentUser] User $user, Log $log, SuggestedCommitsQuery $suggested, TerminalRenderer $renderer): Response|HttpResponse { + if (TerminalRenderer::wantsCli($request)) { + $limit = min(max($request->integer('limit', 20), 1), 50); + + $commits = $log->query() + ->builder($user) + ->take($limit) + ->get(); + + $color = TerminalRenderer::isColorSupported($request); + $force16 = TerminalRenderer::is16Color($request); + $content = $renderer->feed($commits, $color, $force16, title: "Log: {$log->value}"); + + return response($content)->header('Content-Type', 'text/plain; charset=utf-8'); + } + return Inertia::render('log/show', [ 'log' => $log, 'commits' => Inertia::scroll(fn () => $log->query() diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 5b37fb0ed..4c4b9a73e 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -12,17 +12,46 @@ use App\Queries\CommitBuilder; use App\Queries\UserActivityQuery; use App\Queries\UserStreakQuery; +use App\Services\Terminal\TerminalRenderer; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Response as HttpResponse; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response; final readonly class UserController { - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks): RedirectResponse|Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, TerminalRenderer $renderer): RedirectResponse|Response|HttpResponse { + if (TerminalRenderer::wantsCli($request)) { + $color = TerminalRenderer::isColorSupported($request); + $force16 = TerminalRenderer::is16Color($request); + + if (! $currentUser instanceof User) { + return response($renderer->profilePreview($user, $color, $force16)) + ->header('Content-Type', 'text/plain; charset=utf-8'); + } + + $limit = min(max($request->integer('limit', 20), 1), 50); + + $commits = $user->commits() + ->visibleTo($currentUser) + ->topLevel() + ->with('user') + ->withCount(['replies' => fn (CommitBuilder $query): CommitBuilder => $query->approved()]) + ->withCount('stars') + ->latest() + ->latest('id') + ->take($limit) + ->get(); + + $content = $renderer->profile($user, $commits, $color, $force16); + + return response($content)->header('Content-Type', 'text/plain; charset=utf-8'); + } + if (! $currentUser instanceof User) { return $this->preview($request, $user); } diff --git a/app/Services/Terminal/TerminalRenderer.php b/app/Services/Terminal/TerminalRenderer.php new file mode 100644 index 000000000..9f8859861 --- /dev/null +++ b/app/Services/Terminal/TerminalRenderer.php @@ -0,0 +1,391 @@ + + */ + public const array LANES = [ + 0 => [217, 72, 15], // Orange (#d9480f) + 1 => [47, 158, 68], // Green (#2f9e44) + 2 => [156, 54, 181], // Purple (#9c36b5) + 3 => [12, 133, 153], // Cyan (#0c8599) + 4 => [194, 37, 92], // Pink (#c2255c) + 5 => [183, 121, 31], // Yellow (#b7791f) + ]; + + /** + * Standard 16-color ANSI fallbacks for terminal clients without TrueColor support. + * + * @var array + */ + public const array LANES_16 = [ + 0 => "\033[33m", // Yellow / Orange + 1 => "\033[32m", // Green + 2 => "\033[35m", // Magenta + 3 => "\033[36m", // Cyan + 4 => "\033[31m", // Red / Pink + 5 => "\033[33m", // Yellow + ]; + + /** + * Determines if the request is from a terminal/CLI client. + */ + public static function wantsCli(Request $request): bool + { + if ($request->hasHeader('X-Inertia') || $request->wantsJson()) { + return false; + } + + if ($request->boolean('cli') || in_array($request->query('format'), ['cli', 'text'], true)) { + return true; + } + + $userAgent = (string) $request->userAgent(); + + if ( + str_starts_with($userAgent, 'curl/') + || str_starts_with($userAgent, 'Wget/') + || str_starts_with($userAgent, 'HTTPie/') + ) { + return true; + } + + return $request->accepts('text/plain') && ! $request->accepts('text/html'); + } + + /** + * Determines whether ANSI colors should be emitted, respecting the NO_COLOR standard. + */ + public static function isColorSupported(Request $request): bool + { + return ! ($request->hasHeader('No-Color') || $request->has('no-color') || $request->query('color') === '0'); + } + + /** + * Determines whether standard 16-color ANSI should be used instead of 24-bit TrueColor. + */ + public static function is16Color(Request $request): bool + { + return $request->query('color') === '16' + || $request->header('X-Color-Depth') === '16' + || $request->header('X-Color-Depth') === '4'; + } + + /** + * Sanitizes user-controlled text to prevent terminal display manipulation / escape sequence injection. + */ + public static function sanitize(string $text): string + { + // Strip ANSI escape sequences: ESC [ ... or ESC ] ... or ESC + $text = (string) preg_replace('#\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])#', '', $text); + + // Strip C0 control characters except newline (\n) and tab (\t) + return (string) preg_replace('/[\x00-\x08\x0B-\x1F\x7F]/u', '', $text); + } + + /** + * Renders the ASCII art header banner. + */ + public function banner(bool $color = true): string + { + $cyan = $color ? "\033[38;2;12;133;153m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + + $lines = [ + "{$cyan} _ _ _ _ {$reset}", + "{$cyan}__| | _____ __ _ __ | | | ___ ___| |__ {$reset}", + "{$cyan}/ _` |/ _ \ \ / / | '_ \ | | | / __/ __| '_ \ {$reset}", + "{$cyan}| (_| | __/\ V / | | | || | |_\__ \__ \ | | |{$reset}", + "{$cyan}\__,_|\___| \_/ |_| |_| \__(_)___/___/_| |_|{$reset}", + '', + "{$dim}A social network for developers. Pipe your thoughts to /dev/null.{$reset}", + '', + ]; + + return implode("\n", $lines); + } + + /** + * Renders the terminal landing page for unauthenticated visitors (matching web's welcome page). + */ + public function landing(bool $color = true): string + { + $bold = $color ? "\033[1m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + $baseUrl = url('/'); + + return implode("\n", [ + $this->banner($color), + " {$bold}A social network for developers.{$reset}", + '', + ' Sign in with GitHub, follow people who write code, and commit', + ' plain text to a shared log, 280 characters at a time.', + '', + " {$dim}".str_repeat('─', 60)."{$reset}", + " {$dim}Sign in:{$reset} {$baseUrl}/login", + " {$dim}Commit: {$reset} curl -fsSL {$baseUrl}/commit | bash -s -- 'Your message'", + '', + ]); + } + + /** + * Renders a list of commits as an ASCII/ANSI Git graph log. + * + * @param iterable $commits + */ + public function feed(iterable $commits, bool $color = true, bool $force16 = false, ?string $title = null): string + { + $output = [$this->banner($color)]; + + if ($title !== null) { + $dim = $color ? "\033[90m" : ''; + $bold = $color ? "\033[1m" : ''; + $reset = $color ? "\033[0m" : ''; + + $output[] = "{$bold}{$title}{$reset}"; + $output[] = "{$dim}".str_repeat('─', 60)."{$reset}"; + $output[] = ''; + } + + $commitsList = is_array($commits) ? $commits : iterator_to_array($commits); + $total = count($commitsList); + + if ($total === 0) { + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + $output[] = " {$dim}Nobody has committed anything yet. Be the first.{$reset}"; + $output[] = ''; + } else { + foreach ($commitsList as $index => $commit) { + $isLast = $index === $total - 1; + $output[] = $this->commitCard($commit, $color, $force16, ! $isLast); + } + } + + $output[] = $this->footer($color); + + return implode("\n", $output)."\n"; + } + + /** + * Renders a user's terminal profile card and recent commits. + * + * @param iterable $commits + */ + public function profile(User $user, iterable $commits, bool $color = true, bool $force16 = false): string + { + $lane = $this->laneColor($user->lane(), $color, $force16); + $bold = $color ? "\033[1m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + + $name = $user->name ? ' ('.self::sanitize($user->name).')' : ''; + $username = self::sanitize($user->username); + $bio = $user->bio ? "\n {$dim}".self::sanitize($user->bio)."{$reset}" : ''; + + $user->loadPublicCounts(); + + $stats = "{$user->commits_count} commits · {$user->followers_count} followers"; + + $header = [ + $this->banner($color), + " {$lane}@{$username}{$reset}{$bold}{$name}{$reset}", + " {$dim}{$stats}{$reset}{$bio}", + " {$dim}".str_repeat('─', 60)."{$reset}", + '', + ]; + + $commitsList = is_array($commits) ? $commits : iterator_to_array($commits); + $total = count($commitsList); + + if ($total === 0) { + $header[] = " {$dim}@{$username} has not committed anything yet.{$reset}"; + $header[] = ''; + } else { + foreach ($commitsList as $index => $commit) { + $isLast = $index === $total - 1; + $header[] = $this->commitCard($commit, $color, $force16, ! $isLast); + } + } + + $header[] = $this->footer($color); + + return implode("\n", $header)."\n"; + } + + /** + * Renders the public profile preview for guests, prompting for GitHub sign-in to read commits. + */ + public function profilePreview(User $user, bool $color = true, bool $force16 = false): string + { + $lane = $this->laneColor($user->lane(), $color, $force16); + $bold = $color ? "\033[1m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + $baseUrl = url('/'); + + $user->loadPublicCounts(); + + $name = $user->name ? ' ('.self::sanitize($user->name).')' : ''; + $username = self::sanitize($user->username); + $bio = $user->bio ? "\n {$dim}".self::sanitize($user->bio)."{$reset}" : ''; + $stats = "{$user->commits_count} commits · {$user->followers_count} followers"; + + return implode("\n", [ + $this->banner($color), + " {$lane}@{$username}{$reset}{$bold}{$name}{$reset}", + " {$dim}{$stats}{$reset}{$bio}", + " {$dim}".str_repeat('─', 60)."{$reset}", + '', + " {$dim}Sign in with GitHub to read @{$username}'s commits on /dev/null:{$reset}", + " {$bold}{$baseUrl}/login{$reset}", + '', + $this->footer($color), + ])."\n"; + } + + /** + * Renders a single commit with its ancestors and replies thread. + * + * @param iterable $ancestors + * @param iterable $replies + */ + public function thread(Commit $commit, iterable $ancestors, iterable $replies, bool $color = true, bool $force16 = false): string + { + $output = [$this->banner($color)]; + + $ancestorList = is_array($ancestors) ? $ancestors : iterator_to_array($ancestors); + $repliesList = is_array($replies) ? $replies : iterator_to_array($replies); + + // Ancestors... + foreach ($ancestorList as $ancestor) { + $output[] = $this->commitCard($ancestor, $color, $force16, true); + } + + // Focused commit... + $output[] = $this->commitCard($commit, $color, $force16, count($repliesList) > 0, isFocused: true); + + // Replies... + $totalReplies = count($repliesList); + foreach ($repliesList as $index => $reply) { + $isLast = $index === $totalReplies - 1; + $output[] = $this->commitCard($reply, $color, $force16, ! $isLast, isReply: true); + } + + $output[] = $this->footer($color); + + return implode("\n", $output)."\n"; + } + + /** + * Renders a public commit preview for guests. + */ + public function commitPreview(Commit $commit, bool $color = true, bool $force16 = false): string + { + $dim = $color ? "\033[90m" : ''; + $bold = $color ? "\033[1m" : ''; + $reset = $color ? "\033[0m" : ''; + $baseUrl = url('/'); + + return implode("\n", [ + $this->banner($color), + $this->commitCard($commit, $color, $force16, false), + " {$dim}Sign in with GitHub to join the conversation and read replies:{$reset}", + " {$bold}{$baseUrl}/login{$reset}", + '', + $this->footer($color), + ])."\n"; + } + + /** + * Renders an individual commit node with ASCII rail. + */ + public function commitCard(Commit $commit, bool $color = true, bool $force16 = false, bool $hasFollowing = true, bool $isFocused = false, bool $isReply = false): string + { + $lane = $this->laneColor($commit->user->lane(), $color, $force16); + $bold = $color ? "\033[1m" : ''; + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + + $hash = Str::substr($commit->id, 0, 7); + $time = $commit->created_at->diffForHumans(short: true); + $stars = $commit->stars_count > 0 ? " · {$commit->stars_count} ★" : ''; + $replies = $commit->replies_count > 0 ? " · {$commit->replies_count} ⤿" : ''; + + $railStem = $hasFollowing ? "{$dim}│{$reset}" : ' '; + $nodeSymbol = $isReply ? '├─' : '*'; + + if ($isFocused) { + $hashText = "{$bold}{$hash}{$reset} {$dim}(HEAD){$reset}"; + } else { + $hashText = "{$bold}{$hash}{$reset}"; + } + + $username = self::sanitize($commit->user->username); + $headerLine = " {$lane}{$nodeSymbol}{$reset} {$hashText} {$lane}@{$username}{$reset} {$dim}· {$time}{$stars}{$replies}{$reset}"; + + $body = self::sanitize($commit->body); + $bodyLines = explode("\n", wordwrap($body, 70, "\n", true)); + $renderedBody = []; + + foreach ($bodyLines as $line) { + $renderedBody[] = " {$railStem} {$line}"; + } + + if ($hasFollowing) { + $renderedBody[] = " {$railStem}"; + } else { + $renderedBody[] = ''; + } + + return $headerLine."\n".implode("\n", $renderedBody); + } + + /** + * Renders the terminal quick-command footer. + */ + public function footer(bool $color = true): string + { + $dim = $color ? "\033[90m" : ''; + $reset = $color ? "\033[0m" : ''; + $baseUrl = url('/'); + + return implode("\n", [ + " {$dim}".str_repeat('─', 60)."{$reset}", + " {$dim}Commit:{$reset} curl -fsSL {$baseUrl}/commit | bash -s -- 'Your message'", + " {$dim}Log: {$reset} curl -s {$baseUrl}/log", + ]); + } + + /** + * Resolves the ANSI escape sequence for a user lane (24-bit TrueColor or 16-color ANSI). + */ + public function laneColor(int $laneIndex, bool $color, bool $force16 = false): string + { + if (! $color) { + return ''; + } + + if ($force16) { + return self::LANES_16[$laneIndex] ?? self::LANES_16[0]; + } + + $rgb = self::LANES[$laneIndex] ?? self::LANES[0]; + + return sprintf("\033[38;2;%d;%d;%dm", $rgb[0], $rgb[1], $rgb[2]); + } +} diff --git a/routes/web.php b/routes/web.php index 10f7261d3..18fb701b8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Controllers\BookmarkController; +use App\Http\Controllers\Cli\LogController as CliLogController; use App\Http\Controllers\Cli\ScriptController; use App\Http\Controllers\CommitApprovalController; use App\Http\Controllers\CommitController; @@ -34,6 +35,9 @@ // Commit from the terminal: curl -fsSL https://dev-null.sh/commit | bash -s -- 'Hello world!' Route::get('commit', [ScriptController::class, 'show'])->name('cli-script.show'); +// Read from the terminal: curl -s https://dev-null.sh/log +Route::get('log', [CliLogController::class, 'index'])->name('cli-log.index'); + Route::middleware('guest')->group(function (): void { Route::get('/', [HomeController::class, 'show'])->name('home'); diff --git a/tests/Feature/Controllers/Cli/LogControllerTest.php b/tests/Feature/Controllers/Cli/LogControllerTest.php new file mode 100644 index 000000000..9ec92421a --- /dev/null +++ b/tests/Feature/Controllers/Cli/LogControllerTest.php @@ -0,0 +1,199 @@ +withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get('/'); + + $response->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + + expect($response->getContent()) + ->toContain('/dev/null') + ->toContain('A social network for developers.') + ->toContain('/login') + ->toContain('curl -fsSL'); + + $logResponse = $this->withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get(route('cli-log.index')); + + $logResponse->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + + expect($logResponse->getContent()) + ->toContain('/dev/null') + ->toContain('Sign in:'); +}); + +it('serves the following feed for authenticated users via CLI on /log', function (): void { + $user = User::factory()->create(['username' => 'auth_dev']); + $friend = User::factory()->create(['username' => 'nuno']); + + $user->following()->attach($friend); + + $commit = Commit::factory()->for($friend)->create([ + 'body' => 'Terminal commits landed.', + 'status' => CommitStatus::Approved, + ]); + + $response = $this->actingAs($user) + ->withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get(route('cli-log.index')); + + $response->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + + expect($response->getContent()) + ->toContain('/dev/null') + ->toContain('@nuno') + ->toContain('Terminal commits landed.') + ->toContain(mb_substr($commit->id, 0, 7)); +}); + +it('redirects browser requests on /log to home for guests', function (): void { + $response = $this->withServerVariables([ + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml', + ])->get(route('cli-log.index')); + + $response->assertRedirect(route('home')); +}); + +it('redirects browser requests on /log to following feed for authenticated users', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->withServerVariables([ + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml', + ])->get(route('cli-log.index')); + + $response->assertRedirect(route('log.show', 'following')); +}); + +it('serves public profile preview for guests when curl hits @username', function (): void { + $user = User::factory()->create([ + 'username' => 'taylor', + 'name' => 'Taylor Otwell', + 'bio' => 'Building Laravel.', + ]); + + $response = $this->withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get(route('user.show', $user)); + + $response->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + + expect($response->getContent()) + ->toContain('@taylor') + ->toContain('(Taylor Otwell)') + ->toContain('Building Laravel.') + ->toContain("Sign in with GitHub to read @taylor's commits") + ->toContain('/login'); +}); + +it('serves full user profile and commits when authenticated user curls @username', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create([ + 'username' => 'taylor', + 'name' => 'Taylor Otwell', + ]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Pushed commit to devnull.', + 'status' => CommitStatus::Approved, + ]); + + $response = $this->actingAs($viewer) + ->withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get(route('user.show', $user)); + + $response->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + + expect($response->getContent()) + ->toContain('@taylor') + ->toContain('Pushed commit to devnull.'); +}); + +it('serves commit preview for guests and full thread for authenticated users', function (): void { + $author = User::factory()->create(['username' => 'nuno']); + + $parent = Commit::factory()->for($author)->create([ + 'body' => 'Parent thread.', + 'status' => CommitStatus::Approved, + ]); + + $reply = Commit::factory()->for($author)->create([ + 'parent_id' => $parent->id, + 'body' => 'Child reply.', + 'status' => CommitStatus::Approved, + ]); + + // Guest gets preview + $guestResponse = $this->withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get(route('commit.show', [$author, $parent])); + + $guestResponse->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + + expect($guestResponse->getContent()) + ->toContain('Parent thread.') + ->toContain('Sign in with GitHub to join the conversation') + ->toContain('/login') + ->not->toContain('Child reply.'); + + // Authenticated user gets full thread + $viewer = User::factory()->create(); + $authResponse = $this->actingAs($viewer) + ->withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get(route('commit.show', [$author, $reply])); + + $authResponse->assertOk() + ->assertHeader('Content-Type', 'text/plain; charset=utf-8'); + + expect($authResponse->getContent()) + ->toContain('Parent thread.') + ->toContain('Child reply.') + ->toContain('(HEAD)'); +}); + +it('respects NO_COLOR on terminal endpoints', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->withServerVariables([ + 'HTTP_USER_AGENT' => 'curl/8.7.1', + 'HTTP_NO_COLOR' => '1', + ])->get(route('cli-log.index')); + + $response->assertOk(); + + expect($response->getContent()) + ->toContain('/dev/null') + ->not->toContain("\033["); +}); + +it('respects limit query parameter on CLI log', function (): void { + $user = User::factory()->create(); + $friend = User::factory()->create(); + $user->following()->attach($friend); + + Commit::factory()->count(10)->for($friend)->create([ + 'status' => CommitStatus::Approved, + ]); + + $response = $this->actingAs($user) + ->withServerVariables(['HTTP_USER_AGENT' => 'curl/8.7.1']) + ->get(route('cli-log.index', ['limit' => 3])); + + $response->assertOk(); + + $content = $response->getContent(); + expect(mb_substr_count($content, '@'.$friend->username))->toBe(3); +}); diff --git a/tests/Unit/Services/Terminal/TerminalRendererTest.php b/tests/Unit/Services/Terminal/TerminalRendererTest.php new file mode 100644 index 000000000..af103680a --- /dev/null +++ b/tests/Unit/Services/Terminal/TerminalRendererTest.php @@ -0,0 +1,301 @@ + 'curl/8.7.1']); + expect(TerminalRenderer::wantsCli($request))->toBeTrue(); + + // Wget + $request = Request::create('/', server: ['HTTP_USER_AGENT' => 'Wget/1.21.3']); + expect(TerminalRenderer::wantsCli($request))->toBeTrue(); + + // HTTPie + $request = Request::create('/', server: ['HTTP_USER_AGENT' => 'HTTPie/3.2.2']); + expect(TerminalRenderer::wantsCli($request))->toBeTrue(); + + // ?cli=1 + $request = Request::create('/?cli=1'); + expect(TerminalRenderer::wantsCli($request))->toBeTrue(); + + // ?format=cli + $request = Request::create('/?format=cli'); + expect(TerminalRenderer::wantsCli($request))->toBeTrue(); + + // Accept: text/plain + $request = Request::create('/', server: ['HTTP_ACCEPT' => 'text/plain']); + expect(TerminalRenderer::wantsCli($request))->toBeTrue(); + + // Browser + $request = Request::create('/', server: [ + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml', + ]); + expect(TerminalRenderer::wantsCli($request))->toBeFalse(); + + // Inertia header + $request = Request::create('/', server: [ + 'HTTP_USER_AGENT' => 'curl/8.7.1', + 'HTTP_X_INERTIA' => 'true', + ]); + expect(TerminalRenderer::wantsCli($request))->toBeFalse(); +}); + +it('respects the NO_COLOR specification', function (): void { + $request = Request::create('/'); + expect(TerminalRenderer::isColorSupported($request))->toBeTrue(); + + $request = Request::create('/', server: ['HTTP_NO_COLOR' => '1']); + expect(TerminalRenderer::isColorSupported($request))->toBeFalse(); + + $request = Request::create('/?no-color=1'); + expect(TerminalRenderer::isColorSupported($request))->toBeFalse(); + + $request = Request::create('/?color=0'); + expect(TerminalRenderer::isColorSupported($request))->toBeFalse(); +}); + +it('detects 16-color ANSI mode', function (): void { + $request = Request::create('/?color=16'); + expect(TerminalRenderer::is16Color($request))->toBeTrue(); + + $request = Request::create('/', server: ['HTTP_X_COLOR_DEPTH' => '16']); + expect(TerminalRenderer::is16Color($request))->toBeTrue(); + + $request = Request::create('/', server: ['HTTP_X_COLOR_DEPTH' => '4']); + expect(TerminalRenderer::is16Color($request))->toBeTrue(); + + $request = Request::create('/'); + expect(TerminalRenderer::is16Color($request))->toBeFalse(); +}); + +it('sanitizes user text against terminal display manipulation', function (): void { + // Strips ANSI cursor / clear escape sequences + $malicious = "Hello \033[2J\033[HWorld\033[31m!"; + expect(TerminalRenderer::sanitize($malicious))->toBe('Hello World!'); + + // Strips C0 control characters (BEL, NUL, BS, etc.) + $control = "Safe\x07\x00\x08Text"; + expect(TerminalRenderer::sanitize($control))->toBe('SafeText'); + + // Preserves valid UTF-8 and emojis + $emoji = "Commit with 🔥 and 🚀 works\nsecond line"; + expect(TerminalRenderer::sanitize($emoji))->toBe("Commit with 🔥 and 🚀 works\nsecond line"); +}); + +it('renders the ASCII banner', function (): void { + $renderer = new TerminalRenderer(); + + $colored = $renderer->banner(true); + expect($colored)->toContain('/dev/null') + ->toContain("\033["); + + $plain = $renderer->banner(false); + expect($plain)->toContain('/dev/null') + ->not->toContain("\033["); +}); + +it('renders terminal landing page for guests', function (): void { + $renderer = new TerminalRenderer(); + $output = $renderer->landing(false); + + expect($output) + ->toContain('/dev/null') + ->toContain('A social network for developers.') + ->toContain('/login') + ->toContain('/commit'); +}); + +it('renders empty feed cleanly', function (): void { + $renderer = new TerminalRenderer(); + + $output = $renderer->feed([], false); + + expect($output) + ->toContain('Nobody has committed anything yet') + ->toContain('curl -fsSL') + ->not->toContain("\033["); +}); + +it('renders feed with commit nodes and lane colors', function (): void { + $user = User::factory()->create([ + 'username' => 'nuno', + 'preferred_lane' => 0, + ]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Shipped to production.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->feed([$commit], true); + + expect($output) + ->toContain('@nuno') + ->toContain('Shipped to production.') + ->toContain(mb_substr($commit->id, 0, 7)) + ->toContain("\033[38;2;217;72;15m"); // Lane 0 orange +}); + +it('renders feed with 16-color ANSI fallbacks', function (): void { + $user = User::factory()->create([ + 'username' => 'nuno', + 'preferred_lane' => 1, + ]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Standard ANSI terminal.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->feed([$commit], color: true, force16: true); + + expect($output) + ->toContain('@nuno') + ->toContain("\033[32m"); // Lane 1 green in ANSI-16 +}); + +it('renders user profile and stats', function (): void { + $user = User::factory()->create([ + 'username' => 'taylor', + 'name' => 'Taylor Otwell', + 'bio' => 'Laravel creator.', + 'preferred_lane' => 3, + ]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Laravel release day.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->profile($user, [$commit], false); + + expect($output) + ->toContain('@taylor (Taylor Otwell)') + ->toContain('Laravel creator.') + ->toContain('1 commits') + ->toContain('Laravel release day.') + ->not->toContain("\033["); +}); + +it('renders public profile preview for guests', function (): void { + $user = User::factory()->create([ + 'username' => 'guest_target', + 'name' => 'Target User', + 'bio' => 'Target bio.', + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->profilePreview($user, false); + + expect($output) + ->toContain('@guest_target (Target User)') + ->toContain('Target bio.') + ->toContain("Sign in with GitHub to read @guest_target's commits on /dev/null") + ->toContain('/login'); +}); + +it('renders public commit preview for guests', function (): void { + $user = User::factory()->create(['username' => 'author']); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'A preview commit.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->commitPreview($commit, false); + + expect($output) + ->toContain('A preview commit.') + ->toContain('Sign in with GitHub to join the conversation and read replies:') + ->toContain('/login'); +}); + +it('renders thread with ancestors, focused commit, and replies', function (): void { + $user = User::factory()->create(['username' => 'brendt']); + + $parent = Commit::factory()->for($user)->create([ + 'body' => 'Original commit.', + 'status' => CommitStatus::Approved, + ]); + + $focused = Commit::factory()->for($user)->create([ + 'parent_id' => $parent->id, + 'body' => 'In-between reply.', + 'status' => CommitStatus::Approved, + ]); + + $reply = Commit::factory()->for($user)->create([ + 'parent_id' => $focused->id, + 'body' => 'Bottom reply.', + 'status' => CommitStatus::Approved, + ]); + + $renderer = new TerminalRenderer(); + $output = $renderer->thread($focused, [$parent], [$reply], false); + + expect($output) + ->toContain('Original commit.') + ->toContain('In-between reply.') + ->toContain('(HEAD)') + ->toContain('Bottom reply.') + ->not->toContain("\033["); +}); + +it('rejects JSON API requests from wantsCli', function (): void { + $request = Request::create('/', server: [ + 'HTTP_ACCEPT' => 'application/json', + ]); + + expect(TerminalRenderer::wantsCli($request))->toBeFalse(); +}); + +it('renders feed with a title banner', function (): void { + $renderer = new TerminalRenderer(); + $output = $renderer->feed([], false, false, 'Custom Feed Title'); + + expect($output) + ->toContain('Custom Feed Title') + ->toContain('────────────────────────────────────────────────────────────'); +}); + +it('renders user profile with zero commits gracefully', function (): void { + $user = User::factory()->create(['username' => 'newbie']); + + $renderer = new TerminalRenderer(); + $output = $renderer->profile($user, [], false); + + expect($output) + ->toContain('@newbie has not committed anything yet.'); +}); + +it('renders commit card with stars and replies count', function (): void { + $user = User::factory()->create(['username' => 'popular', 'preferred_lane' => 99]); + + $commit = Commit::factory()->for($user)->create([ + 'body' => 'Popular commit with interactions.', + 'status' => CommitStatus::Approved, + ]); + + $commit->stars_count = 42; + $commit->replies_count = 7; + + $renderer = new TerminalRenderer(); + $card = $renderer->commitCard($commit, true); + + expect($card) + ->toContain('42 ★') + ->toContain('7 ⤿') + ->toContain('@popular'); +});