@tertium/hlpr 0.6.3 → 0.7.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/README.md CHANGED
@@ -38,6 +38,7 @@ hlpr ssh init dir
38
38
 
39
39
  # TypeScript commands (nested structure)
40
40
  hlpr file rename <directory> <style> [--dry|-n]
41
+ hlpr file mv <oldPath> <newPath> [--dry|-n]
41
42
 
42
43
  # Continue execution even if commands fail
43
44
  hlpr -f ssh init dir
@@ -50,6 +51,7 @@ See individual command documentation for detailed usage, options, and examples.
50
51
  ### TypeScript Commands
51
52
 
52
53
  - **[file rename](src/commands/file/rename/README.md)** - Recursively rename files/folders with various case styles
54
+ - **[file mv](src/commands/file/mv/README.md)** - Rename/move a single file and update all references to it (markdown links, wikilinks, imports)
53
55
  - **[help](src/commands/help/README.md)** - Display help information about hlpr commands
54
56
  - **[process list-port](src/commands/process/list-port/README.md)** - List processes running on a port
55
57
  - **[process kill-port](src/commands/process/kill-port/README.md)** - Kill processes on a port
@@ -0,0 +1,96 @@
1
+ # File Mv Command
2
+
3
+ Rename or move a single file and automatically update every reference to it — markdown links,
4
+ wikilinks, and quoted import/require/href/src paths — in every text file under a directory, recursively.
5
+
6
+ No extension filtering is applied when scanning: every text file under the root is checked, and
7
+ binary files are skipped automatically.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ hlpr file mv <oldPath> <newPath> [--root <dir>] [--dry|-n] [--force] [--no-update-content]
13
+ ```
14
+
15
+ ### Arguments
16
+
17
+ - `<oldPath>` - Path to the existing file
18
+ - `<newPath>` - New path or filename. If it has no directory separators, the file is renamed
19
+ in place (kept in the same directory as `<oldPath>`); otherwise it's treated as a full path
20
+ and the file is moved there (missing directories are created).
21
+ - `--root <dir>` - Directory to scan for references (default: current directory)
22
+ - `--dry` or `-n` - Preview changes without applying them
23
+ - `--force` - Overwrite the destination file if it already exists
24
+ - `--no-update-content` - Skip updating references in other files (only rename/move the file)
25
+
26
+ ## Examples
27
+
28
+ ```bash
29
+ # Rename a markdown file in place and fix every link to it
30
+ hlpr file mv docs/old-name.md new-name.md
31
+
32
+ # Preview what would change first
33
+ hlpr file mv docs/old-name.md new-name.md --dry
34
+
35
+ # Move a file into another folder
36
+ hlpr file mv docs/old-name.md docs/archive/old-name.md
37
+
38
+ # Only scan a specific subtree for references
39
+ hlpr file mv docs/old-name.md new-name.md --root docs
40
+
41
+ # Overwrite an existing destination file
42
+ hlpr file mv docs/old-name.md docs/existing.md --force
43
+ ```
44
+
45
+ ## What Gets Updated
46
+
47
+ For every text file under `--root` (default: current directory), the command rewrites:
48
+
49
+ - **Markdown links & images**: `[text](./old-name.md)`, `![alt](../old-name.md)`, including
50
+ `<path with spaces>` wrapping, `#heading` fragments, and `"title"` suffixes.
51
+ - **Wikilinks**: `[[old-name]]`, `[[old-name|Alias]]`, `[[old-name#Section]]`. Bare wikilinks
52
+ (no path, Obsidian-style) are matched by basename across the whole scanned root — but only
53
+ when that basename is unique. If another file shares the same basename anywhere under the
54
+ root, bare wikilinks are left untouched to avoid an ambiguous rewrite (the command reports
55
+ this in its output).
56
+ - **Quoted paths**: `'./old-name'`, `"../old-name.md"`, `` `./old-name` `` — covers JS/TS
57
+ imports and requires, and HTML `href`/`src` attributes.
58
+
59
+ Each reference is resolved relative to the *referencing file's own directory* (or to `--root`
60
+ for root-relative/leading-`/` links) before being matched, so files in different folders each
61
+ get the correct relative path to the new location — not just a blind text substitution.
62
+
63
+ Links to unrelated files, and links using absolute URLs (`http://`, `mailto:`, etc.), are never
64
+ touched.
65
+
66
+ ## Safety
67
+
68
+ - Errors out if the destination already exists, unless `--force` is passed.
69
+ - Handles case-only renames correctly on case-insensitive filesystems (Windows/macOS).
70
+ - `--dry` reports exactly what would be renamed and which files would be updated (and how many
71
+ references in each), without touching disk.
72
+ - Skips `.git` and `node_modules` when scanning for references.
73
+
74
+ ## TypeScript API
75
+
76
+ ```typescript
77
+ import { renameFile } from './commands/file/mv/mv.js'
78
+
79
+ const result = await renameFile('docs/old-name.md', 'new-name.md', {
80
+ root: 'docs',
81
+ dryRun: false,
82
+ force: false,
83
+ updateContent: true,
84
+ })
85
+
86
+ // result.from / result.to — resolved absolute paths
87
+ // result.updatedFiles — [{ file, count }, ...]
88
+ // result.bareBasenameAmbiguous — true if bare wikilinks were skipped due to a name clash
89
+ ```
90
+
91
+ ## See Also
92
+
93
+ - [Main hlpr README](../../../../README.md)
94
+ - [file rename](../rename/README.md) - bulk case-style renaming for whole directory trees
95
+ - [TypeScript Implementation](./mv.ts)
96
+ - [Tests](./mv.test.ts)
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/commands/file/mv/mv.ts
5
+ import fs from "fs/promises";
6
+ import path from "path";
7
+ async function exists(p) {
8
+ try {
9
+ await fs.access(p);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+ function pathsEqual(a, b) {
16
+ const ra = path.resolve(a);
17
+ const rb = path.resolve(b);
18
+ return process.platform === "win32" ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
19
+ }
20
+ function toPosix(p) {
21
+ return p.split(path.sep).join("/");
22
+ }
23
+ async function isTextFile(filePath) {
24
+ try {
25
+ const fd = await fs.open(filePath, "r");
26
+ const buffer = Buffer.alloc(512);
27
+ const { bytesRead } = await fd.read(buffer, 0, 512, 0);
28
+ await fd.close();
29
+ if (bytesRead === 0)
30
+ return true;
31
+ for (let i = 0;i < bytesRead; i++) {
32
+ if (buffer[i] === 0)
33
+ return false;
34
+ }
35
+ let printableCount = 0;
36
+ for (let i = 0;i < bytesRead; i++) {
37
+ const byte = buffer[i];
38
+ if (byte >= 32 && byte <= 126 || byte === 9 || byte === 10 || byte === 13) {
39
+ printableCount++;
40
+ }
41
+ }
42
+ return printableCount / bytesRead > 0.85;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+ var DEFAULT_IGNORED_DIRS = new Set([".git", "node_modules"]);
48
+ async function collectFiles(dir, out = []) {
49
+ const entries = await fs.readdir(dir, { withFileTypes: true });
50
+ for (const e of entries) {
51
+ if (e.isDirectory() && DEFAULT_IGNORED_DIRS.has(e.name))
52
+ continue;
53
+ const full = path.join(dir, e.name);
54
+ if (e.isDirectory()) {
55
+ await collectFiles(full, out);
56
+ } else if (e.isFile()) {
57
+ out.push(full);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ async function performRename(oldAbs, newAbs, force) {
63
+ await fs.mkdir(path.dirname(newAbs), { recursive: true });
64
+ const destExists = await exists(newAbs);
65
+ const caseOnlyChange = destExists && oldAbs !== newAbs && oldAbs.toLowerCase() === newAbs.toLowerCase();
66
+ if (destExists && !caseOnlyChange) {
67
+ if (!force) {
68
+ throw new Error(`Destination already exists: ${newAbs} (use --force to overwrite)`);
69
+ }
70
+ await fs.rm(newAbs);
71
+ }
72
+ if (caseOnlyChange) {
73
+ const tmp = newAbs + "__tmp_renaming__";
74
+ await fs.rename(oldAbs, tmp);
75
+ try {
76
+ await fs.rename(tmp, newAbs);
77
+ } catch (err) {
78
+ await fs.rename(tmp, oldAbs).catch(() => {});
79
+ throw err;
80
+ }
81
+ } else {
82
+ await fs.rename(oldAbs, newAbs);
83
+ }
84
+ }
85
+ function isExternalLink(target) {
86
+ return /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//");
87
+ }
88
+ function splitFragment(target) {
89
+ const idx = target.indexOf("#");
90
+ if (idx === -1)
91
+ return { path: target, fragment: "" };
92
+ return { path: target.slice(0, idx), fragment: target.slice(idx) };
93
+ }
94
+ function safeDecodeURIComponent(s) {
95
+ try {
96
+ return decodeURIComponent(s);
97
+ } catch {
98
+ return s;
99
+ }
100
+ }
101
+ function encodeLikeOriginal(newPathStr, original) {
102
+ if (/%20/.test(original)) {
103
+ return newPathStr.replace(/ /g, "%20");
104
+ }
105
+ return newPathStr;
106
+ }
107
+ function resolveTargetMatch(targetPath, fileDir, root, oldAbs, oldExt) {
108
+ const hadExt = !!path.extname(targetPath);
109
+ const withDefaultExt = (p) => hadExt ? [p] : [p, p + oldExt];
110
+ if (targetPath.startsWith("/")) {
111
+ const resolved = path.resolve(root, "." + targetPath);
112
+ return withDefaultExt(resolved).some((c) => pathsEqual(c, oldAbs)) ? "root-relative" : null;
113
+ }
114
+ const fileResolved = path.resolve(fileDir, targetPath);
115
+ if (withDefaultExt(fileResolved).some((c) => pathsEqual(c, oldAbs)))
116
+ return "file-relative";
117
+ const rootResolved = path.resolve(root, targetPath);
118
+ if (withDefaultExt(rootResolved).some((c) => pathsEqual(c, oldAbs)))
119
+ return "root-relative";
120
+ return null;
121
+ }
122
+ function tryRewriteTarget(rawTarget, fileDir, root, oldAbs, newAbs, allowBareBasename) {
123
+ if (!rawTarget || isExternalLink(rawTarget))
124
+ return null;
125
+ const { path: targetPath, fragment } = splitFragment(rawTarget);
126
+ if (!targetPath)
127
+ return null;
128
+ const decoded = safeDecodeURIComponent(targetPath);
129
+ const oldExt = path.extname(oldAbs);
130
+ const newExt = path.extname(newAbs);
131
+ const hadExt = !!path.extname(decoded);
132
+ const hasSlash = decoded.includes("/") || decoded.includes("\\");
133
+ let matchKind = null;
134
+ if (allowBareBasename && !hasSlash) {
135
+ const compareBase = hadExt ? decoded : decoded + oldExt;
136
+ if (pathsEqual(path.basename(compareBase), path.basename(oldAbs))) {
137
+ matchKind = "bare-basename";
138
+ }
139
+ }
140
+ if (!matchKind) {
141
+ matchKind = resolveTargetMatch(decoded, fileDir, root, oldAbs, oldExt);
142
+ }
143
+ if (!matchKind)
144
+ return null;
145
+ let newTargetPath;
146
+ if (matchKind === "bare-basename") {
147
+ const newBase = path.basename(newAbs, newExt);
148
+ newTargetPath = hadExt ? newBase + newExt : newBase;
149
+ } else {
150
+ const base = matchKind === "root-relative" ? root : fileDir;
151
+ let rel = toPosix(path.relative(base, newAbs));
152
+ if (!hadExt && newExt && rel.endsWith(newExt)) {
153
+ rel = rel.slice(0, -newExt.length);
154
+ }
155
+ const wasRootAbsolute = decoded.startsWith("/");
156
+ const wasExplicitRelative = decoded.startsWith("./") || decoded.startsWith("../");
157
+ if (wasRootAbsolute) {
158
+ newTargetPath = "/" + rel;
159
+ } else if (wasExplicitRelative || rel.startsWith("..")) {
160
+ newTargetPath = rel.startsWith(".") ? rel : "./" + rel;
161
+ } else {
162
+ newTargetPath = rel;
163
+ }
164
+ }
165
+ return encodeLikeOriginal(newTargetPath, targetPath) + fragment;
166
+ }
167
+ function parseMdTarget(raw) {
168
+ const s = raw.trim();
169
+ if (s.startsWith("<")) {
170
+ const end = s.indexOf(">");
171
+ if (end !== -1) {
172
+ return { link: s.slice(1, end), suffix: s.slice(end + 1), wrapped: true };
173
+ }
174
+ }
175
+ const titleIdx = s.search(/\s+["']/);
176
+ if (titleIdx !== -1) {
177
+ return { link: s.slice(0, titleIdx), suffix: s.slice(titleIdx), wrapped: false };
178
+ }
179
+ return { link: s, suffix: "", wrapped: false };
180
+ }
181
+ function parseWikiTarget(raw) {
182
+ const idx = raw.search(/[#|]/);
183
+ if (idx === -1)
184
+ return { link: raw, rest: "" };
185
+ return { link: raw.slice(0, idx), rest: raw.slice(idx) };
186
+ }
187
+ function rewriteContent(content, fileDir, root, oldAbs, newAbs, allowBareBasename) {
188
+ let count = 0;
189
+ let updated = content;
190
+ updated = updated.replace(/(!?\[[^\]]*\])\(([^)]+)\)/g, (full, textPart, rawTarget) => {
191
+ const { link, suffix, wrapped } = parseMdTarget(rawTarget);
192
+ const newLink = tryRewriteTarget(link, fileDir, root, oldAbs, newAbs, false);
193
+ if (newLink === null)
194
+ return full;
195
+ count++;
196
+ const rebuilt = wrapped ? `<${newLink}>` : newLink;
197
+ return `${textPart}(${rebuilt}${suffix})`;
198
+ });
199
+ updated = updated.replace(/\[\[([^\]]+)\]\]/g, (full, inner) => {
200
+ const { link, rest } = parseWikiTarget(inner);
201
+ const newLink = tryRewriteTarget(link, fileDir, root, oldAbs, newAbs, allowBareBasename);
202
+ if (newLink === null)
203
+ return full;
204
+ count++;
205
+ return `[[${newLink}${rest}]]`;
206
+ });
207
+ updated = updated.replace(/(['"`])([^'"`]*\/[^'"`]*)\1/g, (full, quote, rawTarget) => {
208
+ const newLink = tryRewriteTarget(rawTarget, fileDir, root, oldAbs, newAbs, false);
209
+ if (newLink === null)
210
+ return full;
211
+ count++;
212
+ return `${quote}${newLink}${quote}`;
213
+ });
214
+ return { content: updated, count };
215
+ }
216
+ async function renameFile(oldPathArg, newPathArg, options = {}) {
217
+ const oldAbs = path.resolve(oldPathArg);
218
+ const hasDirSeparator = newPathArg.includes("/") || newPathArg.includes("\\");
219
+ const newAbs = hasDirSeparator ? path.resolve(newPathArg) : path.join(path.dirname(oldAbs), newPathArg);
220
+ const root = path.resolve(options.root ?? process.cwd());
221
+ const updateContentEnabled = options.updateContent ?? true;
222
+ const dryRun = options.dryRun ?? false;
223
+ const force = options.force ?? false;
224
+ if (!await exists(oldAbs)) {
225
+ throw new Error(`Source file does not exist: ${oldAbs}`);
226
+ }
227
+ const oldStat = await fs.stat(oldAbs);
228
+ if (!oldStat.isFile()) {
229
+ throw new Error(`Source is not a file: ${oldAbs}`);
230
+ }
231
+ if (pathsEqual(oldAbs, newAbs) && oldAbs === newAbs) {
232
+ throw new Error("Source and destination are the same path");
233
+ }
234
+ const result = { from: oldAbs, to: newAbs, updatedFiles: [], bareBasenameAmbiguous: false };
235
+ const allFiles = updateContentEnabled ? await collectFiles(root) : [];
236
+ const oldBasenameLower = path.basename(oldAbs).toLowerCase();
237
+ const sameBasenameElsewhere = allFiles.some((f) => !pathsEqual(f, oldAbs) && path.basename(f).toLowerCase() === oldBasenameLower);
238
+ const allowBareBasename = !sameBasenameElsewhere;
239
+ result.bareBasenameAmbiguous = sameBasenameElsewhere;
240
+ if (!dryRun) {
241
+ await performRename(oldAbs, newAbs, force);
242
+ }
243
+ if (updateContentEnabled) {
244
+ for (const file of allFiles) {
245
+ if (pathsEqual(file, oldAbs) || pathsEqual(file, newAbs))
246
+ continue;
247
+ if (!await isTextFile(file))
248
+ continue;
249
+ const original = await fs.readFile(file, "utf-8");
250
+ const { content: updated, count } = rewriteContent(original, path.dirname(file), root, oldAbs, newAbs, allowBareBasename);
251
+ if (count > 0) {
252
+ result.updatedFiles.push({ file, count });
253
+ if (!dryRun) {
254
+ await fs.writeFile(file, updated, "utf-8");
255
+ }
256
+ }
257
+ }
258
+ }
259
+ return result;
260
+ }
261
+ if (import.meta.url.endsWith(process.argv[1]?.replace(/\\/g, "/"))) {
262
+ const rawArgs = process.argv.slice(2);
263
+ if (rawArgs.includes("--help") || rawArgs.includes("-h") || rawArgs.includes("/help") || rawArgs.includes("/h") || rawArgs.includes("/?")) {
264
+ console.log("Usage: mv <oldPath> <newPath> [--root <dir>] [--dry|-n] [--force] [--no-update-content]");
265
+ console.log("Renames/moves a single file and updates references to it (markdown links, wikilinks,");
266
+ console.log("imports, href/src) in every text file under <dir> (default: current directory), recursively.");
267
+ console.log("Options:");
268
+ console.log(" --root <dir> Directory to scan for references (default: current directory)");
269
+ console.log(" --dry, -n Preview changes without applying them");
270
+ console.log(" --force Overwrite destination file if it already exists");
271
+ console.log(" --no-update-content Skip updating references in other files");
272
+ process.exit(0);
273
+ }
274
+ const positional = [];
275
+ let root;
276
+ let dryRun = false;
277
+ let force = false;
278
+ let updateContent = true;
279
+ for (let i = 0;i < rawArgs.length; i++) {
280
+ const a = rawArgs[i];
281
+ if (a === "--dry" || a === "-n")
282
+ dryRun = true;
283
+ else if (a === "--force")
284
+ force = true;
285
+ else if (a === "--no-update-content")
286
+ updateContent = false;
287
+ else if (a === "--root")
288
+ root = rawArgs[++i];
289
+ else
290
+ positional.push(a);
291
+ }
292
+ const [oldPathArg, newPathArg] = positional;
293
+ if (!oldPathArg || !newPathArg) {
294
+ console.error("Usage: mv <oldPath> <newPath> [--root <dir>] [--dry|-n] [--force] [--no-update-content]");
295
+ process.exit(1);
296
+ }
297
+ renameFile(oldPathArg, newPathArg, { root, dryRun, force, updateContent }).then((result) => {
298
+ if (dryRun) {
299
+ console.log(`Dry run - would rename:
300
+ ${result.from} \u2192 ${result.to}`);
301
+ } else {
302
+ console.log(`Renamed:
303
+ ${result.from} \u2192 ${result.to}`);
304
+ }
305
+ if (result.updatedFiles.length > 0) {
306
+ console.log(`
307
+ ${dryRun ? "Would update" : "Updated"} ${result.updatedFiles.length} file(s):`);
308
+ result.updatedFiles.forEach(({ file, count }) => {
309
+ console.log(` ${file} (${count} reference${count === 1 ? "" : "s"})`);
310
+ });
311
+ } else {
312
+ console.log(`
313
+ No references found to update.`);
314
+ }
315
+ if (result.bareBasenameAmbiguous) {
316
+ console.log(`
317
+ Note: another file also named "${path.basename(result.from)}" exists under the scanned root, ` + "so bare wikilinks (e.g. [[name]]) were not auto-matched to avoid an ambiguous rewrite.");
318
+ }
319
+ }).catch((err) => {
320
+ console.error("Error:", err.message ?? err);
321
+ process.exit(1);
322
+ });
323
+ }
324
+ var mv_default = {
325
+ renameFile
326
+ };
327
+ export {
328
+ mv_default as default,
329
+ renameFile
330
+ };
@@ -0,0 +1 @@
1
+ // @bun
@@ -279,7 +279,7 @@ var rename_default = {
279
279
  renameRecursive
280
280
  };
281
281
  export {
282
- transformBasename,
282
+ rename_default as default,
283
283
  renameRecursive,
284
- rename_default as default
284
+ transformBasename
285
285
  };
@@ -0,0 +1 @@
1
+ // @bun
@@ -0,0 +1 @@
1
+ // @bun
@@ -179,6 +179,6 @@ if (import.meta.url.endsWith(process.argv[1]?.replace(/\\/g, "/"))) {
179
179
  }
180
180
  var kill_port_default = { killPort };
181
181
  export {
182
- killPort,
183
- kill_port_default as default
182
+ kill_port_default as default,
183
+ killPort
184
184
  };
@@ -159,6 +159,6 @@ if (import.meta.url.endsWith(process.argv[1]?.replace(/\\/g, "/"))) {
159
159
  }
160
160
  var list_port_default = { listPort };
161
161
  export {
162
- listPort,
163
- list_port_default as default
162
+ list_port_default as default,
163
+ listPort
164
164
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tertium/hlpr",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Windows and *Nix utility for typical programming activity",
5
5
  "author": "Vitalii Balabanov",
6
6
  "email": "tertiumnon@gmail.com",
@@ -22,7 +22,8 @@
22
22
  "@tertium/js": "^1.4.7"
23
23
  },
24
24
  "devDependencies": {
25
- "@types/node": "^22.13.10",
25
+ "@types/bun": "^1.4.1",
26
+ "@types/node": "^26.4.1",
26
27
  "typescript": "^5.8.3"
27
28
  },
28
29
  "bin": {