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 { Config, Issue, Comment } from './types.js';
import { Config, Issue, Comment, RepoItem } from './types.js';
/**
* Normalizes the Gitea/Forgejo URL by stripping trailing slashes
@ -170,3 +170,81 @@ export async function fetchIssueComments(
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')
.version('1.0.0')
.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('-t, --token <token>', 'Personal Access Token (optional for public repositories)');
@ -27,6 +28,7 @@ async function bootstrap() {
config: {
url: '',
token: null,
userid: '',
owner: '',
repo: '',
},
@ -46,11 +48,15 @@ async function bootstrap() {
selectedIssueComments: [],
commentsLoading: false,
detailScrollOffset: 0,
repos: [],
selectedRepoIndex: 0,
repoSearchQuery: '',
repoPickerActiveSearch: false,
setupForm: {
url: options.url || 'https://forgejo.freshbrewed.science',
repo: options.repo || '',
userid: options.userid || '',
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 = {
url: normalized,
token: options.token ? options.token.trim() : null,
userid: options.userid || repoParts[0].trim(),
owner: repoParts[0].trim(),
repo: repoParts[1].trim(),
};
@ -79,7 +86,7 @@ async function bootstrap() {
state.screen = 'setup';
state.setupForm = {
url: options.url,
repo: options.repo,
userid: options.userid || '',
token: options.token || '',
activeField: 'url',
};

View File

@ -1,7 +1,7 @@
import readline from 'readline';
import chalk from 'chalk';
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
readline.emitKeypressEvents(process.stdin);
@ -80,6 +80,14 @@ function truncate(str: string, length: number): string {
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
*/
@ -227,6 +235,8 @@ export class TuiEngine {
if (this.state.screen === 'setup') {
this.handleSetupKeypress(str, key);
} else if (this.state.screen === 'repo-picker') {
this.handleRepoPickerKeypress(str, key);
} else if (this.state.screen === 'list') {
if (this.activeSearchInput) {
this.handleSearchKeypress(str, key);
@ -243,7 +253,7 @@ export class TuiEngine {
*/
private async handleSetupKeypress(str: string, key: any) {
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);
if (key && key.name === 'down') {
@ -271,15 +281,8 @@ export class TuiEngine {
this.render();
return;
}
if (!form.repo.trim()) {
this.state.error = 'Repository path (owner/repo) 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.';
if (!form.userid.trim()) {
this.state.error = 'User ID / Username is required.';
this.render();
return;
}
@ -288,22 +291,22 @@ export class TuiEngine {
this.state.error = null;
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 {
await validateConnection(testConfig);
// Save config
this.state.config = testConfig;
this.state.screen = 'list';
this.state.currentPage = 1;
this.state.selectedIssueIndex = 0;
this.loadIssues();
const repos = await authenticateAndFetchRepos(form.url, form.userid, form.token);
this.state.repos = repos;
this.state.selectedRepoIndex = 0;
this.state.repoSearchQuery = '';
this.state.repoPickerActiveSearch = false;
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) {
this.state.error = err.message;
this.state.loading = false;
@ -319,7 +322,7 @@ export class TuiEngine {
if (key && key.name === 'backspace') {
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);
this.render();
return;
@ -328,12 +331,107 @@ export class TuiEngine {
// Type character
if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) {
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;
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
*/
@ -375,14 +473,14 @@ export class TuiEngine {
if (this.state.loading) return;
if ((key && key.name === 'escape') || str === '\u001b') {
// If we entered config via CLI, quit. If we came from setup form, go back to setup!
if (this.state.setupForm.repo) {
this.state.screen = 'setup';
this.state.error = null;
this.render();
} else {
this.stop();
}
this.stop();
return;
}
if (str === 'o' || str === 'O') {
this.state.screen = 'setup';
this.state.error = null;
this.render();
return;
}
@ -524,6 +622,15 @@ export class TuiEngine {
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') {
this.state.screen = 'list';
this.state.selectedIssue = null;
@ -544,6 +651,8 @@ export class TuiEngine {
if (this.state.screen === 'setup') {
this.renderSetupScreen(cols, rows);
} else if (this.state.screen === 'repo-picker') {
this.renderRepoPickerScreen(cols, rows);
} else if (this.state.screen === 'list') {
this.renderListScreen(cols, rows);
} 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(border + ' '.repeat(width - 2) + border);
lines.push(renderField('Repository', form.repo, form.activeField === 'repo'));
lines.push(border + ' ' + chalk.gray('Format: owner/repo (e.g. gitea/tea)').padEnd(width - 5) + border);
lines.push(renderField('User ID', form.userid, form.activeField === 'userid'));
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(renderField('Access Token', form.token, form.activeField === 'token', true));
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) {
// Find current active field offset row
let activeRowOffset = vertPadding + 6; // URL row
if (form.activeField === 'repo') activeRowOffset += 2;
if (form.activeField === 'token') activeRowOffset += 4;
if (form.activeField === 'userid') activeRowOffset = vertPadding + 8;
if (form.activeField === 'token') activeRowOffset = vertPadding + 11;
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;
@ -753,7 +862,7 @@ export class TuiEngine {
console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘'));
// 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);
// If search active, position terminal cursor in search box
@ -854,7 +963,89 @@ export class TuiEngine {
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);
}
/**
* 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 {
url: string;
token: string | null;
userid: string;
owner: string;
repo: string;
}
@ -41,12 +42,20 @@ export interface Comment {
export interface SetupForm {
url: string;
userid: string;
token: string;
repo: string;
activeField: 'url' | 'token' | 'repo';
activeField: 'url' | 'userid' | 'token';
}
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 {
screen: ScreenType;
@ -72,6 +81,13 @@ export interface AppState {
commentsLoading: boolean;
detailScrollOffset: number;
// Repo Picker State
repos: RepoItem[];
selectedRepoIndex: number;
repoSearchQuery: string;
repoPickerActiveSearch: boolean;
// Setup Form State
setupForm: SetupForm;
}