just-bash-util 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 blindmansion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # just-bash-util
2
+
3
+ CLI command framework, config file discovery, and path utilities for [just-bash](https://www.npmjs.com/package/just-bash).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add just-bash-util just-bash
9
+ ```
10
+
11
+ ## Modules
12
+
13
+ ### `just-bash-util/command` — CLI framework
14
+
15
+ Type-safe command trees with fluent builders, inherited options, auto-generated help, and typo suggestions.
16
+
17
+ ```ts
18
+ import { Bash } from "just-bash";
19
+ import { command, o, f, a } from "just-bash-util/command";
20
+
21
+ const cli = command("mycli", {
22
+ description: "My CLI tool",
23
+ });
24
+
25
+ const serve = cli.command("serve", {
26
+ description: "Start the dev server",
27
+ options: {
28
+ port: o.number().default(3000).short("p").describe("Port to listen on"),
29
+ host: o.string().describe("Host to bind to"),
30
+ open: f().short("o").describe("Open browser"),
31
+ },
32
+ args: [a.string().name("entry").describe("Entry file")],
33
+ handler: (args, ctx) => {
34
+ // args is fully typed: { port: number; host: string | undefined; open: boolean; entry: string }
35
+ return { stdout: `Listening on :${args.port}`, stderr: "", exitCode: 0 };
36
+ },
37
+ });
38
+
39
+ const bash = new Bash({ customCommands: [cli.toCommand()] });
40
+ await bash.exec("mycli serve app.ts -p 8080");
41
+ ```
42
+
43
+ Commands can also be executed directly without just-bash:
44
+
45
+ ```ts
46
+ import type { Infer } from "just-bash-util/command";
47
+
48
+ // Extract handler args type externally (like z.infer)
49
+ type ServeArgs = Infer<typeof serve>;
50
+
51
+ // Execute from CLI tokens
52
+ await cli.execute(["serve", "app.ts", "-p", "8080"], ctx);
53
+
54
+ // Or invoke programmatically with typed args
55
+ await serve.invoke({ port: 8080, entry: "app.ts" }, ctx);
56
+ ```
57
+
58
+ **Features:**
59
+
60
+ - Subcommand nesting with automatic option inheritance
61
+ - `omitInherited` to exclude parent options from specific subcommands
62
+ - `--help` / `-h` auto-generated at every level
63
+ - `--no-<flag>` negation, `-abc` combined short flags, `--key=value` syntax
64
+ - `--` passthrough separator
65
+ - Environment variable fallbacks for options
66
+ - Levenshtein-based "did you mean?" suggestions for typos
67
+
68
+ ### `just-bash-util/config` — Config file discovery
69
+
70
+ Cosmiconfig-style config search that walks up the directory tree, trying conventional filenames at each level. Comments and trailing commas are supported out of the box.
71
+
72
+ ```ts
73
+ import { searchConfig } from "just-bash-util/config";
74
+
75
+ // Walks up from cwd trying: package.json#myapp, .myapprc, .myapprc.json, myapp.config.json
76
+ const result = await searchConfig(ctx, { name: "myapp" });
77
+ if (result) {
78
+ result.config; // parsed config object
79
+ result.filepath; // absolute path to the file that matched
80
+ result.isEmpty; // true if config is null/undefined/empty object
81
+ }
82
+
83
+ // Customize search places, starting directory, or add custom loaders
84
+ const result2 = await searchConfig(ctx, {
85
+ name: "myapp",
86
+ from: "/specific/start/dir",
87
+ searchPlaces: [".myapprc.json", "myapp.config.json"],
88
+ });
89
+ ```
90
+
91
+ **Layered / cascading configs** — pass `merge: true` to collect configs from every directory level and deep-merge them (closest wins):
92
+
93
+ ```ts
94
+ const result = await searchConfig(ctx, { name: "myapp", merge: true });
95
+ // e.g. /project/.myapprc.json → { indent: 2, rules: { semi: "error" } }
96
+ // /.myapprc.json → { indent: 4, color: true, rules: { semi: "warn" } }
97
+ // result.config → { indent: 2, color: true, rules: { semi: "error" } }
98
+ ```
99
+
100
+ Use `stopWhen` for ESLint-style `root: true` cascading stops:
101
+
102
+ ```ts
103
+ const result = await searchConfig(ctx, {
104
+ name: "myapp",
105
+ merge: true,
106
+ stopWhen: (cfg) => cfg.root === true,
107
+ });
108
+ ```
109
+
110
+ Also exports `loadConfig` for loading a known file path directly (e.g. when the user passes `--config ./path`), and `findUp` for locating files by name up the directory tree.
111
+
112
+ ### `just-bash-util/path` — Path utilities
113
+
114
+ Pure POSIX path operations with no Node.js dependency.
115
+
116
+ ```ts
117
+ import {
118
+ join,
119
+ resolve,
120
+ dirname,
121
+ basename,
122
+ extname,
123
+ relative,
124
+ parse,
125
+ normalize,
126
+ } from "just-bash-util/path";
127
+
128
+ join("src", "utils", "index.ts"); // "src/utils/index.ts"
129
+ dirname("/project/src/index.ts"); // "/project/src"
130
+ basename("src/index.ts", ".ts"); // "index"
131
+ relative("/a/b/c", "/a/d"); // "../../d"
132
+ ```
133
+
134
+ ## Peer dependencies
135
+
136
+ Requires [`just-bash`](https://www.npmjs.com/package/just-bash) ^2.9.6 — provides the `CommandContext` and `ExecResult` types used throughout.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,153 @@
1
+ // src/path/index.ts
2
+ var sep = "/";
3
+ var delimiter = ":";
4
+ function isAbsolute(path) {
5
+ return path.charCodeAt(0) === 47;
6
+ }
7
+ function normalize(path) {
8
+ if (path === "") return ".";
9
+ if (path === "/") return "/";
10
+ const isAbs = path.charCodeAt(0) === 47;
11
+ const trailingSlash = path.charCodeAt(path.length - 1) === 47;
12
+ const segments = path.split("/");
13
+ const result = [];
14
+ for (const seg of segments) {
15
+ if (seg === "" || seg === ".") continue;
16
+ if (seg === "..") {
17
+ if (isAbs) {
18
+ result.pop();
19
+ } else if (result.length > 0 && result[result.length - 1] !== "..") {
20
+ result.pop();
21
+ } else {
22
+ result.push("..");
23
+ }
24
+ } else {
25
+ result.push(seg);
26
+ }
27
+ }
28
+ let out = result.join("/");
29
+ if (isAbs) {
30
+ out = "/" + out;
31
+ }
32
+ if (trailingSlash && out.length > 1 && !out.endsWith("/")) {
33
+ out += "/";
34
+ }
35
+ return out || (isAbs ? "/" : trailingSlash ? "./" : ".");
36
+ }
37
+ function join(...paths) {
38
+ if (paths.length === 0) return ".";
39
+ const joined = paths.filter((p) => p !== "").join("/");
40
+ if (joined === "") return ".";
41
+ return normalize(joined);
42
+ }
43
+ function resolve(...paths) {
44
+ let resolved = "";
45
+ for (let i = paths.length - 1; i >= 0; i--) {
46
+ const p = paths[i];
47
+ if (!p) continue;
48
+ resolved = resolved ? `${p}/${resolved}` : p;
49
+ if (p.charCodeAt(0) === 47) break;
50
+ }
51
+ return normalize(resolved || ".");
52
+ }
53
+ function dirname(path) {
54
+ if (path === "") return ".";
55
+ if (path === "/") return "/";
56
+ let end = path.length;
57
+ while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
58
+ const trimmed = path.slice(0, end);
59
+ const i = trimmed.lastIndexOf("/");
60
+ if (i === -1) return ".";
61
+ if (i === 0) return "/";
62
+ return trimmed.slice(0, i);
63
+ }
64
+ function basename(path, ext) {
65
+ if (path === "") return "";
66
+ let end = path.length;
67
+ while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
68
+ const trimmed = path.slice(0, end);
69
+ if (trimmed === "/") return "";
70
+ const i = trimmed.lastIndexOf("/");
71
+ const base = i === -1 ? trimmed : trimmed.slice(i + 1);
72
+ if (ext && base.endsWith(ext) && base.length > ext.length) {
73
+ return base.slice(0, base.length - ext.length);
74
+ }
75
+ return base;
76
+ }
77
+ function extname(path) {
78
+ const base = basename(path);
79
+ if (base === "" || base === "." || base === "..") return "";
80
+ const i = base.lastIndexOf(".");
81
+ if (i <= 0) return "";
82
+ return base.slice(i);
83
+ }
84
+ function parse(path) {
85
+ if (path === "") return { root: "", dir: "", base: "", name: "", ext: "" };
86
+ const root = isAbsolute(path) ? "/" : "";
87
+ let end = path.length;
88
+ while (end > 1 && path.charCodeAt(end - 1) === 47) end--;
89
+ const p = path.slice(0, end);
90
+ const lastSlash = p.lastIndexOf("/");
91
+ let dir;
92
+ let base;
93
+ if (lastSlash === -1) {
94
+ dir = "";
95
+ base = p;
96
+ } else if (lastSlash === 0) {
97
+ dir = "/";
98
+ base = p.slice(1);
99
+ } else {
100
+ dir = p.slice(0, lastSlash);
101
+ base = p.slice(lastSlash + 1);
102
+ }
103
+ const ext = extname(base);
104
+ const name = ext ? base.slice(0, base.length - ext.length) : base;
105
+ return { root, dir, base, name, ext };
106
+ }
107
+ function format(pathObject) {
108
+ const { root = "", dir, base, name, ext } = pathObject;
109
+ const resolvedBase = base || `${name || ""}${ext || ""}`;
110
+ if (dir) {
111
+ if (dir === root) {
112
+ return `${dir}${resolvedBase}`;
113
+ }
114
+ return `${dir}/${resolvedBase}`;
115
+ }
116
+ return `${root}${resolvedBase}`;
117
+ }
118
+ function relative(from, to) {
119
+ const fromNorm = normalize(from);
120
+ const toNorm = normalize(to);
121
+ if (fromNorm === toNorm) return "";
122
+ const fromParts = fromNorm === "/" ? [""] : fromNorm.split("/");
123
+ const toParts = toNorm === "/" ? [""] : toNorm.split("/");
124
+ const fromAbs = fromNorm.charCodeAt(0) === 47;
125
+ const toAbs = toNorm.charCodeAt(0) === 47;
126
+ const startIdx = fromAbs && toAbs ? 1 : 0;
127
+ let common = startIdx;
128
+ const minLen = Math.min(fromParts.length, toParts.length);
129
+ while (common < minLen && fromParts[common] === toParts[common]) {
130
+ common++;
131
+ }
132
+ const ups = fromParts.length - common;
133
+ const rest = toParts.slice(common);
134
+ const parts = [];
135
+ for (let i = 0; i < ups; i++) parts.push("..");
136
+ for (const r of rest) parts.push(r);
137
+ return parts.join("/") || ".";
138
+ }
139
+
140
+ export {
141
+ sep,
142
+ delimiter,
143
+ isAbsolute,
144
+ normalize,
145
+ join,
146
+ resolve,
147
+ dirname,
148
+ basename,
149
+ extname,
150
+ parse,
151
+ format,
152
+ relative
153
+ };
@@ -0,0 +1,221 @@
1
+ import {
2
+ basename,
3
+ dirname,
4
+ extname,
5
+ join
6
+ } from "./chunk-DAO5RF73.js";
7
+
8
+ // src/config/find-up.ts
9
+ async function findUp(ctx, name, options) {
10
+ const names = Array.isArray(name) ? name : [name];
11
+ const from = options?.from ?? ctx.cwd;
12
+ const stopAt = options?.stopAt ?? "/";
13
+ let dir = from;
14
+ while (true) {
15
+ for (const n of names) {
16
+ const filepath = join(dir, n);
17
+ if (await ctx.fs.exists(filepath)) {
18
+ return filepath;
19
+ }
20
+ }
21
+ if (dir === stopAt) break;
22
+ const parent = dirname(dir);
23
+ if (parent === dir) break;
24
+ dir = parent;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // src/config/jsonc.ts
30
+ function stripJsonComments(input) {
31
+ let result = "";
32
+ let i = 0;
33
+ while (i < input.length) {
34
+ const ch = input[i];
35
+ if (ch === '"') {
36
+ let str = '"';
37
+ i++;
38
+ while (i < input.length) {
39
+ const c = input[i];
40
+ str += c;
41
+ if (c === "\\" && i + 1 < input.length) {
42
+ str += input[i + 1];
43
+ i += 2;
44
+ continue;
45
+ }
46
+ i++;
47
+ if (c === '"') break;
48
+ }
49
+ result += str;
50
+ continue;
51
+ }
52
+ if (ch === "/" && input[i + 1] === "/") {
53
+ i += 2;
54
+ while (i < input.length && input[i] !== "\n") i++;
55
+ continue;
56
+ }
57
+ if (ch === "/" && input[i + 1] === "*") {
58
+ i += 2;
59
+ while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
60
+ i += 2;
61
+ continue;
62
+ }
63
+ result += ch;
64
+ i++;
65
+ }
66
+ return result;
67
+ }
68
+ function parseJsonc(input) {
69
+ const withoutComments = stripJsonComments(input);
70
+ const withoutTrailingCommas = withoutComments.replace(/,\s*([}\]])/g, "$1");
71
+ return JSON.parse(withoutTrailingCommas);
72
+ }
73
+
74
+ // src/config/explorer.ts
75
+ function isPlainObject(value) {
76
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
77
+ }
78
+ function deepMerge(base, override) {
79
+ if (!isPlainObject(base) || !isPlainObject(override)) {
80
+ return override;
81
+ }
82
+ const result = { ...base };
83
+ for (const key of Object.keys(override)) {
84
+ result[key] = key in result ? deepMerge(result[key], override[key]) : override[key];
85
+ }
86
+ return result;
87
+ }
88
+ var defaultLoader = (content) => parseJsonc(content);
89
+ function defaultSearchPlaces(name) {
90
+ return [
91
+ "package.json",
92
+ `.${name}rc`,
93
+ `.${name}rc.json`,
94
+ `${name}.config.json`
95
+ ];
96
+ }
97
+ var builtinLoaders = {
98
+ ".json": defaultLoader,
99
+ "noExt": defaultLoader
100
+ };
101
+ function resolveLoader(filepath, customLoaders) {
102
+ const ext = extname(filepath);
103
+ const key = ext || "noExt";
104
+ if (customLoaders?.[key]) return customLoaders[key];
105
+ if (builtinLoaders[key]) return builtinLoaders[key];
106
+ return defaultLoader;
107
+ }
108
+ function checkEmpty(value) {
109
+ if (value == null) return true;
110
+ if (typeof value === "object" && Object.keys(value).length === 0) return true;
111
+ return false;
112
+ }
113
+ async function loadFileInternal(fs, filepath, customLoaders, packageJsonProp) {
114
+ const content = await fs.readFile(filepath);
115
+ const loader = resolveLoader(filepath, customLoaders);
116
+ let config = loader(content, filepath);
117
+ if (basename(filepath) === "package.json" && packageJsonProp !== false) {
118
+ if (config != null && typeof config === "object" && packageJsonProp in config) {
119
+ config = config[packageJsonProp];
120
+ } else {
121
+ return null;
122
+ }
123
+ }
124
+ return {
125
+ config,
126
+ filepath,
127
+ isEmpty: checkEmpty(config)
128
+ };
129
+ }
130
+ async function collectAll(ctx, options) {
131
+ const {
132
+ name,
133
+ from = ctx.cwd,
134
+ searchPlaces = defaultSearchPlaces(name),
135
+ loaders: customLoaders,
136
+ packageJsonProp = name,
137
+ stopAt = "/",
138
+ stopWhen
139
+ } = options;
140
+ const results = [];
141
+ let dir = from;
142
+ while (true) {
143
+ for (const place of searchPlaces) {
144
+ const filepath = join(dir, place);
145
+ if (!await ctx.fs.exists(filepath)) continue;
146
+ try {
147
+ const result = await loadFileInternal(ctx.fs, filepath, customLoaders, packageJsonProp);
148
+ if (result !== null) {
149
+ results.push(result);
150
+ if (stopWhen?.(result.config)) return results;
151
+ break;
152
+ }
153
+ } catch {
154
+ continue;
155
+ }
156
+ }
157
+ if (dir === stopAt) break;
158
+ const parent = dirname(dir);
159
+ if (parent === dir) break;
160
+ dir = parent;
161
+ }
162
+ return results;
163
+ }
164
+ function mergeResults(results) {
165
+ if (results.length === 0) return null;
166
+ if (results.length === 1) return results[0];
167
+ const merged = results.reduceRight(
168
+ (acc, r) => deepMerge(acc, r.config),
169
+ {}
170
+ );
171
+ return {
172
+ config: merged,
173
+ filepath: results[0].filepath,
174
+ isEmpty: checkEmpty(merged)
175
+ };
176
+ }
177
+ async function searchConfig(ctx, options) {
178
+ if (options.merge) {
179
+ const results = await collectAll(ctx, options);
180
+ return mergeResults(results);
181
+ }
182
+ const {
183
+ name,
184
+ from = ctx.cwd,
185
+ searchPlaces = defaultSearchPlaces(name),
186
+ loaders: customLoaders,
187
+ packageJsonProp = name,
188
+ stopAt = "/"
189
+ } = options;
190
+ let dir = from;
191
+ while (true) {
192
+ for (const place of searchPlaces) {
193
+ const filepath = join(dir, place);
194
+ if (!await ctx.fs.exists(filepath)) continue;
195
+ try {
196
+ const result = await loadFileInternal(ctx.fs, filepath, customLoaders, packageJsonProp);
197
+ if (result !== null) return result;
198
+ } catch {
199
+ continue;
200
+ }
201
+ }
202
+ if (dir === stopAt) break;
203
+ const parent = dirname(dir);
204
+ if (parent === dir) break;
205
+ dir = parent;
206
+ }
207
+ return null;
208
+ }
209
+ async function loadConfig(ctx, filepath, options) {
210
+ const {
211
+ loaders: customLoaders,
212
+ packageJsonProp = false
213
+ } = options ?? {};
214
+ return loadFileInternal(ctx.fs, filepath, customLoaders, packageJsonProp);
215
+ }
216
+
217
+ export {
218
+ findUp,
219
+ searchConfig,
220
+ loadConfig
221
+ };