@samitouri / QOSami-HFS / commits / 68dd75bb

ux: show a "command" prompt in the console, to make clearer that you can enter commands

Massimo Melina committed Sep 28, 2025 at 11:53 UTC 68dd75bbeb9a7d12201dd261fa96d8051ed51291
7 files changed +81 -27
src/commands.ts
+61 -18
@@ -7,29 +7,69 @@ import { getUpdates, update } from './update'
7 import { openAdmin } from './listen'
8 import yaml from 'yaml'
9 import { BUILD_TIMESTAMP, VERSION } from './const'
10 -import { createInterface } from 'readline'
10 +import { createInterface, cursorTo } from 'node:readline'
11 +import { quitting } from './first'
12 import { getAvailablePlugins, mapPlugins, startPlugin, stopPlugin } from './plugins'
13 import { purgeFileAttr } from './fileAttr'
14 import { downloadPlugin } from './github'
15 import { Dict, formatBytes, formatSpeed, formatTimestamp, makeMatcher } from './cross'
16 import apiMonitor from './api.monitor'
17 import { argv } from './argv'
18 +import { consoleHint } from './consoleLog'
19 +import { debounceAsync } from './debounceAsync'
20
18 -if (!argv.updating && !showHelp)
21 +if (!argv.updating && !showHelp) {
22 try {
20 - /*
21 - is this try-block useful in case the stdin is unavailable?
22 - Not sure, but someone reported a problem using nohup https://github.com/rejetto/hfs/issues/74
23 - and I've found this example try-catching https://github.com/DefinitelyTyped/DefinitelyTyped/blob/dda83a906914489e09ca28afea12948529015d4a/types/node/readline.d.ts#L489
24 - */
25 - createInterface({ input: process.stdin }).on('line', parseCommandLine)
26 - console.log(`HINT: type "help" for help`)
23 + // 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
24 + const tty = process.stdin.isTTY && process.stdout.isTTY || undefined
25 + const prompter = createInterface({ input: process.stdin, output: process.stdout, prompt: tty && 'command> ' })
26 + .on('line', x => parseCommandLine(x).then(showPrompt))
27 +
28 + let isClean = true
29 + let cleaning: undefined | Promise<void>
30 + const showPrompt = tty && debounceAsync(async () => {
31 + await cleaning
32 + if (quitting || !tty) return
33 + prompter.prompt(true)
34 + isClean = false
35 + }, { wait: 100 })
36 + function clean() {
37 + if (isClean) return
38 + return cleaning ||= new Promise(resolve => {
39 + cursorTo(process.stdout, 0, undefined, () => {// we don't need to clean as long as the prompt is never longer then the printed line
40 + resolve()
41 + cleaning = undefined
42 + isClean = true
43 + })
44 + })
45 + }
46 +
47 + showPrompt?.()
48 + // print this hint when we have not been printing anything else for a while, to not get mixed too much
49 + let printHintOnce = tty && _.debounce(() => {
50 + consoleHint("this is an interactive console, you can enter commands")
51 + printHintOnce = undefined as any // never more
52 + }, 2000)
53 + _.each(console, (v: any, k) => {
54 + if (!_.isFunction(v)) return
55 + ;(console as any)[k] = async (...args: any[]) => {
56 + if (!quitting && tty)
57 + await clean()
58 + try { v(...args) }
59 + finally {
60 + showPrompt?.()
61 + printHintOnce?.()
62 + }
63 + }
64 + })
65 +
66 }
67 catch {
68 console.log("console commands not available")
69 }
70 +}
71
32 -function parseCommandLine(line: string) {
72 +async function parseCommandLine(line: string) {
73 if (!line) return
74 let [name, ...params] = line.trim().split(/ +/)
75 name = aliases[name!] || name
@@ -37,15 +77,18 @@ function parseCommandLine(line: string) {
77 if (cmd?.alias)
78 cmd = (commands as any)[cmd.alias]
79 if (!cmd)
40 - return console.error("cannot understand entered command, try 'help'")
80 + return console.error("invalid command, try 'help'")
81 if (cmd.cb.length > params.length)
82 return console.error("insufficient parameters, expected: " + cmd.params)
43 - Promise.resolve(cmd.cb(...params)).then(() => console.log("+++ command executed"),
44 - (err: any) => {
45 - if (typeof err !== 'string' && !err?.message)
46 - throw err
47 - console.error("command failed:", err.message || err)
48 - })
83 + try {
84 + await cmd.cb(...params)
85 + console.log("+++ command executed")
86 + }
87 + catch(err: any) {
88 + if (typeof err !== 'string' && !err?.message)
89 + throw err
90 + console.error("command failed:", err.message || err)
91 + }
92 }
93
94 const aliases: Dict<string> = { ver: 'version', exit: 'quit' }
@@ -54,7 +97,7 @@ const commands = {
97 help: {
98 params: '',
99 cb() {
57 - console.log("supported commands:",
100 + console.log("available commands:",
101 ..._.map(commands, ({ params }, name) =>
102 '\n - ' + name + ' ' + params))
103 }
src/consoleLog.ts
+4
@@ -37,4 +37,8 @@ function safeJoin(a: unknown[]): string {
37 }
38 }).join(' ')
39 }
40 +}
41 +
42 +export function consoleHint(msg: string) {
43 + console.log("HINT: "+ msg)
44 }
\ No newline at end of file
src/first.ts
+8 -2
@@ -7,9 +7,15 @@ export function onProcessExit(cb: ProcessExitHandler) {
7 cbs.add(cb)
8 return () => cbs.delete(cb)
9 }
10 +
11 +export let quitting = false
12 +onProcessExit(() => quitting = true)
13 +
14 onFirstEvent(process, ['exit', 'SIGQUIT', 'SIGTERM', 'SIGINT', 'SIGHUP'], signal =>
11 - Promise.allSettled(Array.from(cbs).map(cb => cb(signal))).then(() =>
12 - process.exit(0)))
15 + Promise.allSettled(Array.from(cbs).map(cb => cb(signal))).then(() => {
16 + console.log('quitting')
17 + process.exit(0)
18 + }))
19
20 export function onFirstEvent(emitter:EventEmitter, events: string[], cb: (...args:any[])=> void) {
21 let already = false
src/listen.ts
+3 -2
@@ -22,6 +22,7 @@ import { isIPv6 } from 'net'
22 import { defaultBaseUrl } from './nat'
23 import { storedMap } from './persistence'
24 import { argv } from './argv'
25 +import { consoleHint } from './consoleLog'
26
27 interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
28 let httpSrv: undefined | http.Server & ServerExtra
@@ -55,7 +56,7 @@ const considerHttp = debounceAsync(async () => {
56 if (port === PORT_DISABLED) return
57 if (!await startServer(httpSrv, { port, host }))
58 if (port !== 80)
58 - return console.log(`HINT: try specifying a different port, enter this command: config ${portCfg.key()} 1080`)
59 + return consoleHint(`try specifying a different port, enter this command: config ${portCfg.key()} 1080`)
60 else if (!await startServer(httpSrv, { port: 8080, host }))
61 return
62 httpSrv.on('connection', newConnection)
@@ -81,7 +82,7 @@ export function openAdmin() {
82 console.warn("cannot launch browser on this machine >PLEASE< open your browser and reach one of these (you may need a different address)",
83 ...Object.values(await getUrls()).flat().map(x => '\n - ' + x + ADMIN_URI))
84 if (! anyAccountCanLoginAdmin())
84 - console.log(`HINT: you can enter command: create-admin YOUR_PASSWORD`)
85 + consoleHint(`you can enter this command: create-admin YOUR_PASSWORD`)
86 })
87 return true
88 }
src/perm.ts
+3 -3
@@ -163,15 +163,15 @@ export function addAccount(username: string, props: Partial<Account>, updateExis
163 if (account && !updateExisting) return
164 account = setHidden(account || {}, { username }) // hidden so that stringification won't include it
165 Object.assign(account, _.pickBy(props, Boolean))
166 - accounts.set(accounts =>
167 - Object.assign(accounts, { [username]: account }))
166 + accounts.set(was =>
167 + Object.assign(was, { [username]: account }))
168 return updateAccount(account, account).then(() => account!)
169 }
170
171 export function delAccount(username: string) {
172 if (!getAccount(username))
173 return false
174 - accounts.set(x => _.omit(x, normalizeUsername(username)) )
174 + accounts.set(was => _.omit(was, normalizeUsername(username)) )
175 saveAccountsAsap()
176 return true
177 }
src/util-files.ts
+1 -1
@@ -1,7 +1,7 @@
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 { access, mkdir, readFile, stat } from 'fs/promises'
4 -import { Promisable, try_, wait, isWindowsDrive } from './misc'
4 +import { Promisable, try_, wait, isWindowsDrive } from './cross'
5 import { createWriteStream, mkdirSync, watch, ftruncate } from 'fs'
6 import { basename, dirname } from 'path'
7 import glob from 'fast-glob'
src/watchLoad.ts
+1 -1
@@ -2,7 +2,7 @@
2
3 import { FSWatcher, watch } from 'fs'
4 import fs from 'fs/promises'
5 -import { readFileBusy } from './misc'
5 +import { readFileBusy } from './util-files'
6 import { debounceAsync } from './debounceAsync'
7 import { BetterEventEmitter } from './events'
8