#!/usr/bin/env bun import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; import { execSync } from 'child_process'; import { version } from '../package.json' assert { type: 'json' }; // --- Constants --- const CONFIG_FILE = path.join(os.homedir(), '.note.conf'); // --- Types --- interface Config { TOKEN?: string; REPO?: string; URL?: string; } // --- Config Management --- async function createDefaultConfig(): Promise { const defaultConfig = `TOKEN= REPO= URL=https://cld-dkr-makuro-seafile.wibudev.com/api2 `; await fs.writeFile(CONFIG_FILE, defaultConfig, 'utf8'); } async function editConfig(): Promise { if (!(await fs.stat(CONFIG_FILE)).isFile()) { createDefaultConfig(); } const editor = process.env.EDITOR || 'vim'; try { execSync(`${editor} "${CONFIG_FILE}"`, { stdio: 'inherit' }); } catch { console.error('❌ Failed to open editor'); process.exit(1); } } async function loadConfig(): Promise { if (!(await fs.stat(CONFIG_FILE)).isFile()) { console.error(`⚠️ Config file not found at ${CONFIG_FILE}`); console.error('Run: bun note.ts config to create/edit it.'); process.exit(1); } const configContent = await fs.readFile(CONFIG_FILE, 'utf8'); const config: Config = {}; configContent.split('\n').forEach((line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) return; const [key, ...valueParts] = trimmed.split('='); if (key && valueParts.length > 0) { let value = valueParts.join('=').trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } config[key as keyof Config] = value; } }); if (!config.TOKEN || !config.REPO || !config.URL) { console.error(`❌ Config invalid. Please set TOKEN, REPO, and URL inside ${CONFIG_FILE}`); process.exit(1); } return config; } // --- HTTP Helpers --- async function fetchWithAuth(config: Config, url: string, options: RequestInit = {}): Promise { const headers = { Authorization: `Token ${config.TOKEN}`, ...options.headers, }; const response = await fetch(url, { ...options, headers }); if (!response.ok) { console.error(`❌ Request failed: ${response.status} ${response.statusText}`); console.error(`🔍 URL: ${url}`); console.error(`🔍 Headers:`, headers); try { const errorText = await response.text(); console.error(`🔍 Response body: ${errorText}`); } catch { console.error('🔍 Could not read response body'); } process.exit(1); } return response; } // --- Commands --- async function testConnection(config: Config): Promise { console.log('🔍 Testing connection...'); try { const response = await fetchWithAuth(config, `${config.URL}/ping/`); console.log(`✅ API connection successful: ${await response.text()}`); } catch { console.log('⚠️ API ping failed, trying repo access...'); try { await fetchWithAuth(config, `${config.URL}/${config.REPO}/`); console.log(`✅ Repo access successful`); } catch { console.error('❌ Both API ping and repo access failed'); } } } async function listFiles(config: Config): Promise { const url = `${config.URL}/${config.REPO}/dir/?p=/`; const response = await fetchWithAuth(config, url); try { const files = (await response.json()) as { name: string }[]; files.forEach((file) => console.log(file.name)); } catch { console.error('❌ Failed to parse response'); process.exit(1); } } async function catFile(config: Config, fileName: string): Promise { const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`); const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, ''); const content = await (await fetchWithAuth(config, downloadUrl)).text(); console.log(content); } async function uploadFile(config: Config, localFile: string, remoteFile?: string): Promise { if (!(await fs.stat(localFile)).isFile()) { console.error(`❌ File not found: ${localFile}`); process.exit(1); } const remoteName = remoteFile || path.basename(localFile); const uploadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/upload-link/?p=/`); const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, ''); const formData = new FormData(); formData.append('file', new Blob([await fs.readFile(localFile)]), remoteName); formData.append('filename', remoteName); formData.append('parent_dir', '/'); await fetchWithAuth(config, uploadUrl, { method: 'POST', body: formData }); console.log(`✅ Uploaded ${localFile} → ${remoteName}`); } async function removeFile(config: Config, fileName: string): Promise { await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`, { method: 'DELETE' }); console.log(`🗑️ Removed ${fileName}`); } async function moveFile(config: Config, oldName: string, newName: string): Promise { const url = `${config.URL}/${config.REPO}/file/?p=/${oldName}`; const formData = new FormData(); formData.append('operation', 'rename'); formData.append('newname', newName); await fetchWithAuth(config, url, { method: 'POST', body: formData }); console.log(`✏️ Renamed ${oldName} → ${newName}`); } async function downloadFile(config: Config, remoteFile: string, localFile?: string): Promise { const localName = localFile || remoteFile; const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${remoteFile}`); const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, ''); const buffer = Buffer.from(await (await fetchWithAuth(config, downloadUrl)).arrayBuffer()); await fs.writeFile(localName, buffer); console.log(`⬇️ Downloaded ${remoteFile} → ${localName}`); } async function getFileLink(config: Config, fileName: string): Promise { const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`); console.log(`🔗 Link for ${fileName}:\n${(await downloadUrlResponse.text()).replace(/"/g, '')}`); } function showHelp(): void { console.log(`note - simple CLI for wibu notes Usage: not3 ls List files not3 cat Show file content not3 cp [remote] Upload file not3 rm Remove file not3 mv Rename/move file not3 get [local] Download file not3 link Get file link/URL not3 test Test API connection not3 config Edit config (~/.note.conf) Config (~/.note.conf): TOKEN=your_seafile_token REPO=repos/ URL=your_seafile_url/api2 Version: ${version}`); } // --- Main --- async function not3(): Promise { const [cmd, ...args] = process.argv.slice(2); if (cmd === 'config') return editConfig(); const config = await loadConfig(); switch (cmd) { case 'test': return testConnection(config); case 'ls': return listFiles(config); case 'cat': return args[0] ? catFile(config, args[0]) : console.error('Usage: bun note.ts cat '); case 'cp': return args[0] ? uploadFile(config, args[0], args[1]) : console.error('Usage: bun note.ts cp [remote_file]'); case 'rm': return args[0] ? removeFile(config, args[0]) : console.error('Usage: bun note.ts rm '); case 'mv': return args[1] ? moveFile(config, args[0]!, args[1]) : console.error('Usage: bun note.ts mv '); case 'get': return args[0] ? downloadFile(config, args[0], args[1]) : console.error('Usage: bun note.ts get [local_file]'); case 'link': return args[0] ? getFileLink(config, args[0]) : console.error('Usage: bun note.ts link '); default: return showHelp(); } } not3().catch((error) => { console.error('❌ Error:', error); process.exit(1); }); /** ## 🔍 Analisis * Masalah yang ditemukan: - Banyak fungsi melakukan `process.exit(1)` → sulit diuji dan tidak modular. - Kode `fs` masih sync di beberapa tempat → blocking. - Repetisi pada parsing config & validasi. - Error handling kurang konsisten. * Code smells terdeteksi: - God function (`not3`) menangani semua kasus. - Campuran `sync` & `async` FS. - Hard exit (`process.exit`) dalam helper function. * Peluang perbaikan: - Refactor command ke modular handler. - Gunakan `await fs` untuk semua operasi IO. - Abstraksi error handling lebih konsisten. ## ✨ Kode yang Direfaktor * Semua `fs` sync diganti async. * Struktur command lebih ringkas dengan switch expression. * Konsolidasi error handling di `fetchWithAuth`. * Validasi config lebih sederhana. ## 📋 Perubahan yang Dilakukan * Prinsip yang diterapkan: - **SRP**: Setiap fungsi fokus pada 1 tugas. - **DRY**: Hilangkan duplikasi parsing config. - **KISS**: Struktur `switch` lebih ringkas. * Pattern yang digunakan: - Command dispatcher sederhana. * Pertimbangan performa: - Async IO (`fs.promises`). - Hindari blocking sync operations. ## 🎯 Manfaat yang Dicapai * Kemudahan pemeliharaan: Lebih modular, command handler jelas. * Keterbacaan: Struktur konsisten, error handling seragam. * Kemudahan pengembangan: Mudah menambahkan command baru. ## ⚡ Langkah Selanjutnya (Opsional) * Buat unit test untuk tiap command (mock API). * Pisahkan `config.ts`, `commands.ts`, `http.ts` ke file terpisah untuk arsitektur lebih bersih. * Gunakan library CLI (misal `commander`) untuk parsing argumen lebih robust. */