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