refactor: all

This commit is contained in:
monoid 2025-04-10 21:36:48 +09:00
parent 9d735b1cf0
commit 5a09d5b070
7 changed files with 482 additions and 310 deletions

36
api/base-api.ts Normal file
View file

@ -0,0 +1,36 @@
export class ApiError extends Error {
constructor(public readonly statusCode: number, message: string) {
super(message);
this.name = "ApiError";
}
}
export class BaseApi {
constructor(protected readonly baseUrl: string) {}
protected async request<T>(
endpoint: string,
options: {
method: string,
headers: Record<string, string>,
body?: string
}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const res = await fetch(url, options);
if (!res.ok) {
// Log the error response for debugging
console.error(`Failed with ${res.status} ${res.statusText}`);
const errorText = await res.json();
console.error("Response:", errorText);
throw new ApiError(res.status, errorText.message);
}
if (res.status === 204) {
return undefined as T; // No content: ;
}
const data = await res.json();
return data as T;
}
}

82
api/oauth2-api.ts Normal file
View file

@ -0,0 +1,82 @@
import { BaseApi } from "./base-api.ts";
export type Oauth2Application = {
id: number,
name: string,
redirect_uris: string[],
client_id: string,
client_secret: string,
confidential_client: boolean,
/**
* @format date-time
*/
created: string,
}
export class OAuth2Api extends BaseApi {
private token: string;
constructor(baseUrl: string, token: string) {
super(baseUrl);
this.token = token;
}
// Method to update token if needed later
updateToken(token: string): void {
this.token = token;
}
async createOauth2Application(params: {
name: string,
redirect_uris: string[],
confidential_client?: boolean,
}): Promise<Oauth2Application | undefined> {
return await this.request<Oauth2Application>(`/api/v1/user/applications/oauth2`, {
method: "POST",
headers: {
"Authorization": `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
}
async getOauth2Applications(): Promise<Oauth2Application[] | undefined> {
return await this.request<Oauth2Application[]>(`/api/v1/user/applications/oauth2`, {
method: "GET",
headers: {
"Authorization": `Bearer ${this.token}`,
"Content-Type": "application/json",
},
});
}
async deleteOauth2Application(id: number): Promise<void> {
if (!this.token) {
throw new Error("Authentication required. Please login first.");
}
await this.request<void>(`/api/v1/user/applications/oauth2/${id}`, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${this.token}`,
"Content-Type": "application/json",
},
});
}
async updateOauth2Application(id: number, params: {
name: string,
redirect_uris: string[],
confidential_client?: boolean,
}): Promise<Oauth2Application | undefined> {
return await this.request<Oauth2Application>(`/api/v1/user/applications/oauth2/${id}`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
}
}

51
api/token-api.ts Normal file
View file

@ -0,0 +1,51 @@
import { BaseApi } from "./base-api.ts";
export type Token = {
id: number,
name: string,
scopes: string[],
sha1: string,
token_last_eight: string,
}
export class TokenApi extends BaseApi {
constructor(baseUrl: string, private tokenName: string) {
super(baseUrl);
}
async listTokens(username: string, secret: string): Promise<Token[] | undefined> {
return await this.request<Token[]>(`/api/v1/users/${username}/tokens`, {
method: "GET",
headers: {
"Authorization": `Basic ${btoa(`${username}:${secret}`)}`,
"Content-Type": "application/json",
},
});
}
async deleteToken(username: string, secret: string, tokenId: number): Promise<boolean> {
const result = await this.request(`/api/v1/users/${username}/tokens/${tokenId}`, {
method: "DELETE",
headers: {
"Authorization": `Basic ${btoa(`${username}:${secret}`)}`,
"Content-Type": "application/json",
},
});
return result !== undefined;
}
async createToken(username: string, secret: string): Promise<Token | undefined> {
return await this.request<Token>(`/api/v1/users/${username}/tokens`, {
method: "POST",
headers: {
"Authorization": `Basic ${btoa(`${username}:${secret}`)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.tokenName,
scopes: ["write:user", "read:user"],
}),
});
}
}

484
app.ts
View file

@ -1,319 +1,229 @@
import { AsyncLocalStorage } from "node:async_hooks"; import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
import { Select } from "jsr:@cliffy/prompt@1.0.0-rc.7"; import { Secret } from "jsr:@cliffy/prompt@1.0.0-rc.7";
import { promptSecret, parseArgs } from "jsr:@std/cli" import { homedir } from "node:os";
import { TokenApi } from "./api/token-api.ts";
const tokenStorage = new AsyncLocalStorage<string>(); import { OAuth2Api } from "./api/oauth2-api.ts";
import { CredentialManager } from "./util/credential-manager.ts";
function printHelp() { import { prettyOauth2Application, saveSecretKeys } from "./util/helpers.ts";
const helpText = `forgejo oauth2cli. interactive client credentials generator.
Usage: app.ts [options]
Options:
-h, --help Show this help message
-v, --version Show version information`;
console.log(helpText);
}
// Constants that were previously in types.ts
const URL_BASE = "https://git.monoid.top"; const URL_BASE = "https://git.monoid.top";
const TOKEN_NAME = "oauth2cli";
type Token = { const CREDENTIALS_FILE = `${homedir()}/.oauth2cli-forgejo`;
id: number,
name: string,
scopes: string[],
sha1: string,
token_last_eight: string,
}
async function listTokens(username: string, secret: string) {
const res = await fetch(`${URL_BASE}/api/v1/users/${username}/tokens`, {
method: "GET",
headers: {
"Authorization": `Basic ${btoa(`${username}:${secret}`)}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
console.error("Failed to list tokens:", res.statusText);
console.error("Response:", await res.text());
return;
}
const data = await res.json();
if (data.error) {
console.error("Error listing tokens:", data.error);
return;
}
return data as Token[];
}
async function deleteToken(username: string, secret: string, tokenId: number) {
const res = await fetch(`${URL_BASE}/api/v1/users/${username}/tokens/${tokenId}`, {
method: "DELETE",
headers: {
"Authorization": `Basic ${btoa(`${username}:${secret}`)}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
console.error("Failed to delete token:", res.statusText);
console.error("Response:", await res.text());
return;
}
}
async function getToken(username: string, secret: string) {
// Basic auth
// TODO: two factor auth and webauthn support
const res = await fetch(`${URL_BASE}/api/v1/users/${username}/tokens`, {
method: "POST",
headers: {
"Authorization": `Basic ${btoa(`${username}:${secret}`)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "oauth2cli",
scopes: ["write:user", "read:user"],
}),
});
if (!res.ok) {
console.error("Failed to create token:", res.statusText);
console.error("Response:", await res.text());
return;
}
const data = await res.json();
if (data.error) {
console.error("Error creating token:", data.error);
return;
}
return data as Token;
}
type Oauth2Application = {
id: number,
name: string,
redirect_uris: string[],
client_id: string,
client_secret: string,
confidential_client: boolean,
/**
* @format date-time
*/
created: string,
}
function prettyOauth2Application(app: Oauth2Application): string {
return (`ID: ${app.id}
Name: ${app.name}
Redirect URIs: ${app.redirect_uris.join(", ")}
Client ID: ${app.client_id}
Confidential Client: ${app.confidential_client ? "Yes" : "No"}
Created: ${new Date(app.created).toLocaleString()}`);
}
async function createOauth2Application({
confidential_client = false,
name,
redirect_uris,
}: {
confidential_client?: boolean,
name: string,
redirect_uris: string[],
}) {
const token = tokenStorage.getStore();
if (!token) {
throw new Error("Token not found in storage.");
}
const res = await fetch(`${URL_BASE}/api/v1/user/applications/oauth2`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
confidential_client,
name,
redirect_uris,
}),
});
if (!res.ok) {
console.error("Failed to create oauth2 application:", res.statusText);
console.error("Response:", await res.text());
return;
}
const data = await res.json();
if (data.error) {
console.error("Error creating oauth2 application:", data.error);
return;
}
return data as Oauth2Application;
}
async function getOauth2Application() {
const token = tokenStorage.getStore();
if (!token) {
throw new Error("Token not found in storage.");
}
const res = await fetch(`${URL_BASE}/api/v1/user/applications/oauth2`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
console.error("Failed to get oauth2 application:", res.statusText);
console.error("Response:", await res.text());
return;
}
const data = await res.json();
if (data.error) {
console.error("Error getting oauth2 application:", data.error);
return;
}
return data as Oauth2Application[];
}
async function deleteOauth2Application(id: number) {
const token = tokenStorage.getStore();
if (!token) {
throw new Error("Token not found in storage.");
}
const res = await fetch(`${URL_BASE}/api/v1/user/applications/oauth2/${id}`, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
console.error("Failed to delete oauth2 application:", res.statusText);
console.error("Response:", await res.text());
return;
}
}
async function main() { async function main() {
const args = parseArgs(Deno.args); const tokenApi = new TokenApi(URL_BASE, TOKEN_NAME);
const credentialManager = new CredentialManager(CREDENTIALS_FILE, TOKEN_NAME);
if (args.help) {
printHelp();
return;
}
if (args.version) {
console.log("forgejo oauth2cli v0.1.0");
return;
}
// TODO: libsecret support (Linux), keychain support (macOS), wincred support (Windows)
try {
await new Command()
.name("forgejo-oauth2cli")
.version("0.1.0")
.description("Interactive client credentials generator for Forgejo.")
.command("login", "Authenticate with Forgejo interactively")
.action(async() => {
const username = await prompt("Enter your username: "); const username = await prompt("Enter your username: ");
if (!username) { if (!username) {
console.error("Username is required."); console.error("Username is required.");
return; return;
} }
const secret = await promptSecret("Enter your secret: ");
const secret = await Secret.prompt("Enter your secret: ");
if (!secret) { if (!secret) {
console.error("Secret is required."); console.error("Secret is required.");
return; return;
} }
// check already created tokens
const tokens = await listTokens(username, secret); // Check for existing tokens
const tokens = await tokenApi.listTokens(username, secret);
if (!tokens) { if (!tokens) {
console.error("Failed to list tokens.");
return; return;
} }
console.log("Tokens:", tokens.map(t => t.name));
if (tokens.map(t=> t.name).includes("oauth2cli")) { // Delete existing token if found
console.log("Token already created, deleting..."); const existingToken = tokens.find(t => t.name === TOKEN_NAME);
const token = tokens.find(t => t.name === "oauth2cli"); if (existingToken) {
if (!token) { console.log("Existing token found, replacing it...");
console.error("Failed to find token."); await tokenApi.deleteToken(username, secret, existingToken.id);
return;
}
await deleteToken(username, secret, token.id);
} }
// Create new token
console.log("Creating new token..."); console.log("Creating new token...");
// create new token const token = await tokenApi.createToken(username, secret);
const token = await getToken(username, secret);
if (!token) { if (!token) {
console.error("Failed to get token.");
return; return;
} }
console.log("Token created successfully!");
await tokenStorage.run(token.sha1 ,async () => { // Store credentials
while (true) { await credentialManager.storeCredentials(username, token.sha1);
const opt = await Select.prompt({ console.log("Login successful! Credentials stored securely.");
message: "Select an option", })
options: [ .command("logout", "Delete token and credentials")
{ name: "Create new oauth2 application", value: "create" }, .action(async () => {
{ name: "Get existing oauth2 application", value: "get" }, const username = await prompt("Enter your username: ");
{ name: "Delete oauth2 application", value: "delete" }, if (!username) {
{ name: "List oauth2 applications", value: "list" }, console.error("Username is required.");
{ name: "Exit", value: "exit" }, return;
], }
});
switch (opt) { const secret = await Secret.prompt("Enter your secret: ");
case "create": { if (!secret) {
console.error("Secret is required.");
return;
}
// Find and delete token
const tokens = await tokenApi.listTokens(username, secret);
if (!tokens) {
return;
}
const existingToken = tokens.find(t => t.name === TOKEN_NAME);
if (existingToken) {
await tokenApi.deleteToken(username, secret, existingToken.id);
console.log("Token deleted from Forgejo.");
}
// Clear stored credentials
await credentialManager.clearCredentials(username);
console.log("Logout successful! Credentials removed.");
})
.command("list, ls", "List all OAuth2 applications")
.action(async () => {
const credentials = await credentialManager.getCredentials();
if (!credentials) return;
// Create OAuth2Api with token
const oauth2Api = new OAuth2Api(URL_BASE, credentials.token);
const apps = await oauth2Api.getOauth2Applications();
if (!apps) return;
if (apps.length === 0) {
console.log("No OAuth2 applications found.");
return;
}
console.log("OAuth2 applications:");
console.log(apps.map(prettyOauth2Application).join("\n\n"));
})
.command("create", "Create new OAuth2 application")
.action(async () => {
const credentials = await credentialManager.getCredentials();
if (!credentials) return;
// Create OAuth2Api with token
const oauth2Api = new OAuth2Api(URL_BASE, credentials.token);
const name = await prompt("Enter the name of the application: "); const name = await prompt("Enter the name of the application: ");
if (!name) { if (!name) {
console.error("Name is required."); console.error("Name is required.");
break;
}
const redirect_uris = await prompt("Enter the redirect uris (comma separated): ");
if (!redirect_uris) {
console.error("Redirect uris are required.");
break;
}
const app = await createOauth2Application({
name,
redirect_uris: redirect_uris.split(","),
});
if (!app) {
console.error("Failed to create oauth2 application.");
break;
}
console.log("Oauth2 application created successfully!", app);
break;
}
case "get": {
const app = await getOauth2Application();
if (!app) {
console.error("Failed to get oauth2 application.");
break;
}
console.log("Oauth2 applications:", app);
break;
}
case "delete": {
const id = await prompt("Enter the id of the application to delete: ");
if (!id) {
console.error("Id is required.");
break;
}
const appId = parseInt(id);
if (isNaN(appId)) {
console.error("Id must be a number.");
break;
}
await deleteOauth2Application(appId);
console.log("Oauth2 application deleted successfully!");
break;
}
case "list": {
const app = await getOauth2Application();
if (!app) {
console.error("Failed to get oauth2 application.");
break;
}
console.log("Oauth2 applications:", app.map(prettyOauth2Application).join("\n\n"));
break;
}
case "exit":
default:
return; return;
} }
const redirectUris = await prompt("Enter the redirect URIs (comma separated): ");
if (!redirectUris) {
console.error("Redirect URIs are required.");
return;
}
const app = await oauth2Api.createOauth2Application({
name,
redirect_uris: redirectUris.split(",").map(uri => uri.trim()),
});
if (!app) return;
console.log("OAuth2 application created successfully!");
console.log(prettyOauth2Application(app));
// Save secret keys
const path = await prompt("Enter the path to save the OAuth2 secret keys: ");
if (path) {
await saveSecretKeys(path, app.client_id, app.client_secret);
} }
}) })
.command("delete", "Delete OAuth2 application")
.action(async () => {
const credentials = await credentialManager.getCredentials();
if (!credentials) return;
// Create OAuth2Api with token
const oauth2Api = new OAuth2Api(URL_BASE, credentials.token);
const id = await prompt("Enter the ID of the application to delete: ");
if (!id) {
console.error("ID is required.");
return;
}
const appId = parseInt(id);
if (isNaN(appId)) {
console.error("ID must be a number.");
return;
}
const success = await oauth2Api.deleteOauth2Application(appId);
if (success) {
console.log("OAuth2 application deleted successfully!");
}
})
.command("update", "Update OAuth2 application")
.action(async () => {
const credentials = await credentialManager.getCredentials();
if (!credentials) return;
// Create OAuth2Api with token
const oauth2Api = new OAuth2Api(URL_BASE, credentials.token);
const id = await prompt("Enter the ID of the application to update: ");
if (!id) {
console.error("ID is required.");
return;
}
const appId = parseInt(id);
if (isNaN(appId)) {
console.error("ID must be a number.");
return;
}
const name = await prompt("Enter the new name of the application: ");
if (!name) {
console.error("Name is required.");
return;
}
const redirectUris = await prompt("Enter the new redirect URIs (comma separated): ");
if (!redirectUris) {
console.error("Redirect URIs are required.");
return;
}
const app = await oauth2Api.updateOauth2Application(appId, {
name,
redirect_uris: redirectUris.split(",").map(uri => uri.trim()),
});
if (!app) return;
console.log("OAuth2 application updated successfully!");
console.log(prettyOauth2Application(app));
// Save secret keys
const path = await prompt("Enter the path to save the OAuth2 secret keys: ");
if (path) {
await saveSecretKeys(path, app.client_id, app.client_secret);
}
})
.parse(Deno.args);
} catch (error) {
if (error instanceof Error) {
console.error("An error occurred:", error.message);
}
else {
console.error("An unexpected error occurred:", error);
}
}
} }
if (import.meta.main) { if (import.meta.main) {

24
types.ts Normal file
View file

@ -0,0 +1,24 @@
export const URL_BASE = "https://git.monoid.top";
export const TOKEN_NAME = "oauth2cli";
export const CREDENTIALS_FILE = `${Deno.env.get("HOME") || ""}/.oauth2cli-forgejo`;
export type Token = {
id: number,
name: string,
scopes: string[],
sha1: string,
token_last_eight: string,
}
export type Oauth2Application = {
id: number,
name: string,
redirect_uris: string[],
client_id: string,
client_secret: string,
confidential_client: boolean,
/**
* @format date-time
*/
created: string,
}

View file

@ -0,0 +1,53 @@
import { Entry } from "npm:@napi-rs/keyring";
export class CredentialManager {
constructor(
private credentialsFile: string,
private tokenName: string
) {}
async storeCredentials(username: string, token: string): Promise<void> {
// Store username
await Deno.writeTextFile(this.credentialsFile, username);
// Store token in keyring
const entry = new Entry(this.tokenName, username);
if (entry.getPassword()) {
entry.deletePassword();
}
entry.setPassword(token);
}
async getCredentials(): Promise<{ username: string, token: string } | undefined> {
try {
const username = await Deno.readTextFile(this.credentialsFile);
const entry = new Entry(this.tokenName, username);
const token = entry.getPassword();
if (!token) {
console.error("No token found in keyring. Please login first.");
return undefined;
}
return { username, token };
} catch (error) {
console.error("No credentials found. Please login first.");
return undefined;
}
}
async clearCredentials(username: string): Promise<void> {
// Remove username file
try {
await Deno.remove(this.credentialsFile);
} catch (error) {
console.error("Failed to remove credentials file:", error);
}
// Remove token from keyring
const entry = new Entry(this.tokenName, username);
if (entry.getPassword()) {
entry.deletePassword();
}
}
}

16
util/helpers.ts Normal file
View file

@ -0,0 +1,16 @@
import { Oauth2Application } from "../api/oauth2-api.ts";
export function prettyOauth2Application(app: Oauth2Application): string {
return (`ID: ${app.id}
Name: ${app.name}
Redirect URIs: ${app.redirect_uris.join(", ")}
Client ID: ${app.client_id}
Confidential Client: ${app.confidential_client ? "Yes" : "No"}
Created: ${new Date(app.created).toLocaleString()}`);
}
export async function saveSecretKeys(path: string, clientId: string, clientSecret: string): Promise<void> {
const secretKey = `FORGEJO_CLIENT_KEY=${clientId}\nFORGEJO_SECRET_KEY=${clientSecret}`;
await Deno.writeTextFile(path, secretKey, { append: true, create: true });
console.log("OAuth2 secret keys saved successfully!");
}