main
ts 229 lines 8.38 KB
Raw
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 import { createAdmin, getAccount, updateAccount } from './perm'
4 import { configKeyExists, setConfig, getWholeConfig, showHelp } from './config'
5 import _ from 'lodash'
6 import { getBestUpdate, update } from './update'
7 import { openAdmin } from './listen'
8 import yaml from 'yaml'
9 import { BUILD_TIMESTAMP, DEV, VERSION } from './const'
10 import { createInterface, cursorTo } from 'node:readline'
11 import { quitting } from './first'
12 import { getInactivePlugins, mapPlugins, startPlugin, stopPlugin } from './plugins'
13 import { purgeFileAttr } from './fileAttr'
14 import { downloadPlugin } from './github'
15 import { Dict, formatBytes, formatPerc, formatSpeed, formatTimestamp, makeMatcher, with_ } from './cross'
16 import apiMonitor, { inferOperation, serializeConnection } from './api.monitor'
17 import { getConnections } from './connections'
18 import { argv } from './argv'
19 import { getServerStatus } from './listen'
20
21 let debugEnabled = argv.debug || process.env.HFS_DEBUG || DEV
22
23 if (!argv.updating && !showHelp) {
24 try {
25 // Not sure if the try is necessary for when stdin is unavailable, but someone reported a problem using nohup https://github.com/rejetto/hfs/issues/74 and I've found this example try-catching https://github.com/DefinitelyTyped/DefinitelyTyped/blob/dda83a906914489e09ca28afea12948529015d4a/types/node/readline.d.ts#L489
26 const tty = process.stdin.isTTY && process.stdout.isTTY || undefined
27 const prompter = createInterface({ input: process.stdin, output: process.stdout, prompt: tty && 'command> ' })
28 .on('line', x => parseCommandLine(x).then(showPrompt))
29 .on('SIGINT', () => process.emit('SIGINT')) // readline swallows the first ctrl+c unless we forward it to process-level handlers
30
31 let isClean = true
32 const showPrompt = tty && _.debounce(() => {
33 if (quitting || !tty || !isClean) return
34 prompter.prompt(true)
35 isClean = false
36 }, 100)
37
38 function clean() {
39 if (isClean) return
40 // keep console methods synchronous: reposition the cursor immediately and let stream ordering preserve output sequence
41 cursorTo(process.stdout, 0) // we don't need to clean as long as the prompt is never longer than the printed line
42 isClean = true
43 }
44
45 showPrompt?.()
46 for (const k of ['log', 'warn', 'error', 'debug'] as const) {
47 const original = console[k]
48 ;(console as any)[k] = (...args: any[]) => {
49 if (k === 'debug' && !debugEnabled) return
50 if (!quitting && tty)
51 clean()
52 try { original(...args) }
53 finally {
54 showPrompt?.()
55 }
56 }
57 }
58 }
59 catch {
60 console.log("Console commands not available")
61 const original = console.debug
62 console.debug = (...args: any[]) => debugEnabled && original(...args)
63 }
64 }
65
66 async function parseCommandLine(line: string) {
67 const tokens = Array.from(line.trim().matchAll(/"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|((?:\\.|[^\s"'\\])+)/g)).map(m =>
68 (m[1] ?? m[2] ?? m[3] ?? '').replace(/\\([\\ "'"])/g, '$1')) // unescape
69 if (!tokens.length) return
70 let [name, ...params] = tokens
71 name = aliases[name!] || name
72 let cmd = (commands as any)[name!]
73 if (cmd?.alias)
74 cmd = (commands as any)[cmd.alias]
75 if (!cmd)
76 return console.error("Invalid command, try 'help'")
77 if (cmd.cb.length > params.length)
78 return console.error("Insufficient parameters, expected: " + cmd.params)
79 try {
80 await cmd.cb(...params)
81 console.log("+++ Command executed")
82 }
83 catch(err: any) {
84 if (typeof err !== 'string' && !err?.message)
85 throw err
86 console.error("Command failed:", err.message || err)
87 }
88 }
89
90 const aliases: Dict<string> = { ver: 'version', exit: 'quit' }
91
92 const commands = {
93 help: {
94 params: '',
95 cb() {
96 console.log("Available commands:",
97 ..._.map(commands, ({ params }, name) =>
98 '\n - ' + name + ' ' + params))
99 }
100 },
101 'show-admin': {
102 params: '',
103 cb(){
104 openAdmin()
105 }
106 },
107 'create-admin': {
108 params: '<password> [<username>=admin]',
109 cb: createAdmin
110 },
111 'change-password': {
112 params: '<user> <password>',
113 async cb(user: string, password: string) {
114 const acc = getAccount(user)
115 if (!acc)
116 throw "user doesn't exist"
117 await updateAccount(acc!, { password })
118 }
119 },
120 config: {
121 params: '<key> <value>',
122 async cb(key: string, value: string) {
123 if (!configKeyExists(key))
124 throw "specified key doesn't exist"
125 let v: any = value
126 try { v = JSON.parse(v) }
127 catch {}
128 await setConfig({ [key]: v })
129 }
130 },
131 'get-config': {
132 params: '[<key-mask>]',
133 cb(key='*') {
134 const matcher = makeMatcher(key)
135 const filtered = _.pickBy(getWholeConfig({}), (_v, k) => matcher(k))
136 console.log('\n' + yaml.stringify(filtered, { lineWidth:1000 }).trim())
137 }
138 },
139 quit: {
140 params: '',
141 cb() {
142 process.emit('SIGTERM')
143 }
144 },
145 update: {
146 params: '[<version>=latest]',
147 cb: update,
148 },
149 'check-update': {
150 params: '',
151 async cb() {
152 const update = await getBestUpdate()
153 if (!update)
154 throw "you already have the latest version: " + VERSION
155 console.log("New version available", update.name)
156 }
157 },
158 version: {
159 params: '',
160 cb() {
161 console.log(VERSION, 'build', BUILD_TIMESTAMP)
162 }
163 },
164 debug: {
165 params: '',
166 cb() {
167 debugEnabled = !debugEnabled
168 console.log(`Debug messages ${debugEnabled ? "on" : "off"}`)
169 }
170 },
171 'start-plugin': {
172 params: '<name>',
173 cb: startPlugin,
174 },
175 'stop-plugin': {
176 params: '<name>',
177 cb: stopPlugin,
178 },
179 'download-plugin': {
180 params: '<githubUser/repo>',
181 cb: downloadPlugin,
182 },
183 'list-plugins': {
184 params: '',
185 cb() {
186 mapPlugins(p => console.log('On:', p.id), false)
187 getInactivePlugins().map(p => console.log('Off:', p.id))
188 }
189 },
190 'purge-file-attr': {
191 params: '',
192 cb: purgeFileAttr,
193 },
194 transfers: {
195 params: '',
196 cb() {
197 const transfers = getConnections().map(serializeConnection).filter(x => x.op === 'upload' || x.op === 'download')
198 if (!transfers.length)
199 return console.log("No ongoing uploads/downloads")
200 console.table(transfers.map(x => ({
201 type: x.op,
202 progress: with_(x.opProgress ?? x.opOffset, v => v == null ? '' : formatPerc(v)),
203 transferred: formatBytes(Math.max(x.sent || 0, x.got || 0)),
204 total: x.opTotal == null ? '' : formatBytes(x.opTotal),
205 speed: formatSpeed(Math.max(x.outSpeedKb || 0, x.inSpeedKb || 0) * 1000),
206 user: x.user,
207 path: x.path,
208 })))
209 }
210 },
211 status: {
212 params: '',
213 async cb() {
214 const ports = await getServerStatus(false)
215 console.log(_.map(ports, (x, k) =>
216 `${k.toUpperCase()} ${x.configuredPort < 0 ? "disabled" : x.listening ? `on port ${x.port}` : (x.error || "not working")}`
217 ).join(""))
218 const operations = _.countBy(getConnections(), x => x.ctx && inferOperation(x.ctx).op)
219 console.log(`Active downloads ↑ ${operations.download || 0} – uploads ↓ ${operations.upload || 0}`)
220 const conn = (await apiMonitor.get_connection_stats().next()).value
221 if (conn) {
222 const {sent_got: sg} = conn
223 console.log(`Speed ↑ ${formatSpeed(conn.outSpeedKb * 1000)}${formatSpeed(conn.inSpeedKb * 1000)}`)
224 console.log(`Transferred ↑ ${formatBytes(sg[0])}${formatBytes(sg[1])} since ${formatTimestamp(sg[2])}`)
225 console.log(`Connections ${conn.connections} (${conn.ips} IPs)`)
226 }
227 }
228 }
229 }