login and settings

This commit is contained in:
Isaac Johnson 2026-05-31 21:02:23 -05:00
parent e35f9ad95e
commit 506700caf9
4 changed files with 341 additions and 49 deletions

View File

@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { Config, Issue, Comment } from './types.js'; import { Config, Issue, Comment, RepoItem } from './types.js';
/** /**
* Normalizes the Gitea/Forgejo URL by stripping trailing slashes * Normalizes the Gitea/Forgejo URL by stripping trailing slashes
@ -170,3 +170,81 @@ export async function fetchIssueComments(
throw new Error(`Network Error: ${error.message}`); throw new Error(`Network Error: ${error.message}`);
} }
} }
/**
* Validates connection and fetches all repositories accessible by the user.
*/
export async function authenticateAndFetchRepos(
url: string,
userid: string,
token: string
): Promise<RepoItem[]> {
const normalized = normalizeUrl(url);
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (token.trim()) {
headers['Authorization'] = `token ${token.trim()}`;
}
const client = axios.create({
baseURL: `${normalized}/api/v1`,
headers,
timeout: 10000,
});
// 1. Verify credentials / user existence
try {
if (token.trim()) {
await client.get('/user');
} else if (userid.trim()) {
await client.get(`/users/${userid.trim()}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 401) {
throw new Error('Unauthorized: Invalid API Token.');
}
if (error.response.status === 404) {
throw new Error(`User "${userid}" not found.`);
}
throw new Error(`Authentication failed: ${error.response.data?.message || error.message}`);
}
throw new Error(`Failed to connect to ${normalized}: ${error.message}`);
}
// 2. Fetch repositories
try {
let repos: any[] = [];
if (token.trim()) {
// Fetch authenticated user's repos (lists all they have access to)
const response = await client.get('/user/repos', { params: { limit: 100 } });
repos = response.data;
} else if (userid.trim()) {
// Fetch public repos of the target username
const response = await client.get(`/users/${userid.trim()}/repos`, { params: { limit: 100 } });
repos = response.data;
}
if (!Array.isArray(repos)) {
throw new Error('Invalid response from server (expected list of repositories).');
}
return repos.map((r: any) => ({
id: r.id,
name: r.name,
full_name: r.full_name || `${r.owner?.login}/${r.name}`,
private: !!r.private,
description: r.description || '',
}));
} catch (error: any) {
if (error.response) {
throw new Error(`Failed to fetch repositories: ${error.response.data?.message || error.message}`);
}
throw new Error(`Failed to fetch repositories: ${error.message}`);
}
}

View File

@ -13,6 +13,7 @@ program
.description('A premium CLI TUI Dashboard for exploring Gitea and Forgejo issues') .description('A premium CLI TUI Dashboard for exploring Gitea and Forgejo issues')
.version('1.0.0') .version('1.0.0')
.option('-u, --url <url>', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)') .option('-u, --url <url>', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)')
.option('-i, --userid <userid>', 'Forgejo/Gitea user ID / username')
.option('-r, --repo <owner/repo>', 'Repository path (e.g. owner/repo)') .option('-r, --repo <owner/repo>', 'Repository path (e.g. owner/repo)')
.option('-t, --token <token>', 'Personal Access Token (optional for public repositories)'); .option('-t, --token <token>', 'Personal Access Token (optional for public repositories)');
@ -27,6 +28,7 @@ async function bootstrap() {
config: { config: {
url: '', url: '',
token: null, token: null,
userid: '',
owner: '', owner: '',
repo: '', repo: '',
}, },
@ -46,11 +48,15 @@ async function bootstrap() {
selectedIssueComments: [], selectedIssueComments: [],
commentsLoading: false, commentsLoading: false,
detailScrollOffset: 0, detailScrollOffset: 0,
repos: [],
selectedRepoIndex: 0,
repoSearchQuery: '',
repoPickerActiveSearch: false,
setupForm: { setupForm: {
url: options.url || 'https://forgejo.freshbrewed.science', url: options.url || 'https://forgejo.freshbrewed.science',
repo: options.repo || '', userid: options.userid || '',
token: options.token || '', token: options.token || '',
activeField: options.url ? (options.repo ? 'token' : 'repo') : 'url', activeField: options.url ? (options.userid ? 'token' : 'userid') : 'url',
}, },
}; };
@ -62,6 +68,7 @@ async function bootstrap() {
const config: Config = { const config: Config = {
url: normalized, url: normalized,
token: options.token ? options.token.trim() : null, token: options.token ? options.token.trim() : null,
userid: options.userid || repoParts[0].trim(),
owner: repoParts[0].trim(), owner: repoParts[0].trim(),
repo: repoParts[1].trim(), repo: repoParts[1].trim(),
}; };
@ -79,7 +86,7 @@ async function bootstrap() {
state.screen = 'setup'; state.screen = 'setup';
state.setupForm = { state.setupForm = {
url: options.url, url: options.url,
repo: options.repo, userid: options.userid || '',
token: options.token || '', token: options.token || '',
activeField: 'url', activeField: 'url',
}; };

View File

@ -1,7 +1,7 @@
import readline from 'readline'; import readline from 'readline';
import chalk from 'chalk'; import chalk from 'chalk';
import { AppState, Issue, Comment } from './types.js'; import { AppState, Issue, Comment } from './types.js';
import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl } from './api.js'; import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl, authenticateAndFetchRepos } from './api.js';
// Setup readline for stdin keypress events // Setup readline for stdin keypress events
readline.emitKeypressEvents(process.stdin); readline.emitKeypressEvents(process.stdin);
@ -80,6 +80,14 @@ function truncate(str: string, length: number): string {
return str.substring(0, length - 3) + '...'; return str.substring(0, length - 3) + '...';
} }
/**
* Helper to strip ANSI escape codes from a string for accurate length calculations.
*/
function stripAnsi(str: string): string {
return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
}
/** /**
* TUI State Controller and Render Engine * TUI State Controller and Render Engine
*/ */
@ -227,6 +235,8 @@ export class TuiEngine {
if (this.state.screen === 'setup') { if (this.state.screen === 'setup') {
this.handleSetupKeypress(str, key); this.handleSetupKeypress(str, key);
} else if (this.state.screen === 'repo-picker') {
this.handleRepoPickerKeypress(str, key);
} else if (this.state.screen === 'list') { } else if (this.state.screen === 'list') {
if (this.activeSearchInput) { if (this.activeSearchInput) {
this.handleSearchKeypress(str, key); this.handleSearchKeypress(str, key);
@ -243,7 +253,7 @@ export class TuiEngine {
*/ */
private async handleSetupKeypress(str: string, key: any) { private async handleSetupKeypress(str: string, key: any) {
const form = this.state.setupForm; const form = this.state.setupForm;
const fields: Array<'url' | 'repo' | 'token'> = ['url', 'repo', 'token']; const fields: Array<'url' | 'userid' | 'token'> = ['url', 'userid', 'token'];
const currentIdx = fields.indexOf(form.activeField); const currentIdx = fields.indexOf(form.activeField);
if (key && key.name === 'down') { if (key && key.name === 'down') {
@ -271,15 +281,8 @@ export class TuiEngine {
this.render(); this.render();
return; return;
} }
if (!form.repo.trim()) { if (!form.userid.trim()) {
this.state.error = 'Repository path (owner/repo) is required.'; this.state.error = 'User ID / Username is required.';
this.render();
return;
}
const repoParts = form.repo.split('/');
if (repoParts.length !== 2 || !repoParts[0].trim() || !repoParts[1].trim()) {
this.state.error = 'Repository must be in "owner/repo" format.';
this.render(); this.render();
return; return;
} }
@ -288,22 +291,22 @@ export class TuiEngine {
this.state.error = null; this.state.error = null;
this.render(); this.render();
const normalizedUrl = normalizeUrl(form.url);
const testConfig = {
url: normalizedUrl,
token: form.token.trim() || null,
owner: repoParts[0].trim(),
repo: repoParts[1].trim(),
};
try { try {
await validateConnection(testConfig); const repos = await authenticateAndFetchRepos(form.url, form.userid, form.token);
// Save config
this.state.config = testConfig; this.state.repos = repos;
this.state.screen = 'list'; this.state.selectedRepoIndex = 0;
this.state.currentPage = 1; this.state.repoSearchQuery = '';
this.state.selectedIssueIndex = 0; this.state.repoPickerActiveSearch = false;
this.loadIssues();
if (repos.length === 0) {
throw new Error('No repositories found for the provided credentials.');
}
// Go to repo picker screen!
this.state.screen = 'repo-picker';
this.state.loading = false;
this.render();
} catch (err: any) { } catch (err: any) {
this.state.error = err.message; this.state.error = err.message;
this.state.loading = false; this.state.loading = false;
@ -319,7 +322,7 @@ export class TuiEngine {
if (key && key.name === 'backspace') { if (key && key.name === 'backspace') {
if (form.activeField === 'url') form.url = form.url.slice(0, -1); if (form.activeField === 'url') form.url = form.url.slice(0, -1);
if (form.activeField === 'repo') form.repo = form.repo.slice(0, -1); if (form.activeField === 'userid') form.userid = form.userid.slice(0, -1);
if (form.activeField === 'token') form.token = form.token.slice(0, -1); if (form.activeField === 'token') form.token = form.token.slice(0, -1);
this.render(); this.render();
return; return;
@ -328,12 +331,107 @@ export class TuiEngine {
// Type character // Type character
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) { if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
if (form.activeField === 'url') form.url += str; if (form.activeField === 'url') form.url += str;
if (form.activeField === 'repo') form.repo += str; if (form.activeField === 'userid') form.userid += str;
if (form.activeField === 'token') form.token += str; if (form.activeField === 'token') form.token += str;
this.render(); this.render();
} }
} }
/**
* Key handling for Repository Picker screen
*/
private handleRepoPickerKeypress(str: string, key: any) {
if (this.state.loading) return;
const filteredRepos = this.state.repos.filter(r =>
r.full_name.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()) ||
(r.description && r.description.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()))
);
if (this.state.repoPickerActiveSearch) {
if ((key && key.name === 'escape') || str === '\u001b') {
this.state.repoPickerActiveSearch = false;
this.state.repoSearchQuery = '';
this.state.selectedRepoIndex = 0;
process.stdout.write('\x1B[?25l'); // Hide cursor
this.render();
return;
}
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
this.state.repoPickerActiveSearch = false;
process.stdout.write('\x1B[?25l'); // Hide cursor
this.render();
return;
}
if (key && key.name === 'backspace') {
this.state.repoSearchQuery = this.state.repoSearchQuery.slice(0, -1);
this.state.selectedRepoIndex = 0;
this.render();
return;
}
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
this.state.repoSearchQuery += str;
this.state.selectedRepoIndex = 0;
this.render();
}
return;
}
// Search inactive keypresses
if ((key && key.name === 'escape') || str === '\u001b') {
this.state.screen = 'setup';
this.render();
return;
}
if (key && key.name === 'up') {
if (this.state.selectedRepoIndex > 0) {
this.state.selectedRepoIndex--;
this.render();
}
return;
}
if (key && key.name === 'down') {
if (this.state.selectedRepoIndex < filteredRepos.length - 1) {
this.state.selectedRepoIndex++;
this.render();
}
return;
}
if (str === '/') {
this.state.repoPickerActiveSearch = true;
this.render();
return;
}
if ((key && key.name === 'return') || str === '\r' || str === '\n') {
if (filteredRepos.length > 0 && this.state.selectedRepoIndex < filteredRepos.length) {
const selectedRepo = filteredRepos[this.state.selectedRepoIndex];
const parts = selectedRepo.full_name.split('/');
this.state.config = {
url: normalizeUrl(this.state.setupForm.url),
token: this.state.setupForm.token.trim() || null,
userid: this.state.setupForm.userid.trim(),
owner: parts[0],
repo: parts[1],
};
this.state.screen = 'list';
this.state.currentPage = 1;
this.state.selectedIssueIndex = 0;
this.state.searchQuery = '';
this.loadIssues();
}
}
}
/** /**
* Key handling for searching overlay * Key handling for searching overlay
*/ */
@ -375,14 +473,14 @@ export class TuiEngine {
if (this.state.loading) return; if (this.state.loading) return;
if ((key && key.name === 'escape') || str === '\u001b') { if ((key && key.name === 'escape') || str === '\u001b') {
// If we entered config via CLI, quit. If we came from setup form, go back to setup! this.stop();
if (this.state.setupForm.repo) { return;
this.state.screen = 'setup'; }
this.state.error = null;
this.render(); if (str === 'o' || str === 'O') {
} else { this.state.screen = 'setup';
this.stop(); this.state.error = null;
} this.render();
return; return;
} }
@ -524,6 +622,15 @@ export class TuiEngine {
return; return;
} }
if (str === 'o' || str === 'O') {
this.state.screen = 'setup';
this.state.selectedIssue = null;
this.state.selectedIssueComments = [];
this.state.error = null;
this.render();
return;
}
if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') { if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') {
this.state.screen = 'list'; this.state.screen = 'list';
this.state.selectedIssue = null; this.state.selectedIssue = null;
@ -544,6 +651,8 @@ export class TuiEngine {
if (this.state.screen === 'setup') { if (this.state.screen === 'setup') {
this.renderSetupScreen(cols, rows); this.renderSetupScreen(cols, rows);
} else if (this.state.screen === 'repo-picker') {
this.renderRepoPickerScreen(cols, rows);
} else if (this.state.screen === 'list') { } else if (this.state.screen === 'list') {
this.renderListScreen(cols, rows); this.renderListScreen(cols, rows);
} else if (this.state.screen === 'details') { } else if (this.state.screen === 'details') {
@ -591,8 +700,8 @@ export class TuiEngine {
lines.push(renderField('Gitea URL', form.url, form.activeField === 'url')); lines.push(renderField('Gitea URL', form.url, form.activeField === 'url'));
lines.push(border + ' '.repeat(width - 2) + border); lines.push(border + ' '.repeat(width - 2) + border);
lines.push(renderField('Repository', form.repo, form.activeField === 'repo')); lines.push(renderField('User ID', form.userid, form.activeField === 'userid'));
lines.push(border + ' ' + chalk.gray('Format: owner/repo (e.g. gitea/tea)').padEnd(width - 5) + border); lines.push(border + ' ' + chalk.gray('Forgejo/Gitea username (e.g. gitea_admin)').padEnd(width - 5) + border);
lines.push(border + ' '.repeat(width - 2) + border); lines.push(border + ' '.repeat(width - 2) + border);
lines.push(renderField('Access Token', form.token, form.activeField === 'token', true)); lines.push(renderField('Access Token', form.token, form.activeField === 'token', true));
lines.push(border + ' ' + chalk.gray('Optional. Required for private repos.').padEnd(width - 5) + border); lines.push(border + ' ' + chalk.gray('Optional. Required for private repos.').padEnd(width - 5) + border);
@ -627,8 +736,8 @@ export class TuiEngine {
if (!this.state.loading) { if (!this.state.loading) {
// Find current active field offset row // Find current active field offset row
let activeRowOffset = vertPadding + 6; // URL row let activeRowOffset = vertPadding + 6; // URL row
if (form.activeField === 'repo') activeRowOffset += 2; if (form.activeField === 'userid') activeRowOffset = vertPadding + 8;
if (form.activeField === 'token') activeRowOffset += 4; if (form.activeField === 'token') activeRowOffset = vertPadding + 11;
const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField]; const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField];
const cursorCol = Math.max(0, Math.floor((cols - width) / 2)) + 23 + activeValue.length; const cursorCol = Math.max(0, Math.floor((cols - width) / 2)) + 23 + activeValue.length;
@ -753,7 +862,7 @@ export class TuiEngine {
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// Keyboard controls help line // Keyboard controls help line
const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [Esc] Back'); const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [O] Settings [Esc] Quit');
process.stdout.write(helpLine); process.stdout.write(helpLine);
// If search active, position terminal cursor in search box // If search active, position terminal cursor in search box
@ -854,7 +963,89 @@ export class TuiEngine {
scrollHelp += chalk.yellow(` (${pct}%)`); scrollHelp += chalk.yellow(` (${pct}%)`);
} }
const helpLine = chalk.gray(` [Esc/Backspace] Back to List ${scrollHelp}`); const helpLine = chalk.gray(` [Esc/Backspace] Back to List [O] Settings ${scrollHelp}`);
process.stdout.write(helpLine); process.stdout.write(helpLine);
} }
/**
* Draw Repository Picker Screen
*/
private renderRepoPickerScreen(cols: number, rows: number) {
const title = 'SELECT REPOSITORY';
const subtitle = `Connected to ${normalizeUrl(this.state.setupForm.url).replace(/^https?:\/\//, '')} as ${this.state.setupForm.userid}`;
console.log(` ${chalk.bold.hex('#4A90E2')(title)}${chalk.gray(subtitle)}`);
console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐'));
const borderCh = chalk.bold.hex('#4A90E2')('│');
// Filter repositories based on query
const filteredRepos = this.state.repos.filter(r =>
r.full_name.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()) ||
(r.description && r.description.toLowerCase().includes(this.state.repoSearchQuery.toLowerCase()))
);
const listHeight = rows - 7; // Header, borders, search, footer
// Scroll window calculation
let startIndex = 0;
if (this.state.selectedRepoIndex >= listHeight) {
startIndex = this.state.selectedRepoIndex - listHeight + 1;
}
if (filteredRepos.length === 0) {
const msg = this.state.repoSearchQuery ? 'No repositories match your filter.' : 'No repositories found.';
const paddingRows = Math.floor((listHeight - 1) / 2);
for (let i = 0; i < paddingRows; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
console.log(borderCh + (' '.repeat(Math.max(0, Math.floor((cols - 2 - msg.length) / 2))) + chalk.gray(msg)).padEnd(cols - 2) + borderCh);
for (let i = 0; i < listHeight - paddingRows - 1; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
} else {
for (let i = 0; i < listHeight; i++) {
const repoIdx = i + startIndex;
if (repoIdx < filteredRepos.length) {
const repo = filteredRepos[repoIdx];
const isSelected = repoIdx === this.state.selectedRepoIndex;
const typeStr = repo.private ? chalk.bold.yellow('[Private]') : chalk.bold.green('[Public] ');
const nameStr = chalk.bold(repo.full_name);
const descStr = repo.description ? chalk.gray(`${truncate(repo.description, cols - repo.full_name.length - 20)}`) : '';
let lineContent = ` ${typeStr} ${nameStr}${descStr}`;
// Pad the line content properly to match terminal width
const visibleLen = stripAnsi(lineContent).length;
const padding = ' '.repeat(Math.max(0, cols - 4 - visibleLen));
lineContent = lineContent + padding;
if (isSelected) {
lineContent = chalk.bgHex('#2E4E7E').white.bold(lineContent);
}
console.log(borderCh + ' ' + lineContent + ' ' + borderCh);
} else {
console.log(borderCh + ' '.repeat(cols - 2) + borderCh);
}
}
}
console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤'));
// Filter status line
let filterLabel = this.state.repoSearchQuery ? chalk.yellow(`"${this.state.repoSearchQuery}"`) : chalk.gray('none');
if (this.state.repoPickerActiveSearch) {
filterLabel = chalk.inverse(this.state.repoSearchQuery + ' ');
}
const filterText = ` Filter: ${filterLabel}`;
console.log(borderCh + filterText.padEnd(cols - 2) + borderCh);
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] Select [/] Filter [Esc] Back to Setup');
process.stdout.write(helpLine);
if (this.state.repoPickerActiveSearch) {
const searchCursorCol = 10 + this.state.repoSearchQuery.length;
process.stdout.write(`\x1B[${rows - 1};${searchCursorCol}H\x1B[?25h`); // Show cursor
}
}
} }

View File

@ -1,6 +1,7 @@
export interface Config { export interface Config {
url: string; url: string;
token: string | null; token: string | null;
userid: string;
owner: string; owner: string;
repo: string; repo: string;
} }
@ -41,12 +42,20 @@ export interface Comment {
export interface SetupForm { export interface SetupForm {
url: string; url: string;
userid: string;
token: string; token: string;
repo: string; activeField: 'url' | 'userid' | 'token';
activeField: 'url' | 'token' | 'repo';
} }
export type ScreenType = 'setup' | 'list' | 'details'; export type ScreenType = 'setup' | 'repo-picker' | 'list' | 'details';
export interface RepoItem {
id: number;
name: string;
full_name: string;
private: boolean;
description: string;
}
export interface AppState { export interface AppState {
screen: ScreenType; screen: ScreenType;
@ -72,6 +81,13 @@ export interface AppState {
commentsLoading: boolean; commentsLoading: boolean;
detailScrollOffset: number; detailScrollOffset: number;
// Repo Picker State
repos: RepoItem[];
selectedRepoIndex: number;
repoSearchQuery: string;
repoPickerActiveSearch: boolean;
// Setup Form State // Setup Form State
setupForm: SetupForm; setupForm: SetupForm;
} }