hlidskjalf 0.4.2 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.3]
9
+
10
+ ### Added
11
+
12
+ - **Pause / resume and force-kill controls** — press `p` to suspend the selected
13
+ workspace with `SIGSTOP` (it stays alive and holds its port but consumes no CPU)
14
+ and again to resume it with `SIGCONT`, restoring the status it held before
15
+ pausing; a new `paused` status shows in amber. Press `x` to force-kill a
16
+ wedged workspace with `SIGKILL` immediately — no graceful `SIGTERM` grace period
17
+ and no restart. A paused process is woken before any stop/restart/kill so the
18
+ signal lands promptly.
19
+
20
+ ### Changed
21
+
22
+ - **Lighter redraws under heavy output** — the dashboard now skips re-rendering
23
+ rows and the log panel whose content hasn't changed, so a flood of output from
24
+ one workspace no longer repaints every other row.
25
+ - **Header status dot reads three states at a glance** — green when every
26
+ workspace is up, green and hollow when some are up and some stopped, and amber
27
+ when any is paused; hollow grey when nothing is running.
28
+ - **Consistent process-control log notes** — the stop, restart, kill, and pause
29
+ messages now share one wording and each shows its signal, and a stop or kill
30
+ appends a line when the child has actually exited, not just when requested.
31
+
32
+ ### Fixed
33
+
34
+ - **Quitting with a paused workspace no longer hangs** — a suspended process is
35
+ woken so it acts on the shutdown signal at once, instead of waiting out the
36
+ five-second kill grace period.
37
+ - **Stopping a workspace no longer logs a phantom failure** — the dev runner's
38
+ `ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL` / "command failed with signal SIGTERM"
39
+ complaint, emitted as it forwards the stop we sent, is dropped rather than
40
+ shown as an error the user didn't cause.
41
+ - **Status glyphs sit on the baseline** — the half-circle and pause glyphs were
42
+ pulled from an oversized fallback font that floated them above the text;
43
+ indicators now stay within glyphs the primary font renders in line.
44
+
8
45
  ## [0.4.2]
9
46
 
10
47
  ### Changed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![NPM Version](https://img.shields.io/npm/v/hlidskjalf)
