numux 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.ts +14 -4
- package/src/completions.ts +6 -4
- package/src/config/expand-scripts.ts +96 -0
- package/src/config/validator.ts +13 -3
- package/src/index.ts +35 -6
- package/src/types.ts +6 -3
- package/src/ui/tabs.ts +13 -0
- package/src/utils/color.ts +9 -2
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface ParsedArgs {
|
|
|
19
19
|
logDir?: string
|
|
20
20
|
only?: string[]
|
|
21
21
|
exclude?: string[]
|
|
22
|
+
colors?: string[]
|
|
22
23
|
commands: string[]
|
|
23
24
|
named: Array<{ name: string; command: string }>
|
|
24
25
|
}
|
|
@@ -72,8 +73,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
72
73
|
result.noRestart = true
|
|
73
74
|
} else if (arg === '--no-watch') {
|
|
74
75
|
result.noWatch = true
|
|
75
|
-
} else if (arg === '
|
|
76
|
+
} else if (arg === '--config') {
|
|
76
77
|
result.configPath = consumeValue(arg)
|
|
78
|
+
} else if (arg === '-c' || arg === '--color') {
|
|
79
|
+
result.colors = consumeValue(arg)
|
|
80
|
+
.split(',')
|
|
81
|
+
.map(s => s.trim())
|
|
82
|
+
.filter(Boolean)
|
|
77
83
|
} else if (arg === '--log-dir') {
|
|
78
84
|
result.logDir = consumeValue(arg)
|
|
79
85
|
} else if (arg === '--only') {
|
|
@@ -128,13 +134,16 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
128
134
|
export function buildConfigFromArgs(
|
|
129
135
|
commands: string[],
|
|
130
136
|
named: Array<{ name: string; command: string }>,
|
|
131
|
-
options?: { noRestart?: boolean }
|
|
137
|
+
options?: { noRestart?: boolean; colors?: string[] }
|
|
132
138
|
): ResolvedNumuxConfig {
|
|
133
139
|
const processes: ResolvedNumuxConfig['processes'] = {}
|
|
134
140
|
const maxRestarts = options?.noRestart ? 0 : undefined
|
|
141
|
+
const colors = options?.colors
|
|
142
|
+
let colorIndex = 0
|
|
135
143
|
|
|
136
144
|
for (const { name, command } of named) {
|
|
137
|
-
|
|
145
|
+
const color = colors?.[colorIndex++ % colors.length]
|
|
146
|
+
processes[name] = { command, persistent: true, maxRestarts, ...(color ? { color } : {}) }
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
for (let i = 0; i < commands.length; i++) {
|
|
@@ -144,7 +153,8 @@ export function buildConfigFromArgs(
|
|
|
144
153
|
if (processes[name]) {
|
|
145
154
|
name = `${name}-${i}`
|
|
146
155
|
}
|
|
147
|
-
|
|
156
|
+
const color = colors?.[colorIndex++ % colors.length]
|
|
157
|
+
processes[name] = { command: cmd, persistent: true, maxRestarts, ...(color ? { color } : {}) }
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
return { processes }
|
package/src/completions.ts
CHANGED
|
@@ -22,7 +22,7 @@ _numux() {
|
|
|
22
22
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
23
23
|
|
|
24
24
|
case "$prev" in
|
|
25
|
-
|
|
25
|
+
--config)
|
|
26
26
|
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
27
27
|
return ;;
|
|
28
28
|
--log-dir)
|
|
@@ -38,7 +38,7 @@ _numux() {
|
|
|
38
38
|
esac
|
|
39
39
|
|
|
40
40
|
if [[ "$cur" == -* ]]; then
|
|
41
|
-
COMPREPLY=( $(compgen -W "-h --help -v --version -c --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
|
|
41
|
+
COMPREPLY=( $(compgen -W "-h --help -v --version -c --color --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
|
|
42
42
|
else
|
|
43
43
|
local subcmds="init validate exec completions"
|
|
44
44
|
COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )
|
|
@@ -63,7 +63,8 @@ _numux() {
|
|
|
63
63
|
_arguments -s \\
|
|
64
64
|
'(-h --help)'{-h,--help}'[Show help]' \\
|
|
65
65
|
'(-v --version)'{-v,--version}'[Show version]' \\
|
|
66
|
-
'(-c --
|
|
66
|
+
'(-c --color)'{-c,--color}'[Comma-separated colors for processes]' \\
|
|
67
|
+
'--config[Config file path]:file:_files' \\
|
|
67
68
|
'(-n --name)'{-n,--name}'[Named process (name=command)]:named process' \\
|
|
68
69
|
'(-p --prefix)'{-p,--prefix}'[Prefixed output mode]' \\
|
|
69
70
|
'--only[Only run these processes]:processes' \\
|
|
@@ -105,7 +106,8 @@ complete -c numux -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish
|
|
|
105
106
|
# Options
|
|
106
107
|
complete -c numux -s h -l help -d 'Show help'
|
|
107
108
|
complete -c numux -s v -l version -d 'Show version'
|
|
108
|
-
complete -c numux -s c -l
|
|
109
|
+
complete -c numux -s c -l color -r -d 'Comma-separated colors for processes'
|
|
110
|
+
complete -c numux -l config -rF -d 'Config file path'
|
|
109
111
|
complete -c numux -s n -l name -r -d 'Named process (name=command)'
|
|
110
112
|
complete -c numux -s p -l prefix -d 'Prefixed output mode'
|
|
111
113
|
complete -c numux -l only -r -d 'Only run these processes'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { NumuxConfig, NumuxProcessConfig } from '../types'
|
|
4
|
+
|
|
5
|
+
type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'
|
|
6
|
+
|
|
7
|
+
const LOCKFILE_PM: [string, PackageManager][] = [
|
|
8
|
+
['bun.lockb', 'bun'],
|
|
9
|
+
['bun.lock', 'bun'],
|
|
10
|
+
['yarn.lock', 'yarn'],
|
|
11
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
12
|
+
['package-lock.json', 'npm']
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export function detectPackageManager(pkgJson: Record<string, unknown>, cwd: string): PackageManager {
|
|
16
|
+
const field = pkgJson.packageManager
|
|
17
|
+
if (typeof field === 'string') {
|
|
18
|
+
const name = field.split('@')[0] as PackageManager
|
|
19
|
+
if (['npm', 'yarn', 'pnpm', 'bun'].includes(name)) return name
|
|
20
|
+
}
|
|
21
|
+
for (const [file, pm] of LOCKFILE_PM) {
|
|
22
|
+
if (existsSync(resolve(cwd, file))) return pm
|
|
23
|
+
}
|
|
24
|
+
return 'npm'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function expandScriptPatterns(config: NumuxConfig, cwd?: string): NumuxConfig {
|
|
28
|
+
const entries = Object.entries(config.processes)
|
|
29
|
+
const hasWildcard = entries.some(([name]) => name.startsWith('npm:'))
|
|
30
|
+
if (!hasWildcard) return config
|
|
31
|
+
|
|
32
|
+
const dir = config.cwd ?? cwd ?? process.cwd()
|
|
33
|
+
const pkgPath = resolve(dir, 'package.json')
|
|
34
|
+
|
|
35
|
+
if (!existsSync(pkgPath)) {
|
|
36
|
+
throw new Error(`npm: patterns require a package.json (looked in ${dir})`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf-8')) as Record<string, unknown>
|
|
40
|
+
const scripts = pkgJson.scripts as Record<string, string> | undefined
|
|
41
|
+
if (!scripts || typeof scripts !== 'object') {
|
|
42
|
+
throw new Error('package.json has no "scripts" field')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const scriptNames = Object.keys(scripts)
|
|
46
|
+
const pm = detectPackageManager(pkgJson, dir)
|
|
47
|
+
|
|
48
|
+
const expanded: Record<string, NumuxProcessConfig | string> = {}
|
|
49
|
+
|
|
50
|
+
for (const [name, value] of entries) {
|
|
51
|
+
if (!name.startsWith('npm:')) {
|
|
52
|
+
expanded[name] = value as NumuxProcessConfig | string
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pattern = name.slice(4) // strip "npm:"
|
|
57
|
+
const template = (value ?? {}) as Partial<NumuxProcessConfig>
|
|
58
|
+
|
|
59
|
+
if (template.command) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`"${name}": wildcard processes cannot have a "command" field (commands come from package.json scripts)`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const glob = new Bun.Glob(pattern)
|
|
66
|
+
const matches = scriptNames.filter(s => glob.match(s))
|
|
67
|
+
|
|
68
|
+
if (matches.length === 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`"${name}": no scripts matched pattern "${pattern}". Available scripts: ${scriptNames.join(', ')}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const colors = Array.isArray(template.color) ? template.color : undefined
|
|
75
|
+
const singleColor = typeof template.color === 'string' ? template.color : undefined
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < matches.length; i++) {
|
|
78
|
+
const scriptName = matches[i]
|
|
79
|
+
|
|
80
|
+
if (expanded[scriptName]) {
|
|
81
|
+
throw new Error(`"${name}": expanded script "${scriptName}" collides with an existing process name`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const color = colors ? colors[i % colors.length] : singleColor
|
|
85
|
+
|
|
86
|
+
const { color: _color, ...rest } = template
|
|
87
|
+
expanded[scriptName] = {
|
|
88
|
+
...rest,
|
|
89
|
+
command: `${pm} run ${scriptName}`,
|
|
90
|
+
...(color ? { color } : {})
|
|
91
|
+
} as NumuxProcessConfig
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ...config, processes: expanded }
|
|
96
|
+
}
|
package/src/config/validator.ts
CHANGED
|
@@ -72,8 +72,18 @@ export function validateConfig(raw: unknown, warnings?: ValidationWarning[]): Re
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Validate color hex format
|
|
75
|
-
if (typeof p.color === 'string'
|
|
76
|
-
|
|
75
|
+
if (typeof p.color === 'string') {
|
|
76
|
+
if (!HEX_COLOR_RE.test(p.color)) {
|
|
77
|
+
throw new Error(`Process "${name}".color must be a valid hex color (e.g. "#ff8800"), got "${p.color}"`)
|
|
78
|
+
}
|
|
79
|
+
} else if (Array.isArray(p.color)) {
|
|
80
|
+
for (const c of p.color) {
|
|
81
|
+
if (typeof c !== 'string' || !HEX_COLOR_RE.test(c)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Process "${name}".color entries must be valid hex colors (e.g. "#ff8800"), got "${c}"`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
const persistent = typeof p.persistent === 'boolean' ? p.persistent : true
|
|
@@ -113,7 +123,7 @@ export function validateConfig(raw: unknown, warnings?: ValidationWarning[]): Re
|
|
|
113
123
|
delay: typeof p.delay === 'number' && p.delay > 0 ? p.delay : undefined,
|
|
114
124
|
condition: typeof p.condition === 'string' && p.condition.trim() ? p.condition.trim() : undefined,
|
|
115
125
|
stopSignal: validateStopSignal(p.stopSignal),
|
|
116
|
-
color: typeof p.color === 'string' ? p.color : undefined,
|
|
126
|
+
color: typeof p.color === 'string' ? p.color : Array.isArray(p.color) ? (p.color as string[]) : undefined,
|
|
117
127
|
watch: validateStringOrStringArray(p.watch),
|
|
118
128
|
interactive: typeof p.interactive === 'boolean' ? p.interactive : false
|
|
119
129
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,12 @@ import { existsSync, writeFileSync } from 'node:fs'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { buildConfigFromArgs, filterConfig, parseArgs } from './cli'
|
|
5
5
|
import { generateCompletions } from './completions'
|
|
6
|
+
import { expandScriptPatterns } from './config/expand-scripts'
|
|
6
7
|
import { loadConfig } from './config/loader'
|
|
7
8
|
import { resolveDependencyTiers } from './config/resolver'
|
|
8
9
|
import { type ValidationWarning, validateConfig } from './config/validator'
|
|
9
10
|
import { ProcessManager } from './process/manager'
|
|
10
|
-
import type { ResolvedNumuxConfig } from './types'
|
|
11
|
+
import type { NumuxProcessConfig, ResolvedNumuxConfig } from './types'
|
|
11
12
|
import { App } from './ui/app'
|
|
12
13
|
import { PrefixDisplay } from './ui/prefix'
|
|
13
14
|
import { loadEnvFiles } from './utils/env-file'
|
|
@@ -28,7 +29,8 @@ Usage:
|
|
|
28
29
|
|
|
29
30
|
Options:
|
|
30
31
|
-n, --name <name=command> Add a named process
|
|
31
|
-
-c, --
|
|
32
|
+
-c, --color <colors> Comma-separated colors for processes (hex, e.g. #ff0,#0f0)
|
|
33
|
+
--config <path> Config file path (default: auto-detect)
|
|
32
34
|
-p, --prefix Prefixed output mode (no TUI, for CI/scripts)
|
|
33
35
|
--only <a,b,...> Only run these processes (+ their dependencies)
|
|
34
36
|
--exclude <a,b,...> Exclude these processes
|
|
@@ -100,7 +102,7 @@ async function main() {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
if (parsed.validate) {
|
|
103
|
-
const raw = await loadConfig(parsed.configPath)
|
|
105
|
+
const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
|
|
104
106
|
const warnings: ValidationWarning[] = []
|
|
105
107
|
let config = validateConfig(raw, warnings)
|
|
106
108
|
|
|
@@ -135,7 +137,7 @@ async function main() {
|
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
if (parsed.exec) {
|
|
138
|
-
const raw = await loadConfig(parsed.configPath)
|
|
140
|
+
const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
|
|
139
141
|
const config = validateConfig(raw)
|
|
140
142
|
const proc = config.processes[parsed.execName!]
|
|
141
143
|
if (!proc) {
|
|
@@ -169,9 +171,36 @@ async function main() {
|
|
|
169
171
|
const warnings: ValidationWarning[] = []
|
|
170
172
|
|
|
171
173
|
if (parsed.commands.length > 0 || parsed.named.length > 0) {
|
|
172
|
-
|
|
174
|
+
const hasNpmPatterns = parsed.commands.some(c => c.startsWith('npm:'))
|
|
175
|
+
if (hasNpmPatterns) {
|
|
176
|
+
// Expand npm: patterns into named processes, pass remaining commands as-is
|
|
177
|
+
const npmPatterns = parsed.commands.filter(c => c.startsWith('npm:'))
|
|
178
|
+
const otherCommands = parsed.commands.filter(c => !c.startsWith('npm:'))
|
|
179
|
+
const processes: Record<string, NumuxProcessConfig | string> = {}
|
|
180
|
+
for (const pattern of npmPatterns) {
|
|
181
|
+
const entry: Partial<NumuxProcessConfig> = {}
|
|
182
|
+
if (parsed.colors?.length) entry.color = parsed.colors
|
|
183
|
+
processes[pattern] = entry as NumuxProcessConfig
|
|
184
|
+
}
|
|
185
|
+
for (let i = 0; i < otherCommands.length; i++) {
|
|
186
|
+
const cmd = otherCommands[i]
|
|
187
|
+
let name = cmd.split(/\s+/)[0].split('/').pop()!
|
|
188
|
+
if (processes[name]) name = `${name}-${i}`
|
|
189
|
+
processes[name] = cmd
|
|
190
|
+
}
|
|
191
|
+
for (const { name, command } of parsed.named) {
|
|
192
|
+
processes[name] = command
|
|
193
|
+
}
|
|
194
|
+
const expanded = expandScriptPatterns({ processes })
|
|
195
|
+
config = validateConfig(expanded, warnings)
|
|
196
|
+
} else {
|
|
197
|
+
config = buildConfigFromArgs(parsed.commands, parsed.named, {
|
|
198
|
+
noRestart: parsed.noRestart,
|
|
199
|
+
colors: parsed.colors
|
|
200
|
+
})
|
|
201
|
+
}
|
|
173
202
|
} else {
|
|
174
|
-
const raw = await loadConfig(parsed.configPath)
|
|
203
|
+
const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
|
|
175
204
|
config = validateConfig(raw, warnings)
|
|
176
205
|
|
|
177
206
|
if (parsed.noRestart) {
|
package/src/types.ts
CHANGED
|
@@ -11,17 +11,20 @@ export interface NumuxProcessConfig {
|
|
|
11
11
|
delay?: number // ms to wait before starting the process (default: none)
|
|
12
12
|
condition?: string // env var name (prefix with ! to negate); process skipped if condition is falsy
|
|
13
13
|
stopSignal?: 'SIGTERM' | 'SIGINT' | 'SIGHUP' // signal for graceful stop (default: SIGTERM)
|
|
14
|
-
color?: string
|
|
14
|
+
color?: string | string[]
|
|
15
15
|
watch?: string | string[] // Glob patterns — restart process when matching files change
|
|
16
16
|
interactive?: boolean // default false — when true, keyboard input is forwarded to the process
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/** Config for npm: wildcard entries — command is derived from package.json scripts */
|
|
20
|
+
export type NumuxScriptPattern = Omit<NumuxProcessConfig, 'command'> & { command?: never }
|
|
21
|
+
|
|
22
|
+
/** Raw config as authored — processes can be string shorthand, full objects, or wildcard patterns */
|
|
20
23
|
export interface NumuxConfig {
|
|
21
24
|
cwd?: string // Global working directory, inherited by all processes
|
|
22
25
|
env?: Record<string, string> // Global env vars, merged into each process (process-level overrides)
|
|
23
26
|
envFile?: string | string[] // Global .env file(s), inherited by processes without their own envFile
|
|
24
|
-
processes: Record<string, NumuxProcessConfig | string>
|
|
27
|
+
processes: Record<string, NumuxProcessConfig | NumuxScriptPattern | string>
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/** Validated config with all shorthand expanded to full objects */
|
package/src/ui/tabs.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type CliRenderer,
|
|
3
|
+
type MouseEvent,
|
|
3
4
|
type OptimizedBuffer,
|
|
4
5
|
parseColor,
|
|
5
6
|
type RGBA,
|
|
@@ -59,6 +60,18 @@ class ColoredSelectRenderable extends SelectRenderable {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
protected onMouseEvent(event: MouseEvent): void {
|
|
64
|
+
if (event.type === 'down') {
|
|
65
|
+
const linesPerItem = (this as any).linesPerItem as number
|
|
66
|
+
const scrollOffset = (this as any).scrollOffset as number
|
|
67
|
+
const clickedIndex = scrollOffset + Math.floor(event.y / linesPerItem)
|
|
68
|
+
if (clickedIndex >= 0 && clickedIndex < this.options.length) {
|
|
69
|
+
this.setSelectedIndex(clickedIndex)
|
|
70
|
+
this.selectCurrent()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
private colorizeOptions(): void {
|
|
63
76
|
const fb = this.frameBuffer!
|
|
64
77
|
// Access internal layout state (private in TS, accessible at runtime)
|
package/src/utils/color.ts
CHANGED
|
@@ -51,13 +51,20 @@ const DEFAULT_ANSI_COLORS = [
|
|
|
51
51
|
/** Default palette as hex colors (for styled text rendering) */
|
|
52
52
|
const DEFAULT_HEX_COLORS = ['#00cccc', '#cccc00', '#cc00cc', '#0000cc', '#00cc00', '#ff5555', '#ffff55', '#ff55ff']
|
|
53
53
|
|
|
54
|
+
/** Resolve a color value (string or array) to a single hex string, or undefined. */
|
|
55
|
+
function resolveColor(color: string | string[] | undefined): string | undefined {
|
|
56
|
+
if (typeof color === 'string') return color
|
|
57
|
+
if (Array.isArray(color) && color.length > 0) return color[0]
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
/** Build a map of process names to ANSI color codes, using explicit config colors or a default palette. */
|
|
55
62
|
export function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
|
|
56
63
|
const map = new Map<string, string>()
|
|
57
64
|
if ('NO_COLOR' in process.env) return map
|
|
58
65
|
let paletteIndex = 0
|
|
59
66
|
for (const name of names) {
|
|
60
|
-
const explicit = config.processes[name]?.color
|
|
67
|
+
const explicit = resolveColor(config.processes[name]?.color)
|
|
61
68
|
if (explicit) {
|
|
62
69
|
map.set(name, hexToAnsi(explicit))
|
|
63
70
|
} else {
|
|
@@ -74,7 +81,7 @@ export function buildProcessHexColorMap(names: string[], config: ResolvedNumuxCo
|
|
|
74
81
|
if ('NO_COLOR' in process.env) return map
|
|
75
82
|
let paletteIndex = 0
|
|
76
83
|
for (const name of names) {
|
|
77
|
-
const explicit = config.processes[name]?.color
|
|
84
|
+
const explicit = resolveColor(config.processes[name]?.color)
|
|
78
85
|
if (explicit) {
|
|
79
86
|
map.set(name, explicit.startsWith('#') ? explicit : `#${explicit}`)
|
|
80
87
|
} else {
|