4
4
  ![NPM Last Update](https://img.shields.io/npm/last-update/hlidskjalf)
5
- ![GitHub License](https://img.shields.io/github/license/charliebeckstrand/hlidskjalf)
5
+ ![GitHub License](https://img.shields.io/github/license/charliebeckstrand/hlidskjalf?v=2)
6
6
 
7
7
  A Terminal User Interface for visualizing Turborepo tasks, built with [Ink](https://npm.im/ink).
8
8
 
@@ -31,8 +31,8 @@ pnpm dev
31
31
  | Option | Description |
32
32
  | --- | --- |
33
33
  | `filter` | Include specific workspaces (`--filter=web`). Append `...` for transitive dependencies (`--filter=web...`). |
34
- | `order` | Sort by `alphabetical` or `run` (`--order=run`). Defaults to `alphabetical`. |
35
- | `title` | Custom title for the header (`--title="My App"`). Defaults to `Hlidskjalf`. |
34
+ | `order` | Sort by `alphabetical` or `run` order (`--order=run`). Defaults to `alphabetical`. |
35
+ | `title` | Custom title (`--title="My App"`). Defaults to `Hlidskjalf`. |
36
36
  | `theme` | Colour theme (`--theme=niflheim` or `--theme=ice`). Defaults to `bifrost`. |
37
37
  | `metrics` | Show CPU and memory usage per workspace. Defaults to `false`. |
38
38
  | `watch` | Re-discover workspaces when `package.json` files change. Defaults to `true`; disable with `--watch=false`. |
@@ -97,11 +97,15 @@ stopped and dropped from the dashboard. Pass `--watch=false` (or set
97
97
 
98
98
  | Key | Action |
99
99
  | --- | --- |
100
- | `↑` / `↓` | Move the selection between workspaces |
100
+ | `↑` / `↓` (or `k` / `j`) | Move the selection between workspaces |
101
101
  | `s` | Stop the selected workspace (or start it again if stopped) |
102
+ | `p` | Pause the selected workspace with `SIGSTOP` (or resume it with `SIGCONT` if paused) |
103
+ | `x` | Force-kill the selected workspace with `SIGKILL`, without restarting it |
102
104
  | `r` | Restart the selected workspace |
103
105
  | `c` | Clear the logs for the selected workspace |
104
106
  | `PgUp` / `PgDn` | Scroll the log panel up / down a page |
107
+ | `Home` / `End` | Jump to the oldest / newest log line |
108
+ | `?` | Toggle help |
105
109
  | `q` | Quit |
106
110
 
107
111
  ## License
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { existsSync, readdirSync, realpathSync, readFileSync } from 'fs';
4
+ import { resolve, join, sep } from 'path';
5
+ import { pathToFileURL } from 'url';
6
+ import { stripVTControlCharacters } from 'util';
7
+
8
+ createRequire(import.meta.url);
9
+ var MAX_PARSE_LENGTH=4096,LOCAL_HOSTS=new Set(["localhost","127.0.0.1","[::1]","0.0.0.0"]);function localOrigin(raw){let cleaned=raw.replace(/[.,;:!?)}\]]+$/,""),parsed;try{parsed=new URL(cleaned);}catch{return}if(!(parsed.protocol!=="http:"&&parsed.protocol!=="https:")&&parsed.port&&LOCAL_HOSTS.has(parsed.hostname))return parsed.origin}var DTS=/\bDTS\b/,baseMatchers=[{pattern:/running on (https?:\/\/\S+)/,status:"ready"},{pattern:/listening on (https?:\/\/\S+)/,status:"ready"},{pattern:/listening at (https?:\/\/\S+)/,status:"ready"},{pattern:/started.*?(https?:\/\/localhost:\d+)/,status:"ready"},{pattern:/\bVITE\b.*?\bready in\b/i,status:"ready"},{pattern:/\bLocal:\s+(https?:\/\/\S+)/,status:"ready"},{pattern:/⚡️?\s*Build success/,status:"watching"},{pattern:/Build start/,status:"building"},{pattern:/Watching for changes/,status:"watching"},{pattern:/\[ERROR\]/,status:"error"},{pattern:/(?<!\/)error[\s:]/i,status:"error"},{pattern:/process exit/,status:"error"},{pattern:/\blistening\b/i,status:"ready"}],matchers=baseMatchers.map(m=>({...m,needsHttp:m.pattern.source.includes("http")})),ANY_NON_HTTP_MATCHER=new RegExp(matchers.filter(m=>!m.needsHttp).map(m=>`(?:${m.pattern.source})`).join("|"),"i");function parseLine(line){let truncated=line.length>MAX_PARSE_LENGTH?line.slice(0,MAX_PARSE_LENGTH):line;if(DTS.test(truncated))return {};let hasHttp=truncated.includes("http");if(!hasHttp&&!ANY_NON_HTTP_MATCHER.test(truncated))return {};for(let{pattern,status,needsHttp}of matchers){if(needsHttp&&!hasHttp)continue;let match=truncated.match(pattern);if(match){let url=match[1]?localOrigin(match[1]):void 0;return {status,url}}}return {}}function stripAnsi(text){return text.includes("\x1B")?stripVTControlCharacters(text):text}var NON_SGR_ESCAPES=/\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[?>=]*[\d;]*[A-Za-ln-z@~`]|\([A-Za-z]|[^[(\]\x1b])/g,BARE_CONTROLS=/[\x00-\x08\x0b-\x1a\x1c-\x1f\x7f]/g;function sanitizeForDisplay(text){let hasEscape=text.includes("\x1B"),hasControl=text.search(BARE_CONTROLS)!==-1;if(!hasEscape&&!hasControl)return text;let out=text;return hasEscape&&(out=out.replace(NON_SGR_ESCAPES,"")),hasControl&&(out=out.replace(BARE_CONTROLS,"")),out}var SHARED={success:"#15FA5A",warning:"#FACC15",error:"#F87171",pending:"#8D93A0",highlight:"#faf9f6",muted:"#8D93A0",dim:"#6B7280",separator:"#353940"},themes={bifrost:{accent:"#7C8EF2",accentBright:"#BEC7F9",url:"#E8EBFD",...SHARED},niflheim:{accent:"#22D3EE",accentBright:"#92E9F7",url:"#E7FAFD",...SHARED},muspelheim:{accent:"#F97316",accentBright:"#FCBB8D",url:"#FEF0E6",...SHARED},yggdrasil:{accent:"#22C55E",accentBright:"#73E79E",url:"#E9FBF0",...SHARED}},THEME_ALIASES={ice:"niflheim",fire:"muspelheim",earth:"yggdrasil"},DEFAULT_THEME="bifrost";function parseTheme(value){if(typeof value=="string")return value in themes?value:value in THEME_ALIASES?THEME_ALIASES[value]:void 0}var colors=themes[DEFAULT_THEME];function buildStatusDisplay(c){return {pending:{color:c.pending,label:"pending",icon:"\u25CB"},building:{color:c.warning,label:"building",icon:"\u25CB"},watching:{color:c.success,label:"watching",icon:"\u25CF"},ready:{color:c.success,label:"watching",icon:"\u25CF"},error:{color:c.error,label:"error",icon:"\u2716"},stopped:{color:c.pending,label:"stopped",icon:"\u25CB"},idle:{color:c.warning,label:"idle",icon:"\u25CB"},paused:{color:c.warning,label:"paused",icon:"\u25CB"},timeout:{color:c.error,label:"timeout",icon:"\u2716"}}}var statusDisplay=buildStatusDisplay(colors);function setTheme(name){colors=themes[name],statusDisplay=buildStatusDisplay(colors);}function formatCpu(cpu){return `${cpu.toFixed(1)}%`.padStart(6)}function formatMem(bytes){let formatted;return bytes<1024*1024?formatted=`${(bytes/1024).toFixed(0)} K`:bytes<1024*1024*1024?formatted=`${(bytes/(1024*1024)).toFixed(1)} M`:formatted=`${(bytes/(1024*1024*1024)).toFixed(1)} G`,formatted.padStart(7)}function memColor(bytes){return bytes>512*1024*1024?colors.error:bytes>256*1024*1024?colors.warning:colors.muted}function cpuColor(cpu){return cpu>80?colors.error:colors.muted}var HINTS="\u2191/\u2193 navigate | ? help | q quit";var OSC8="\x1B]8;;";function hyperlink(url,label=url){return `${OSC8}${url}\x07${label}${OSC8}\x07`}function truncateEnd(text,width){return width<=0?"":text.length<=width?text:width===1?"\u2026":`${text.slice(0,width-1)}\u2026`}var ENTER_ALT_SCREEN="\x1B[?1049h",EXIT_ALT_SCREEN="\x1B[?1049l";function enterAltScreen(stream=process.stdout){if(!stream.isTTY)return ()=>{};stream.write(ENTER_ALT_SCREEN);let restored=false,restore=()=>{restored||(restored=true,stream.write(EXIT_ALT_SCREEN));};return process.once("exit",restore),restore}function every(ms,fn){let timer=setInterval(fn,ms);return timer.unref(),()=>clearInterval(timer)}function isPlainObject(value){return typeof value=="object"&&value!==null&&!Array.isArray(value)}var VALID_PKG_NAME=/^(@[a-z0-9\-~][a-z0-9\-._~]*\/)?[a-z0-9\-~][a-z0-9\-._~]*$/;function isValidPackageName(name){return VALID_PKG_NAME.test(name)&&name.length<=214}function normalizeFilters(raw){return raw.map(v=>v.replace(/^\{(.+)\}$/,"$1")).filter(v=>{let name=v.endsWith("...")?v.slice(0,-3):v;return isValidPackageName(name)?true:(console.error(`Ignoring invalid filter: ${sanitizeForDisplay(name)}`),false)})}function stringRecord(value){if(!isPlainObject(value))return;let result={};for(let[key,v]of Object.entries(value))typeof v=="string"&&(result[key]=v);return result}function readJson(path){try{let raw=JSON.parse(readFileSync(path,"utf-8"));return isPlainObject(raw)?{name:typeof raw.name=="string"?raw.name:void 0,scripts:stringRecord(raw.scripts),dependencies:stringRecord(raw.dependencies)}:null}catch{return null}}function workspaceDeps(pkg){return Object.entries(pkg.dependencies??{}).filter(([name,v])=>v.startsWith("workspace:")&&isValidPackageName(name)).map(([name])=>name)}var kindOrder={package:0,app:1,service:1};function discover(root){let results=[],dirs=[["packages","package"],["apps","app"],["services","service"]],resolvedRoot=resolve(root);for(let[dir,kind]of dirs){let base=join(resolvedRoot,dir);if(existsSync(base))for(let entry of readdirSync(base,{withFileTypes:true})){if(!entry.isDirectory())continue;let entryPath=join(base,entry.name);try{if(!realpathSync(entryPath).startsWith(resolvedRoot+sep))continue}catch{continue}let pkg=readJson(join(entryPath,"package.json"));pkg?.name&&isValidPackageName(pkg.name)&&pkg.name!=="hlidskjalf"&&pkg.scripts?.dev&&results.push({name:pkg.name,kind,deps:workspaceDeps(pkg)});}}return results}function sortByDeps(workspaces){let names=new Set(workspaces.map(w=>w.name)),depCount=new Map;for(let workspace of workspaces){let count=0;for(let dep of workspace.deps)names.has(dep)&&count++;depCount.set(workspace,count);}return [...workspaces].sort((a,b)=>a.kind!==b.kind?kindOrder[a.kind]-kindOrder[b.kind]:(depCount.get(a)??0)-(depCount.get(b)??0))}function sortByName(workspaces){return [...workspaces].sort((a,b)=>a.kind!==b.kind?kindOrder[a.kind]-kindOrder[b.kind]:a.name.localeCompare(b.name))}function filterWorkspaces(workspaces,patterns){let byName=new Map(workspaces.map(w=>[w.name,w])),matches=new Set;for(let pattern of patterns){let transitive=pattern.endsWith("..."),name=transitive?pattern.slice(0,-3):pattern;byName.has(name)&&matches.add(name),transitive&&collectDeps(name,byName,matches);}return workspaces.filter(w=>matches.has(w.name))}function collectDeps(name,byName,collected){let workspace=byName.get(name);if(workspace)for(let dep of workspace.deps)byName.has(dep)&&!collected.has(dep)&&(collected.add(dep),collectDeps(dep,byName,collected));}function defineConfig(config){return config}var CONFIG_FILES=["hlidskjalf.config.ts","hlidskjalf.config.mjs","hlidskjalf.config.js"],PACKAGE_JSON_KEY="hlidskjalf";function validate(raw,source){if(!isPlainObject(raw))return console.error(`Ignoring ${source}: expected a config object.`),{};let config={};if(Array.isArray(raw.filter)){let strings=raw.filter.filter(v=>typeof v=="string"),filter=normalizeFilters(strings);filter.length&&(config.filter=filter);}(raw.order==="run"||raw.order==="alphabetical")&&(config.order=raw.order),typeof raw.title=="string"&&(config.title=sanitizeForDisplay(raw.title)),typeof raw.metrics=="boolean"&&(config.metrics=raw.metrics),typeof raw.watch=="boolean"&&(config.watch=raw.watch);let theme=parseTheme(raw.theme);return theme&&(config.theme=theme),config}function fromPackageJson(root){let path=join(root,"package.json");if(!existsSync(path))return {};let parsed;try{parsed=JSON.parse(readFileSync(path,"utf-8"));}catch{return {}}if(!isPlainObject(parsed))return {};let key=parsed[PACKAGE_JSON_KEY];return key===void 0?{}:validate(key,`package.json "${PACKAGE_JSON_KEY}" key`)}async function fromConfigFile(root){for(let name of CONFIG_FILES){let path=join(root,name);if(existsSync(path))try{let mod=await import(pathToFileURL(path).href);return validate(mod.default??mod,name)}catch(err){return console.error(`Ignoring ${name}: ${err instanceof Error?err.message:"failed to load"}`),{}}}return {}}async function loadConfig(root){let fromPkg=fromPackageJson(root),fromFile=await fromConfigFile(root);return {...fromPkg,...fromFile}}
10
+
11
+ export { DEFAULT_THEME, HINTS, THEME_ALIASES, colors, cpuColor, defineConfig, discover, enterAltScreen, every, filterWorkspaces, formatCpu, formatMem, hyperlink, loadConfig, memColor, normalizeFilters, parseLine, parseTheme, sanitizeForDisplay, setTheme, sortByDeps, sortByName, statusDisplay, stripAnsi, themes, truncateEnd };
package/dist/config.d.ts CHANGED
@@ -1,16 +1,12 @@
1
1
  /**
2
- * Shared presentation layer: the colour palette and status glyphs, the terminal
3
- * primitives the dashboard leans on (OSC 8 hyperlinks, alternate screen), value
4
- * formatters, and the timer helpers used across the polling code. Kept free of
5
- * Ink/React imports so every helper here can be unit tested directly.
2
+ * Colour palette and status glyphs. No Ink/React imports, so every helper is unit-testable.
6
3
  */
7
4
 
8
5
  /**
9
6
  * Built-in palettes, named for the realms of Norse cosmology (fitting for a tool named
10
- * after Odin's all-seeing high seat). `success`/`warning`/`error` stay semantically
11
- * legible — green-ish / amber-ish / red-ish — in every theme so a status glyph never
12
- * misreads; the personality lives in the accent and status colours, with the neutral
13
- * slots pulled from {@link SHARED}.
7
+ * after Odin's all-seeing high seat). `success`/`warning`/`error` stay green/amber/red
8
+ * across every theme so a status glyph never misreads; personality lives in the accent
9
+ * colours, with neutral slots pulled from {@link SHARED}.
14
10
  */
15
11
  declare const themes: {
16
12
  readonly bifrost: {
@@ -93,9 +89,9 @@ interface Config {
93
89
  /** Re-discover workspaces when `package.json` files change. Defaults to `true`. */
94
90
  watch?: boolean;
95
91
  /**
96
- * Colour theme. Defaults to `bifrost` (icy blues and purples). Accepts a realm name
97
- * (`niflheim`, `muspelheim`, `yggdrasil`) or an elemental alias (`ice`, `fire`, `earth`).
98
- * See {@link themes} for the palettes or define your own with {@link parseTheme}.
92
+ * Colour theme. Defaults to `bifrost` (electric purples and sky blues). Accepts a realm
93
+ * name (`niflheim`, `muspelheim`, `yggdrasil`) or an elemental alias (`ice`, `fire`,
94
+ * `earth`). See {@link themes} for the available palettes.
99
95
  */
100
96
  theme?: ThemeName;
101
97
  }
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- export { defineConfig, loadConfig } from './chunk-WSO44QZR.js';
3
+ export { defineConfig, loadConfig } from './chunk-P7MGC72I.js';
4
4
  import './chunk-4PLEQYIC.js';
5
5
 
6
6
  createRequire(import.meta.url);