@qodalis/cli-files 2.0.0-beta.8 → 2.0.0-rc.1

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/public-api.js CHANGED
@@ -3,11 +3,14 @@
3
3
  var cliCore = require('@qodalis/cli-core');
4
4
 
5
5
  // src/lib/interfaces/i-file-system-service.ts
6
- var IFileSystemService_TOKEN = "cli-file-system-service";
6
+ var IFileSystemService_TOKEN = cliCore.createCliToken("cli-file-system-service");
7
7
 
8
8
  // src/lib/version.ts
9
- var LIBRARY_VERSION = "2.0.0-beta.8";
9
+ var LIBRARY_VERSION = "2.0.0-rc.1";
10
10
  var API_VERSION = 2;
11
+ function splitArgs(raw) {
12
+ return cliCore.splitShellWords(raw);
13
+ }
11
14
 
12
15
  // src/lib/processors/cli-ls-command-processor.ts
13
16
  var CliLsCommandProcessor = class {
@@ -32,41 +35,63 @@ var CliLsCommandProcessor = class {
32
35
  description: "Use long listing format",
33
36
  required: false,
34
37
  type: "boolean"
38
+ },
39
+ {
40
+ name: "human-readable",
41
+ aliases: ["h"],
42
+ description: "With -l, print sizes like 1.5K and 2M",
43
+ required: false,
44
+ type: "boolean"
35
45
  }
36
46
  ];
47
+ this.humanReadable = false;
37
48
  }
38
49
  async processCommand(command, context) {
39
- const fs = context.services.get(
50
+ const fs = context.services.getRequired(
40
51
  IFileSystemService_TOKEN
41
52
  );
42
- const targetPath = command.value || fs.getCurrentDirectory();
53
+ const paths = splitArgs(command.value || "");
43
54
  const showAll = command.args["all"] || command.args["a"];
44
55
  const longFormat = command.args["long"] || command.args["l"];
45
- try {
46
- const entries = fs.listDirectory(targetPath);
47
- const filtered = showAll ? entries : entries.filter((e) => !e.name.startsWith("."));
48
- if (filtered.length === 0) {
49
- return;
50
- }
51
- filtered.sort((a, b) => {
52
- if (a.type !== b.type) {
53
- return a.type === "directory" ? -1 : 1;
56
+ this.humanReadable = !!(command.args["human-readable"] || command.args["h"]);
57
+ const targets = paths.length > 0 ? paths : [fs.getCurrentDirectory()];
58
+ const piped = [];
59
+ targets.forEach((targetPath, index) => {
60
+ try {
61
+ const node = fs.getNode(targetPath);
62
+ const entries = node && node.type !== "directory" ? [{ ...node, name: targetPath }] : fs.listDirectory(targetPath);
63
+ const filtered = showAll ? entries : entries.filter((e) => !e.name.startsWith("."));
64
+ if (targets.length > 1) {
65
+ if (index > 0) context.writer.writeln();
66
+ context.writer.writeln(`${targetPath}:`);
54
67
  }
55
- return a.name.localeCompare(b.name);
56
- });
57
- if (longFormat) {
58
- this.writeLongFormat(filtered, context);
59
- } else {
60
- this.writeShortFormat(filtered, context);
68
+ if (filtered.length === 0) {
69
+ return;
70
+ }
71
+ filtered.sort((a, b) => {
72
+ if (a.type !== b.type) {
73
+ return a.type === "directory" ? -1 : 1;
74
+ }
75
+ return a.name.localeCompare(b.name);
76
+ });
77
+ if (longFormat) {
78
+ this.writeLongFormat(filtered, context);
79
+ } else {
80
+ this.writeShortFormat(filtered, context);
81
+ piped.push(...filtered.map((e) => e.name));
82
+ }
83
+ } catch (e) {
84
+ context.writer.writeError(e.message);
61
85
  }
62
- } catch (e) {
63
- context.writer.writeError(e.message);
86
+ });
87
+ if (!longFormat && piped.length > 0) {
88
+ context.process.output(piped.join("\n"));
64
89
  }
65
90
  }
66
91
  writeDescription(context) {
67
92
  context.writer.writeln(this.description);
68
93
  context.writer.writeln();
69
- context.writer.writeln("Usage: ls [path] [--all] [--long]");
94
+ context.writer.writeln("Usage: ls [path...] [-a|--all] [-l|--long] [-h|--human-readable]");
70
95
  }
71
96
  writeLongFormat(entries, context) {
72
97
  const { writer } = context;
@@ -75,7 +100,7 @@ var CliLsCommandProcessor = class {
75
100
  const perms = entry.permissions || (entry.type === "directory" ? "rwxr-xr-x" : "rw-r--r--");
76
101
  const owner = entry.ownership?.uid || "-";
77
102
  const group = entry.ownership?.gid || "-";
78
- const size = entry.type === "file" ? entry.size.toString() : "-";
103
+ const size = entry.type !== "file" ? "-" : this.humanReadable ? this.formatSize(entry.size) : entry.size.toString();
79
104
  const date = new Date(entry.modifiedAt);
80
105
  const dateStr = date.toLocaleDateString("en-US", {
81
106
  month: "short",
@@ -89,6 +114,17 @@ var CliLsCommandProcessor = class {
89
114
  );
90
115
  }
91
116
  }
117
+ formatSize(bytes) {
118
+ const units = ["K", "M", "G", "T"];
119
+ let size = bytes;
120
+ let unit = "";
121
+ for (const next of units) {
122
+ if (size < 1024) break;
123
+ size /= 1024;
124
+ unit = next;
125
+ }
126
+ return unit ? `${size < 10 ? size.toFixed(1) : Math.round(size)}${unit}` : String(bytes);
127
+ }
92
128
  writeShortFormat(entries, context) {
93
129
  const { writer } = context;
94
130
  const names = entries.map(
@@ -106,11 +142,30 @@ var CliCdCommandProcessor = class {
106
142
  this.acceptsRawInput = true;
107
143
  this.metadata = { icon: "\u{1F4C1}", module: "file management" };
108
144
  }
145
+ writeDescription(context) {
146
+ const t = context.translator;
147
+ cliCore.writeWrapped(
148
+ context,
149
+ t.t("cli.cd.help.change_current_directory_without", "Change the current directory. Without a path it goes to your home directory. Paths can be absolute (/...), relative to where you are, or start with ~ for home.")
150
+ );
151
+ context.writer.writeln();
152
+ cliCore.writeUsage(context, [
153
+ ["cd", t.t("cli.cd.help.go_home_directory", "Go to your home directory")],
154
+ ["cd <dir>", t.t("cli.cd.help.go_directory", "Go into a directory")],
155
+ ["cd ..", t.t("cli.cd.help.go_up_one_level", "Go up one level")]
156
+ ]);
157
+ context.writer.writeln();
158
+ cliCore.writeExamples(context, [
159
+ ["cd ~/docs", t.t("cli.cd.help.go_docs_home", "Go to docs in your home")],
160
+ ["cd ../projects", t.t("cli.cd.help.go_sibling_directory", "Go to a sibling directory")],
161
+ ["cd /", t.t("cli.cd.help.go_root", "Go to the root")]
162
+ ]);
163
+ }
109
164
  async processCommand(command, context) {
110
- const fs = context.services.get(
165
+ const fs = context.services.getRequired(
111
166
  IFileSystemService_TOKEN
112
167
  );
113
- const target = command.value || "~";
168
+ const target = splitArgs(command.value || "").join(" ") || "~";
114
169
  try {
115
170
  fs.setCurrentDirectory(target);
116
171
  await fs.persist();
@@ -127,8 +182,24 @@ var CliPwdCommandProcessor = class {
127
182
  this.version = LIBRARY_VERSION;
128
183
  this.metadata = { icon: "\u{1F4C2}", module: "file management" };
129
184
  }
185
+ writeDescription(context) {
186
+ const t = context.translator;
187
+ cliCore.writeWrapped(
188
+ context,
189
+ t.t("cli.pwd.help.print_full_path_directory", "Print the full path of the directory you are in. Relative paths in other commands start from here.")
190
+ );
191
+ context.writer.writeln();
192
+ cliCore.writeUsage(context, [
193
+ ["pwd", t.t("cli.pwd.help.show_current_directory", "Show the current directory")]
194
+ ]);
195
+ context.writer.writeln();
196
+ cliCore.writeExamples(context, [
197
+ ["pwd", t.t("cli.pwd.help.example_home_user", "For example /home/user")],
198
+ ["cd ~/docs; pwd", t.t("cli.pwd.help.prints_home_user_docs", "Prints /home/user/docs")]
199
+ ]);
200
+ }
130
201
  async processCommand(command, context) {
131
- const fs = context.services.get(
202
+ const fs = context.services.getRequired(
132
203
  IFileSystemService_TOKEN
133
204
  );
134
205
  context.writer.writeln(fs.getCurrentDirectory());
@@ -153,22 +224,44 @@ var CliMkdirCommandProcessor = class {
153
224
  }
154
225
  ];
155
226
  }
227
+ writeDescription(context) {
228
+ const t = context.translator;
229
+ cliCore.writeWrapped(
230
+ context,
231
+ t.t("cli.mkdir.help.create_new_directory_p", "Create a new directory. With -p, any missing parent directories are created too.")
232
+ );
233
+ context.writer.writeln();
234
+ cliCore.writeUsage(context, [
235
+ ["mkdir <dir...>", t.t("cli.mkdir.help.create_one_more_directories", "Create one or more directories")],
236
+ ["mkdir -p <path>", t.t("cli.mkdir.help.create_directory_missing_parents", "Create a directory and its missing parents")]
237
+ ]);
238
+ context.writer.writeln();
239
+ cliCore.writeExamples(context, [
240
+ ["mkdir projects", t.t("cli.mkdir.help.create_projects_current_directory", "Create projects in the current directory")],
241
+ ["mkdir -p ~/docs/2026/notes", t.t("cli.mkdir.help.create_whole_path_once", "Create the whole path at once")]
242
+ ]);
243
+ }
156
244
  async processCommand(command, context) {
157
- const fs = context.services.get(
245
+ const fs = context.services.getRequired(
158
246
  IFileSystemService_TOKEN
159
247
  );
160
- const path = command.value;
248
+ const paths = splitArgs(command.value || "");
161
249
  const recursive = command.args["parents"] || command.args["p"];
162
- if (!path) {
250
+ if (paths.length === 0) {
163
251
  context.writer.writeError("mkdir: missing operand");
164
252
  return;
165
253
  }
166
- try {
167
- fs.createDirectory(path, recursive);
168
- await fs.persist();
169
- } catch (e) {
170
- context.writer.writeError(e.message);
254
+ let changed = false;
255
+ for (const path of paths) {
256
+ try {
257
+ if (recursive && fs.isDirectory(path)) continue;
258
+ fs.createDirectory(path, recursive);
259
+ changed = true;
260
+ } catch (e) {
261
+ context.writer.writeError(e.message);
262
+ }
171
263
  }
264
+ if (changed) await fs.persist();
172
265
  }
173
266
  };
174
267
  var CliRmdirCommandProcessor = class {
@@ -181,38 +274,58 @@ var CliRmdirCommandProcessor = class {
181
274
  this.valueRequired = true;
182
275
  this.metadata = { icon: "\u{1F5D1}", module: "file management" };
183
276
  }
277
+ writeDescription(context) {
278
+ const t = context.translator;
279
+ cliCore.writeWrapped(
280
+ context,
281
+ t.t("cli.rmdir.help.remove_empty_directory_refuses", "Remove an empty directory. It refuses to remove a directory that still contains files; use rm -r for that.")
282
+ );
283
+ context.writer.writeln();
284
+ cliCore.writeUsage(context, [
285
+ ["rmdir <dir...>", t.t("cli.rmdir.help.remove_empty_directories", "Remove empty directories")]
286
+ ]);
287
+ context.writer.writeln();
288
+ cliCore.writeExamples(context, [
289
+ ["rmdir empty-folder", t.t("cli.rmdir.help.remove_directory_current_one", "Remove a directory in the current one")],
290
+ ["rmdir ~/docs/old", t.t("cli.rmdir.help.remove_full_path", "Remove by full path")]
291
+ ]);
292
+ }
184
293
  async processCommand(command, context) {
185
- const fs = context.services.get(
294
+ const fs = context.services.getRequired(
186
295
  IFileSystemService_TOKEN
187
296
  );
188
- const path = command.value;
189
- if (!path) {
297
+ const paths = splitArgs(command.value || "");
298
+ if (paths.length === 0) {
190
299
  context.writer.writeError("rmdir: missing operand");
191
300
  return;
192
301
  }
193
- try {
194
- const node = fs.getNode(path);
195
- if (!node) {
196
- context.writer.writeError(
197
- `rmdir: ${path}: No such file or directory`
198
- );
199
- return;
200
- }
201
- if (node.type !== "directory") {
202
- context.writer.writeError(`rmdir: ${path}: Not a directory`);
203
- return;
204
- }
205
- if (node.children && node.children.length > 0) {
206
- context.writer.writeError(
207
- `rmdir: ${path}: Directory not empty`
208
- );
209
- return;
302
+ let changed = false;
303
+ for (const path of paths) {
304
+ try {
305
+ const node = fs.getNode(path);
306
+ if (!node) {
307
+ context.writer.writeError(
308
+ `rmdir: ${path}: No such file or directory`
309
+ );
310
+ continue;
311
+ }
312
+ if (node.type !== "directory") {
313
+ context.writer.writeError(`rmdir: ${path}: Not a directory`);
314
+ continue;
315
+ }
316
+ if (node.children && node.children.length > 0) {
317
+ context.writer.writeError(
318
+ `rmdir: ${path}: Directory not empty`
319
+ );
320
+ continue;
321
+ }
322
+ fs.remove(path, true);
323
+ changed = true;
324
+ } catch (e) {
325
+ context.writer.writeError(e.message);
210
326
  }
211
- fs.remove(path);
212
- await fs.persist();
213
- } catch (e) {
214
- context.writer.writeError(e.message);
215
327
  }
328
+ if (changed) await fs.persist();
216
329
  }
217
330
  };
218
331
  var CliTouchCommandProcessor = class {
@@ -225,23 +338,56 @@ var CliTouchCommandProcessor = class {
225
338
  this.valueRequired = true;
226
339
  this.metadata = { icon: "\u{1F4C4}", module: "file management" };
227
340
  }
341
+ writeDescription(context) {
342
+ const t = context.translator;
343
+ cliCore.writeWrapped(
344
+ context,
345
+ t.t("cli.touch.help.create_empty_file_update", "Create an empty file, or update the modified time of a file that already exists. The parent directory must already exist.")
346
+ );
347
+ context.writer.writeln();
348
+ cliCore.writeUsage(context, [
349
+ ["touch <file...>", t.t("cli.touch.help.create_files_refresh_their", "Create the files, or refresh their timestamps")]
350
+ ]);
351
+ context.writer.writeln();
352
+ cliCore.writeExamples(context, [
353
+ ["touch notes.txt", t.t("cli.touch.help.create_empty_notes_txt", "Create an empty notes.txt")],
354
+ ["touch ~/docs/todo.md", t.t("cli.touch.help.create_file_docs", "Create a file in ~/docs")]
355
+ ]);
356
+ }
228
357
  async processCommand(command, context) {
229
- const fs = context.services.get(
358
+ const fs = context.services.getRequired(
230
359
  IFileSystemService_TOKEN
231
360
  );
232
- const path = command.value;
233
- if (!path) {
361
+ const paths = splitArgs(command.value || "");
362
+ if (paths.length === 0) {
234
363
  context.writer.writeError("touch: missing file operand");
235
364
  return;
236
365
  }
237
- try {
238
- fs.createFile(path);
239
- await fs.persist();
240
- } catch (e) {
241
- context.writer.writeError(e.message);
366
+ let changed = false;
367
+ for (const path of paths) {
368
+ try {
369
+ fs.createFile(path);
370
+ changed = true;
371
+ } catch (e) {
372
+ context.writer.writeError(e.message);
373
+ }
242
374
  }
375
+ if (changed) await fs.persist();
243
376
  }
244
377
  };
378
+
379
+ // src/lib/utils/lines.ts
380
+ function splitLines(content) {
381
+ if (content === "") return [];
382
+ const lines = content.split("\n");
383
+ if (lines[lines.length - 1] === "") lines.pop();
384
+ return lines;
385
+ }
386
+ function pipedText(data) {
387
+ return typeof data === "string" ? data : JSON.stringify(data);
388
+ }
389
+
390
+ // src/lib/processors/cli-cat-command-processor.ts
245
391
  var CliCatCommandProcessor = class {
246
392
  constructor() {
247
393
  this.command = "cat";
@@ -252,22 +398,58 @@ var CliCatCommandProcessor = class {
252
398
  this.valueRequired = true;
253
399
  this.metadata = { icon: "\u{1F4D6}", module: "file management" };
254
400
  }
401
+ writeDescription(context) {
402
+ const t = context.translator;
403
+ cliCore.writeWrapped(
404
+ context,
405
+ t.t("cli.cat.help.print_contents_one_more", "Print the contents of one or more files, joined in order, or of piped input. Pipe the output into other commands to search, sort or count it.")
406
+ );
407
+ context.writer.writeln();
408
+ cliCore.writeUsage(context, [
409
+ ["cat <file...>", t.t("cli.cat.help.print_files_one_after", "Print files one after another")],
410
+ ["cat <file> | <command>", t.t("cli.cat.help.send_file_another_command", "Send a file to another command")]
411
+ ]);
412
+ context.writer.writeln();
413
+ cliCore.writeExamples(context, [
414
+ ["cat notes.txt", t.t("cli.cat.help.show_whole_file", "Show the whole file")],
415
+ ["cat app.log | grep error", t.t("cli.cat.help.show_only_lines_error", "Show only lines with error")],
416
+ ["cat names.txt | sort", t.t("cli.cat.help.print_lines_sorted", "Print the lines sorted")]
417
+ ]);
418
+ }
255
419
  async processCommand(command, context) {
256
- const fs = context.services.get(
420
+ const fs = context.services.getRequired(
257
421
  IFileSystemService_TOKEN
258
422
  );
259
- const path = command.value;
260
- if (!path) {
423
+ const paths = splitArgs(command.value || "");
424
+ if (paths.length === 0) {
425
+ if (command.data != null) {
426
+ context.writer.writeln(pipedText(command.data));
427
+ return;
428
+ }
261
429
  context.writer.writeError("cat: missing file operand");
262
430
  return;
263
431
  }
264
- try {
265
- const content = fs.readFile(path);
266
- if (content !== null) {
267
- context.writer.writeln(content);
432
+ let output = "";
433
+ for (const path of paths) {
434
+ try {
435
+ if (fs.isDirectory(path)) {
436
+ context.writer.writeError(`cat: ${path}: Is a directory`);
437
+ continue;
438
+ }
439
+ const content = fs.readFile(path);
440
+ if (content === null) {
441
+ context.writer.writeError(
442
+ `cat: ${path}: No such file or directory`
443
+ );
444
+ continue;
445
+ }
446
+ output += content;
447
+ } catch (e) {
448
+ context.writer.writeError(e.message);
268
449
  }
269
- } catch (e) {
270
- context.writer.writeError(e.message);
450
+ }
451
+ if (output !== "") {
452
+ context.writer.writeln(output.replace(/\n$/, ""));
271
453
  }
272
454
  }
273
455
  };
@@ -281,60 +463,33 @@ var CliEchoCommandProcessor = class {
281
463
  this.extendsProcessor = true;
282
464
  this.metadata = { icon: "\u{1F4AC}", module: "file management" };
283
465
  }
466
+ writeDescription(context) {
467
+ const t = context.translator;
468
+ cliCore.writeWrapped(
469
+ context,
470
+ t.t("cli.echo.help.print_text_terminal_add", "Print text to the terminal. Add > file to save it to a file (replacing it) or >> file to add it to the end.")
471
+ );
472
+ context.writer.writeln();
473
+ cliCore.writeUsage(context, [
474
+ ["echo <text>", t.t("cli.echo.help.print_text", "Print text")],
475
+ ["echo <text> > <file>", t.t("cli.echo.help.write_text_file", "Write text to a file")],
476
+ ["echo <text> >> <file>", t.t("cli.echo.help.append_text_file", "Append text to a file")]
477
+ ]);
478
+ context.writer.writeln();
479
+ cliCore.writeExamples(context, [
480
+ ["echo Hello world", t.t("cli.echo.help.print_greeting", "Print a greeting")],
481
+ ["echo first line > notes.txt", t.t("cli.echo.help.create_replace_notes_txt", "Create or replace notes.txt")],
482
+ ["echo another line >> notes.txt", t.t("cli.echo.help.add_line", "Add a line to it")]
483
+ ]);
484
+ }
284
485
  async processCommand(command, context) {
285
- const afterEcho = command.value || "";
286
- const hasRedirection = />>?\s*.+$/.test(afterEcho);
287
- if (!hasRedirection) {
288
- if (this.originalProcessor) {
289
- await this.originalProcessor.processCommand(command, context);
290
- } else {
291
- context.writer.writeln(afterEcho);
292
- }
486
+ if (this.originalProcessor) {
487
+ await this.originalProcessor.processCommand(command, context);
293
488
  return;
294
489
  }
295
- const fs = context.services.get(
296
- IFileSystemService_TOKEN
297
- );
298
- let text;
299
- let filePath;
300
- let append = false;
301
- const appendMatch = afterEcho.match(/^(.*?)\s*>>\s*(.+)$/);
302
- const overwriteMatch = afterEcho.match(/^(.*?)\s*>\s*(.+)$/);
303
- if (appendMatch) {
304
- text = appendMatch[1].trim();
305
- filePath = appendMatch[2].trim();
306
- append = true;
307
- } else {
308
- text = overwriteMatch[1].trim();
309
- filePath = overwriteMatch[2].trim();
310
- }
311
- if (text.startsWith('"') && text.endsWith('"') || text.startsWith("'") && text.endsWith("'")) {
312
- text = text.slice(1, -1);
313
- }
314
- text = text.replace(/\\([\\nrtv0])/g, (_, ch) => {
315
- switch (ch) {
316
- case "n":
317
- return "\n";
318
- case "t":
319
- return " ";
320
- case "r":
321
- return "\r";
322
- case "v":
323
- return "\v";
324
- case "0":
325
- return "\0";
326
- case "\\":
327
- return "\\";
328
- default:
329
- return ch;
330
- }
331
- });
332
- try {
333
- fs.writeFile(filePath, text + "\n", append);
334
- await fs.persist();
335
- } catch (e) {
336
- context.writer.writeError(e.message);
337
- }
490
+ const text = cliCore.joinShellWords(command.value || "");
491
+ context.writer.writeln(text);
492
+ context.process.output(text);
338
493
  }
339
494
  };
340
495
  var CliRmCommandProcessor = class {
@@ -363,35 +518,58 @@ var CliRmCommandProcessor = class {
363
518
  }
364
519
  ];
365
520
  }
521
+ writeDescription(context) {
522
+ const t = context.translator;
523
+ cliCore.writeWrapped(
524
+ context,
525
+ t.t("cli.rm.help.delete_file_directories_only", "Delete a file. Directories are only removed with -r, which deletes everything inside them. Deleted files cannot be recovered.")
526
+ );
527
+ context.writer.writeln();
528
+ cliCore.writeUsage(context, [
529
+ ["rm <file...>", t.t("cli.rm.help.delete_one_more_files", "Delete one or more files")],
530
+ ["rm -r <dir>", t.t("cli.rm.help.delete_directory_contents", "Delete a directory and its contents")],
531
+ ["rm -f <file>", t.t("cli.rm.help.delete_without_error_if", "Delete without an error if it is missing")]
532
+ ]);
533
+ context.writer.writeln();
534
+ cliCore.writeExamples(context, [
535
+ ["rm notes.bak", t.t("cli.rm.help.delete_one_file", "Delete one file")],
536
+ ["rm -r old-project", t.t("cli.rm.help.delete_whole_directory", "Delete a whole directory")],
537
+ ["rm -f maybe.txt", t.t("cli.rm.help.quiet_if_file_does", "Quiet if the file does not exist")]
538
+ ]);
539
+ }
366
540
  async processCommand(command, context) {
367
- const fs = context.services.get(
541
+ const fs = context.services.getRequired(
368
542
  IFileSystemService_TOKEN
369
543
  );
370
- const path = command.value;
544
+ const paths = splitArgs(command.value || "");
371
545
  const recursive = command.args["recursive"] || command.args["r"] || command.args["R"];
372
546
  const force = command.args["force"] || command.args["f"];
373
- if (!path) {
374
- context.writer.writeError("rm: missing operand");
547
+ if (paths.length === 0) {
548
+ if (!force) context.writer.writeError("rm: missing operand");
375
549
  return;
376
550
  }
377
- try {
378
- if (!fs.exists(path)) {
379
- if (!force) {
380
- context.writer.writeError(
381
- `rm: ${path}: No such file or directory`
382
- );
551
+ let changed = false;
552
+ for (const path of paths) {
553
+ try {
554
+ if (!fs.exists(path)) {
555
+ if (!force) {
556
+ context.writer.writeError(
557
+ `rm: ${path}: No such file or directory`
558
+ );
559
+ }
560
+ continue;
383
561
  }
384
- return;
385
- }
386
- if (fs.isDirectory(path) && !recursive) {
387
- context.writer.writeError(`rm: ${path}: Is a directory`);
388
- return;
562
+ if (fs.isDirectory(path) && !recursive) {
563
+ context.writer.writeError(`rm: ${path}: Is a directory`);
564
+ continue;
565
+ }
566
+ fs.remove(path, recursive);
567
+ changed = true;
568
+ } catch (e) {
569
+ context.writer.writeError(e.message);
389
570
  }
390
- fs.remove(path, recursive);
391
- await fs.persist();
392
- } catch (e) {
393
- context.writer.writeError(e.message);
394
571
  }
572
+ if (changed) await fs.persist();
395
573
  }
396
574
  };
397
575
  var CliCpCommandProcessor = class {
@@ -409,11 +587,37 @@ var CliCpCommandProcessor = class {
409
587
  description: "Copy directories recursively",
410
588
  required: false,
411
589
  type: "boolean"
590
+ },
591
+ {
592
+ name: "force",
593
+ aliases: ["f"],
594
+ description: "Overwrite existing files (always done; accepted for -rf)",
595
+ required: false,
596
+ type: "boolean"
412
597
  }
413
598
  ];
414
599
  }
600
+ writeDescription(context) {
601
+ const t = context.translator;
602
+ cliCore.writeWrapped(
603
+ context,
604
+ t.t("cli.cp.help.copy_file_new_path", "Copy a file to a new path, or into an existing directory. Copying a directory needs -r, which copies everything inside it. A file already at the destination is replaced.")
605
+ );
606
+ context.writer.writeln();
607
+ cliCore.writeUsage(context, [
608
+ ["cp <source> <dest>", t.t("cli.cp.help.copy_file_new_name", "Copy a file to a new name")],
609
+ ["cp <source...> <dir>", t.t("cli.cp.help.copy_files_directory", "Copy files into a directory")],
610
+ ["cp -r <dir> <dest>", t.t("cli.cp.help.copy_directory_contents", "Copy a directory and its contents")]
611
+ ]);
612
+ context.writer.writeln();
613
+ cliCore.writeExamples(context, [
614
+ ["cp notes.txt notes.bak", t.t("cli.cp.help.make_backup_copy", "Make a backup copy")],
615
+ ["cp report.txt ~/docs", t.t("cli.cp.help.copy_docs", "Copy into ~/docs")],
616
+ ["cp -r project project-copy", t.t("cli.cp.help.duplicate_whole_directory", "Duplicate a whole directory")]
617
+ ]);
618
+ }
415
619
  async processCommand(command, context) {
416
- const fs = context.services.get(
620
+ const fs = context.services.getRequired(
417
621
  IFileSystemService_TOKEN
418
622
  );
419
623
  const recursive = command.args["recursive"] || command.args["r"] || command.args["R"];
@@ -425,18 +629,26 @@ var CliCpCommandProcessor = class {
425
629
  );
426
630
  return;
427
631
  }
428
- const src = parts[0];
429
- const dest = parts[1];
430
- try {
431
- fs.copy(src, dest, recursive);
432
- await fs.persist();
433
- } catch (e) {
434
- context.writer.writeError(e.message);
632
+ const dest = parts[parts.length - 1];
633
+ const sources = parts.slice(0, -1);
634
+ if (sources.length > 1 && !fs.isDirectory(dest)) {
635
+ context.writer.writeError(`cp: target '${dest}' is not a directory`);
636
+ return;
637
+ }
638
+ let changed = false;
639
+ for (const src of sources) {
640
+ try {
641
+ fs.copy(src, dest, recursive);
642
+ changed = true;
643
+ } catch (e) {
644
+ context.writer.writeError(e.message);
645
+ }
435
646
  }
647
+ if (changed) await fs.persist();
436
648
  }
437
649
  parseArgs(command) {
438
650
  const raw = command.value || "";
439
- return raw.split(/\s+/).filter((t) => t && !t.startsWith("--") && !t.startsWith("-"));
651
+ return splitArgs(raw).filter(Boolean);
440
652
  }
441
653
  };
442
654
  var CliMvCommandProcessor = class {
@@ -448,8 +660,26 @@ var CliMvCommandProcessor = class {
448
660
  this.acceptsRawInput = true;
449
661
  this.metadata = { icon: "\u{1F4E6}", module: "file management" };
450
662
  }
663
+ writeDescription(context) {
664
+ const t = context.translator;
665
+ cliCore.writeWrapped(
666
+ context,
667
+ t.t("cli.mv.help.move_rename_file_directory", "Move or rename a file or directory. If the destination is an existing directory, the source is moved into it; otherwise it is renamed. A file already at the destination is replaced.")
668
+ );
669
+ context.writer.writeln();
670
+ cliCore.writeUsage(context, [
671
+ ["mv <source> <dest>", t.t("cli.mv.help.rename_file_directory", "Rename a file or directory")],
672
+ ["mv <source...> <dir>", t.t("cli.mv.help.move_them_directory", "Move them into a directory")]
673
+ ]);
674
+ context.writer.writeln();
675
+ cliCore.writeExamples(context, [
676
+ ["mv draft.txt final.txt", t.t("cli.mv.help.rename_file", "Rename a file")],
677
+ ["mv final.txt ~/docs", t.t("cli.mv.help.move_docs", "Move it into ~/docs")],
678
+ ["mv old-dir new-dir", t.t("cli.mv.help.rename_directory", "Rename a directory")]
679
+ ]);
680
+ }
451
681
  async processCommand(command, context) {
452
- const fs = context.services.get(
682
+ const fs = context.services.getRequired(
453
683
  IFileSystemService_TOKEN
454
684
  );
455
685
  const parts = this.parseArgs(command);
@@ -458,20 +688,36 @@ var CliMvCommandProcessor = class {
458
688
  context.writer.writeln("Usage: mv <source> <destination>");
459
689
  return;
460
690
  }
461
- const src = parts[0];
462
- const dest = parts[1];
463
- try {
464
- fs.move(src, dest);
465
- await fs.persist();
466
- } catch (e) {
467
- context.writer.writeError(e.message);
691
+ const dest = parts[parts.length - 1];
692
+ const sources = parts.slice(0, -1);
693
+ if (sources.length > 1 && !fs.isDirectory(dest)) {
694
+ context.writer.writeError(`mv: target '${dest}' is not a directory`);
695
+ return;
468
696
  }
697
+ let changed = false;
698
+ for (const src of sources) {
699
+ try {
700
+ fs.move(src, dest);
701
+ changed = true;
702
+ } catch (e) {
703
+ context.writer.writeError(e.message);
704
+ }
705
+ }
706
+ if (changed) await fs.persist();
469
707
  }
470
708
  parseArgs(command) {
471
709
  const raw = command.value || "";
472
- return raw.split(/\s+/).filter((t) => t && !t.startsWith("--") && !t.startsWith("-"));
710
+ return splitArgs(raw).filter(Boolean);
473
711
  }
474
712
  };
713
+ function readableChildren(fs, path) {
714
+ try {
715
+ return fs.listDirectory(path);
716
+ } catch (e) {
717
+ if (cliCore.isCliAccessDeniedError(e)) return null;
718
+ throw e;
719
+ }
720
+ }
475
721
  var CliTreeCommandProcessor = class {
476
722
  constructor() {
477
723
  this.command = "tree";
@@ -490,11 +736,29 @@ var CliTreeCommandProcessor = class {
490
736
  }
491
737
  ];
492
738
  }
739
+ writeDescription(context) {
740
+ const t = context.translator;
741
+ cliCore.writeWrapped(
742
+ context,
743
+ t.t("cli.tree.help.draw_directory_everything_below", "Draw a directory and everything below it as a tree, directories first, followed by a count of directories and files. Defaults to the current directory.")
744
+ );
745
+ context.writer.writeln();
746
+ cliCore.writeUsage(context, [
747
+ ["tree [dir]", t.t("cli.tree.help.show_whole_tree", "Show the whole tree")],
748
+ ["tree -L <n> [dir]", t.t("cli.tree.help.only_levels_deep", "Only <n> levels deep")]
749
+ ]);
750
+ context.writer.writeln();
751
+ cliCore.writeExamples(context, [
752
+ ["tree", t.t("cli.tree.help.tree_current_directory", "Tree of the current directory")],
753
+ ["tree ~/docs", t.t("cli.tree.help.tree_docs", "Tree of ~/docs")],
754
+ ["tree -L 2", t.t("cli.tree.help.top_two_levels_only", "Top two levels only")]
755
+ ]);
756
+ }
493
757
  async processCommand(command, context) {
494
- const fs = context.services.get(
758
+ const fs = context.services.getRequired(
495
759
  IFileSystemService_TOKEN
496
760
  );
497
- const targetPath = command.value || fs.getCurrentDirectory();
761
+ const targetPath = splitArgs(command.value || "").join(" ") || fs.getCurrentDirectory();
498
762
  const maxDepth = command.args["depth"] ? parseInt(command.args["depth"]) : command.args["L"] ? parseInt(command.args["L"]) : Infinity;
499
763
  try {
500
764
  const node = fs.getNode(targetPath);
@@ -518,7 +782,8 @@ var CliTreeCommandProcessor = class {
518
782
  )
519
783
  );
520
784
  const counts = { dirs: 0, files: 0 };
521
- this.printTree(node, "", true, 0, maxDepth, context, counts);
785
+ this.fs = fs;
786
+ this.printTree(node, "", true, 0, maxDepth, context, counts, resolvedPath);
522
787
  context.writer.writeln();
523
788
  context.writer.writeln(
524
789
  `${counts.dirs} directories, ${counts.files} files`
@@ -527,11 +792,15 @@ var CliTreeCommandProcessor = class {
527
792
  context.writer.writeError(e.message);
528
793
  }
529
794
  }
530
- printTree(node, prefix, isRoot, depth, maxDepth, context, counts) {
531
- if (!node.children || depth >= maxDepth) {
795
+ printTree(node, prefix, isRoot, depth, maxDepth, context, counts, path = "/") {
796
+ if (depth >= maxDepth) {
797
+ return;
798
+ }
799
+ const visible = this.fs ? readableChildren(this.fs, path) : node.children;
800
+ if (!visible) {
532
801
  return;
533
802
  }
534
- const children = [...node.children].sort((a, b) => {
803
+ const children = [...visible].sort((a, b) => {
535
804
  if (a.type !== b.type) {
536
805
  return a.type === "directory" ? -1 : 1;
537
806
  }
@@ -556,7 +825,8 @@ var CliTreeCommandProcessor = class {
556
825
  depth + 1,
557
826
  maxDepth,
558
827
  context,
559
- counts
828
+ counts,
829
+ path === "/" ? `/${child.name}` : `${path}/${child.name}`
560
830
  );
561
831
  } else {
562
832
  counts.files++;
@@ -583,21 +853,40 @@ var CliHeadCommandProcessor = class {
583
853
  }
584
854
  ];
585
855
  }
856
+ writeDescription(context) {
857
+ const t = context.translator;
858
+ cliCore.writeWrapped(
859
+ context,
860
+ t.t("cli.head.help.print_first_lines_file", "Print the first lines of a file, 10 by default. Works on piped input too. With several files, each one gets a ==> name <== header.")
861
+ );
862
+ context.writer.writeln();
863
+ cliCore.writeUsage(context, [
864
+ ["head <file>", t.t("cli.head.help.first_10_lines", "First 10 lines")],
865
+ ["head -n <count> <file>", t.t("cli.head.help.first_lines", "First <count> lines")],
866
+ ["<command> | head", t.t("cli.head.help.first_lines_piped_input", "First lines of piped input")]
867
+ ]);
868
+ context.writer.writeln();
869
+ cliCore.writeExamples(context, [
870
+ ["head notes.txt", t.t("cli.head.help.show_start_file", "Show the start of a file")],
871
+ ["head -n 3 a.txt b.txt", t.t("cli.head.help.first_3_lines_each", "First 3 lines of each file")],
872
+ ["cat app.log | head -n 20", t.t("cli.head.help.first_20_lines_log", "First 20 lines of the log")]
873
+ ]);
874
+ }
586
875
  async processCommand(command, context) {
587
- const fs = context.services.get(
876
+ const fs = context.services.getRequired(
588
877
  IFileSystemService_TOKEN
589
878
  );
590
- const count = parseInt(
591
- command.args["lines"] || command.args["n"] || "10",
592
- 10
593
- );
879
+ const countArg = command.args["lines"] ?? command.args["n"] ?? Object.keys(command.args).find((k) => /^\d+$/.test(k));
880
+ const parsedCount = parseInt(String(countArg ?? "10"), 10);
881
+ const count = isNaN(parsedCount) ? 10 : Math.abs(parsedCount);
594
882
  const paths = this.parsePaths(command);
595
883
  if (paths.length === 0) {
596
884
  if (command.data != null) {
597
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
598
- const lines = content.split("\n");
885
+ const lines = splitLines(pipedText(command.data));
599
886
  const selected = lines.slice(0, count);
600
- context.writer.writeln(selected.join("\n"));
887
+ if (selected.length > 0) {
888
+ context.writer.writeln(selected.join("\n"));
889
+ }
601
890
  return;
602
891
  }
603
892
  context.writer.writeError("head: missing file operand");
@@ -616,9 +905,11 @@ var CliHeadCommandProcessor = class {
616
905
  if (i > 0) context.writer.writeln();
617
906
  context.writer.writeln(`==> ${path} <==`);
618
907
  }
619
- const lines = content.split("\n");
908
+ const lines = splitLines(content);
620
909
  const selected = lines.slice(0, count);
621
- context.writer.writeln(selected.join("\n"));
910
+ if (selected.length > 0) {
911
+ context.writer.writeln(selected.join("\n"));
912
+ }
622
913
  } catch (e) {
623
914
  context.writer.writeError(`head: ${e.message}`);
624
915
  }
@@ -626,7 +917,7 @@ var CliHeadCommandProcessor = class {
626
917
  }
627
918
  parsePaths(command) {
628
919
  const raw = command.value || "";
629
- return raw.split(/\s+/).filter(Boolean);
920
+ return splitArgs(raw).filter(Boolean);
630
921
  }
631
922
  };
632
923
  var CliTailCommandProcessor = class {
@@ -648,21 +939,40 @@ var CliTailCommandProcessor = class {
648
939
  }
649
940
  ];
650
941
  }
942
+ writeDescription(context) {
943
+ const t = context.translator;
944
+ cliCore.writeWrapped(
945
+ context,
946
+ t.t("cli.tail.help.print_last_lines_file", "Print the last lines of a file, 10 by default. Works on piped input too. With several files, each one gets a ==> name <== header.")
947
+ );
948
+ context.writer.writeln();
949
+ cliCore.writeUsage(context, [
950
+ ["tail <file>", t.t("cli.tail.help.last_10_lines", "Last 10 lines")],
951
+ ["tail -n <count> <file>", t.t("cli.tail.help.last_lines", "Last <count> lines")],
952
+ ["<command> | tail", t.t("cli.tail.help.last_lines_piped_input", "Last lines of piped input")]
953
+ ]);
954
+ context.writer.writeln();
955
+ cliCore.writeExamples(context, [
956
+ ["tail app.log", t.t("cli.tail.help.show_end_log", "Show the end of a log")],
957
+ ["tail -n 5 notes.txt", t.t("cli.tail.help.last_5_lines", "Last 5 lines")],
958
+ ["cat app.log | tail -n 20", t.t("cli.tail.help.last_20_lines_log", "Last 20 lines of the log")]
959
+ ]);
960
+ }
651
961
  async processCommand(command, context) {
652
- const fs = context.services.get(
962
+ const fs = context.services.getRequired(
653
963
  IFileSystemService_TOKEN
654
964
  );
655
- const count = parseInt(
656
- command.args["lines"] || command.args["n"] || "10",
657
- 10
658
- );
965
+ const countArg = command.args["lines"] ?? command.args["n"] ?? Object.keys(command.args).find((k) => /^\d+$/.test(k));
966
+ const parsedCount = parseInt(String(countArg ?? "10"), 10);
967
+ const count = isNaN(parsedCount) ? 10 : Math.abs(parsedCount);
659
968
  const paths = this.parsePaths(command);
660
969
  if (paths.length === 0) {
661
970
  if (command.data != null) {
662
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
663
- const lines = content.split("\n");
664
- const selected = lines.slice(-count);
665
- context.writer.writeln(selected.join("\n"));
971
+ const lines = splitLines(pipedText(command.data));
972
+ const selected = count > 0 ? lines.slice(-count) : [];
973
+ if (selected.length > 0) {
974
+ context.writer.writeln(selected.join("\n"));
975
+ }
666
976
  return;
667
977
  }
668
978
  context.writer.writeError("tail: missing file operand");
@@ -681,9 +991,11 @@ var CliTailCommandProcessor = class {
681
991
  if (i > 0) context.writer.writeln();
682
992
  context.writer.writeln(`==> ${path} <==`);
683
993
  }
684
- const lines = content.split("\n");
685
- const selected = lines.slice(-count);
686
- context.writer.writeln(selected.join("\n"));
994
+ const lines = splitLines(content);
995
+ const selected = count > 0 ? lines.slice(-count) : [];
996
+ if (selected.length > 0) {
997
+ context.writer.writeln(selected.join("\n"));
998
+ }
687
999
  } catch (e) {
688
1000
  context.writer.writeError(`tail: ${e.message}`);
689
1001
  }
@@ -691,7 +1003,7 @@ var CliTailCommandProcessor = class {
691
1003
  }
692
1004
  parsePaths(command) {
693
1005
  const raw = command.value || "";
694
- return raw.split(/\s+/).filter(Boolean);
1006
+ return splitArgs(raw).filter(Boolean);
695
1007
  }
696
1008
  };
697
1009
  var CliWcCommandProcessor = class {
@@ -727,8 +1039,30 @@ var CliWcCommandProcessor = class {
727
1039
  }
728
1040
  ];
729
1041
  }
1042
+ writeDescription(context) {
1043
+ const t = context.translator;
1044
+ cliCore.writeWrapped(
1045
+ context,
1046
+ t.t("cli.wc.help.count_lines_words_characters", "Count lines, words and characters in files or piped input. With several files, a total line is printed at the end.")
1047
+ );
1048
+ context.writer.writeln();
1049
+ cliCore.writeUsage(context, [
1050
+ ["wc <file...>", t.t("cli.wc.help.lines_words_characters", "Lines, words and characters")],
1051
+ ["wc -l <file>", t.t("cli.wc.help.only_line_count", "Only the line count")],
1052
+ ["<command> | wc", t.t("cli.wc.help.count_piped_input", "Count piped input")]
1053
+ ]);
1054
+ context.writer.writeln();
1055
+ cliCore.writeExamples(context, [
1056
+ ["wc notes.txt", t.t("cli.wc.help.all_three_counts", "All three counts")],
1057
+ ["wc -l a.txt b.txt", t.t("cli.wc.help.line_counts_total", "Line counts with a total")],
1058
+ [
1059
+ "cat app.log | grep error | wc -l",
1060
+ t.t("cli.wc.help.how_many_lines_mention", "How many lines mention error")
1061
+ ]
1062
+ ]);
1063
+ }
730
1064
  async processCommand(command, context) {
731
- const fs = context.services.get(
1065
+ const fs = context.services.getRequired(
732
1066
  IFileSystemService_TOKEN
733
1067
  );
734
1068
  const showLines = command.args["lines"] || command.args["l"] || false;
@@ -738,10 +1072,9 @@ var CliWcCommandProcessor = class {
738
1072
  const paths = this.parsePaths(command);
739
1073
  if (paths.length === 0) {
740
1074
  if (command.data != null) {
741
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
742
- const lineCount = content === "" ? 0 : content.split("\n").length;
743
- const wordCount = content.trim() === "" ? 0 : content.trim().split(/\s+/).length;
744
- const charCount = content.length;
1075
+ let content = pipedText(command.data);
1076
+ if (content !== "" && !content.endsWith("\n")) content += "\n";
1077
+ const { lineCount, wordCount, charCount } = this.count(content);
745
1078
  const parts = [];
746
1079
  if (showAll || showLines) parts.push(String(lineCount).padStart(8));
747
1080
  if (showAll || showWords) parts.push(String(wordCount).padStart(8));
@@ -762,9 +1095,7 @@ var CliWcCommandProcessor = class {
762
1095
  continue;
763
1096
  }
764
1097
  const content = fs.readFile(path) ?? "";
765
- const lineCount = content === "" ? 0 : content.split("\n").length;
766
- const wordCount = content.trim() === "" ? 0 : content.trim().split(/\s+/).length;
767
- const charCount = content.length;
1098
+ const { lineCount, wordCount, charCount } = this.count(content);
768
1099
  totalLines += lineCount;
769
1100
  totalWords += wordCount;
770
1101
  totalChars += charCount;
@@ -787,9 +1118,17 @@ var CliWcCommandProcessor = class {
787
1118
  context.writer.writeln(parts.join(""));
788
1119
  }
789
1120
  }
1121
+ /** Counts like Unix wc: lines are newline characters. */
1122
+ count(content) {
1123
+ return {
1124
+ lineCount: (content.match(/\n/g) ?? []).length,
1125
+ wordCount: content.trim() === "" ? 0 : content.trim().split(/\s+/).length,
1126
+ charCount: content.length
1127
+ };
1128
+ }
790
1129
  parsePaths(command) {
791
1130
  const raw = command.value || "";
792
- return raw.split(/\s+/).filter(Boolean);
1131
+ return splitArgs(raw).filter(Boolean);
793
1132
  }
794
1133
  };
795
1134
  var CliFindCommandProcessor = class {
@@ -803,7 +1142,13 @@ var CliFindCommandProcessor = class {
803
1142
  this.parameters = [
804
1143
  {
805
1144
  name: "name",
806
- description: "Match filename pattern (glob: * and ? supported)",
1145
+ description: "Match filename pattern (glob: * and ? supported), case-sensitive",
1146
+ required: false,
1147
+ type: "string"
1148
+ },
1149
+ {
1150
+ name: "iname",
1151
+ description: "Like --name, but case-insensitive",
807
1152
  required: false,
808
1153
  type: "string"
809
1154
  },
@@ -815,20 +1160,42 @@ var CliFindCommandProcessor = class {
815
1160
  },
816
1161
  {
817
1162
  name: "maxdepth",
818
- description: "Maximum directory depth to search",
1163
+ description: "Maximum depth: 0 is only the start path, 1 its direct children",
819
1164
  required: false,
820
1165
  type: "number"
821
1166
  }
822
1167
  ];
823
1168
  }
1169
+ writeDescription(context) {
1170
+ const t = context.translator;
1171
+ cliCore.writeWrapped(
1172
+ context,
1173
+ t.t("cli.find.help.list_files_directories_under", "List files and directories under a path (the current directory by default), searching all subdirectories. The start path itself is listed first. Filter by name with a glob pattern (* and ?; --name is case-sensitive, --iname is not) or by type.")
1174
+ );
1175
+ context.writer.writeln();
1176
+ cliCore.writeUsage(context, [
1177
+ ["find [path]", t.t("cli.find.help.list_everything_under_path", "List everything under a path")],
1178
+ ["find [path] --name <pattern>", t.t("cli.find.help.match_names_against_glob", "Match names against a glob")],
1179
+ ["find [path] --iname <pattern>", t.t("cli.find.help.same_ignoring_case", "Same, ignoring case")],
1180
+ ["find [path] --type f|d", t.t("cli.find.help.only_files_f_directories", "Only files (f) or directories (d)")]
1181
+ ]);
1182
+ context.writer.writeln();
1183
+ cliCore.writeExamples(context, [
1184
+ ["find ~/docs", t.t("cli.find.help.list_everything_docs", "List everything in ~/docs")],
1185
+ ['find . --name "*.txt"', t.t("cli.find.help.find_all_txt_files", "Find all .txt files")],
1186
+ ["find . --type d --maxdepth 1", t.t("cli.find.help.directories_near_top", "Directories near the top")],
1187
+ ['find . --name "*.tmp" | xargs rm', t.t("cli.find.help.delete_all_tmp_files", "Delete all .tmp files")]
1188
+ ]);
1189
+ }
824
1190
  async processCommand(command, context) {
825
- const fs = context.services.get(
1191
+ const fs = context.services.getRequired(
826
1192
  IFileSystemService_TOKEN
827
1193
  );
828
1194
  const searchPath = this.parseSearchPath(command) || fs.getCurrentDirectory();
829
- const namePattern = command.args["name"] || null;
830
1195
  const typeFilter = command.args["type"] || null;
831
- const maxDepth = command.args["maxdepth"] ? parseInt(command.args["maxdepth"], 10) : Infinity;
1196
+ const maxDepthArg = command.args["maxdepth"];
1197
+ const parsedDepth = parseInt(String(maxDepthArg ?? ""), 10);
1198
+ const maxDepth = maxDepthArg != null && maxDepthArg !== true && !isNaN(parsedDepth) ? parsedDepth : Infinity;
832
1199
  try {
833
1200
  const node = fs.getNode(searchPath);
834
1201
  if (!node) {
@@ -837,24 +1204,25 @@ var CliFindCommandProcessor = class {
837
1204
  );
838
1205
  return;
839
1206
  }
840
- if (node.type !== "directory") {
841
- context.writer.writeError(
842
- `find: '${searchPath}': Not a directory`
843
- );
844
- return;
845
- }
846
1207
  const resolvedBase = fs.resolvePath(searchPath);
847
- const nameRegex = namePattern ? this.globToRegex(namePattern) : null;
1208
+ const nameRegex = this.nameRegex(command);
848
1209
  const results = [];
849
- this.walk(
850
- node,
851
- resolvedBase,
852
- 0,
853
- maxDepth,
854
- nameRegex,
855
- typeFilter,
856
- results
857
- );
1210
+ this.fs = fs;
1211
+ const baseName = resolvedBase.split("/").filter(Boolean).pop() ?? "/";
1212
+ if (this.matches(baseName, node.type, nameRegex, typeFilter)) {
1213
+ results.push(resolvedBase);
1214
+ }
1215
+ if (node.type === "directory" && maxDepth > 0) {
1216
+ this.walk(
1217
+ node,
1218
+ resolvedBase,
1219
+ 1,
1220
+ maxDepth,
1221
+ nameRegex,
1222
+ typeFilter,
1223
+ results
1224
+ );
1225
+ }
858
1226
  for (const r of results) {
859
1227
  context.writer.writeln(r);
860
1228
  }
@@ -862,31 +1230,41 @@ var CliFindCommandProcessor = class {
862
1230
  context.writer.writeError(`find: ${e.message}`);
863
1231
  }
864
1232
  }
1233
+ /** `--name` matches case-sensitively, `--iname` ignoring case. */
1234
+ nameRegex(command) {
1235
+ const name = command.args["name"];
1236
+ if (name != null && name !== true) {
1237
+ return this.globToRegex(String(name), false);
1238
+ }
1239
+ const iname = command.args["iname"];
1240
+ if (iname != null && iname !== true) {
1241
+ return this.globToRegex(String(iname), true);
1242
+ }
1243
+ return null;
1244
+ }
1245
+ matches(name, type, nameRegex, typeFilter) {
1246
+ if (nameRegex && !nameRegex.test(name)) return false;
1247
+ if (typeFilter === "f" && type !== "file") return false;
1248
+ if (typeFilter === "d" && type !== "directory") return false;
1249
+ return true;
1250
+ }
865
1251
  parseSearchPath(command) {
866
1252
  const raw = command.value || "";
867
- const tokens = raw.split(/\s+/).filter(Boolean);
1253
+ const tokens = splitArgs(raw).filter(Boolean);
868
1254
  return tokens.length > 0 ? tokens[0] : null;
869
1255
  }
870
- globToRegex(pattern) {
1256
+ globToRegex(pattern, ignoreCase) {
871
1257
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
872
1258
  const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
873
- return new RegExp(`^${regexStr}$`, "i");
1259
+ return new RegExp(`^${regexStr}$`, ignoreCase ? "i" : "");
874
1260
  }
1261
+ /** Lists the children of `node`, which are at `depth` (1 = direct children). */
875
1262
  walk(node, currentPath, depth, maxDepth, nameRegex, typeFilter, results) {
876
- if (!node.children) return;
877
- for (const child of node.children) {
1263
+ const children = this.fs ? readableChildren(this.fs, currentPath) : node.children;
1264
+ if (!children) return;
1265
+ for (const child of children) {
878
1266
  const childPath = currentPath === "/" ? `/${child.name}` : `${currentPath}/${child.name}`;
879
- let matches = true;
880
- if (nameRegex && !nameRegex.test(child.name)) {
881
- matches = false;
882
- }
883
- if (typeFilter === "f" && child.type !== "file") {
884
- matches = false;
885
- }
886
- if (typeFilter === "d" && child.type !== "directory") {
887
- matches = false;
888
- }
889
- if (matches) {
1267
+ if (this.matches(child.name, child.type, nameRegex, typeFilter)) {
890
1268
  results.push(childPath);
891
1269
  }
892
1270
  if (child.type === "directory" && depth < maxDepth) {
@@ -940,6 +1318,13 @@ var CliGrepCommandProcessor = class {
940
1318
  required: false,
941
1319
  type: "boolean"
942
1320
  },
1321
+ {
1322
+ name: "quiet",
1323
+ aliases: ["q"],
1324
+ description: "Print nothing; only the exit status tells whether a line matched",
1325
+ required: false,
1326
+ type: "boolean"
1327
+ },
943
1328
  {
944
1329
  name: "files-with-matches",
945
1330
  aliases: ["l"],
@@ -953,11 +1338,52 @@ var CliGrepCommandProcessor = class {
953
1338
  description: "Select non-matching lines",
954
1339
  required: false,
955
1340
  type: "boolean"
1341
+ },
1342
+ {
1343
+ name: "word-regexp",
1344
+ aliases: ["w"],
1345
+ description: "Match only whole words",
1346
+ required: false,
1347
+ type: "boolean"
1348
+ },
1349
+ {
1350
+ name: "only-matching",
1351
+ aliases: ["o"],
1352
+ description: "Print only the matched parts, one per line",
1353
+ required: false,
1354
+ type: "boolean"
1355
+ },
1356
+ {
1357
+ name: "extended-regexp",
1358
+ aliases: ["E"],
1359
+ description: "Extended regular expressions (patterns always are)",
1360
+ required: false,
1361
+ type: "boolean"
956
1362
  }
957
1363
  ];
958
1364
  }
1365
+ writeDescription(context) {
1366
+ const t = context.translator;
1367
+ cliCore.writeWrapped(
1368
+ context,
1369
+ t.t("cli.grep.help.print_lines_match_pattern", "Print the lines that match a pattern. The pattern is a regular expression, matches are highlighted, and the input can be files, directories (with -r) or piped text.")
1370
+ );
1371
+ context.writer.writeln();
1372
+ cliCore.writeUsage(context, [
1373
+ ["grep <pattern> <file...>", t.t("cli.grep.help.search_one_more_files", "Search one or more files")],
1374
+ ["grep -r <pattern> <dir>", t.t("cli.grep.help.search_every_file_under", "Search every file under a directory")],
1375
+ ["<command> | grep <pattern>", t.t("cli.grep.help.filter_piped_input", "Filter piped input")]
1376
+ ]);
1377
+ context.writer.writeln();
1378
+ cliCore.writeExamples(context, [
1379
+ ["grep -i error app.log", t.t("cli.grep.help.case_insensitive_search", "Case-insensitive search")],
1380
+ ["grep -r -n TODO src", t.t("cli.grep.help.find_todos_line_numbers", "Find TODOs with line numbers")],
1381
+ ["grep -v ^# config.txt", t.t("cli.grep.help.hide_comment_lines", "Hide comment lines")],
1382
+ ["cat app.log | grep -c ERROR", t.t("cli.grep.help.count_matching_lines", "Count matching lines")]
1383
+ ]);
1384
+ }
959
1385
  async processCommand(command, context) {
960
- const fs = context.services.get(
1386
+ const fs = context.services.getRequired(
961
1387
  IFileSystemService_TOKEN
962
1388
  );
963
1389
  const ignoreCase = command.args["ignore-case"] || command.args["i"] || false;
@@ -966,51 +1392,56 @@ var CliGrepCommandProcessor = class {
966
1392
  const countOnly = command.args["count"] || command.args["c"] || false;
967
1393
  const filesOnly = command.args["files-with-matches"] || command.args["l"] || false;
968
1394
  const invert = command.args["invert-match"] || command.args["v"] || false;
1395
+ const wordRegexp = command.args["word-regexp"] || command.args["w"] || false;
1396
+ const onlyMatching = command.args["only-matching"] || command.args["o"] || false;
1397
+ const quiet = command.args["quiet"] || command.args["q"] || false;
969
1398
  const { pattern, paths } = this.parseArgs(command);
970
1399
  if (!pattern) {
971
1400
  context.writer.writeError(
972
1401
  "grep: missing pattern. Usage: grep [options] <pattern> <file>"
973
1402
  );
1403
+ context.process.exit(2, { silent: true });
1404
+ return;
1405
+ }
1406
+ let regex;
1407
+ try {
1408
+ const source = wordRegexp ? `(?<!\\w)(?:${pattern})(?!\\w)` : pattern;
1409
+ regex = new RegExp(source, ignoreCase ? "gi" : "g");
1410
+ } catch {
1411
+ context.writer.writeError(`grep: invalid pattern '${pattern}'`);
1412
+ context.process.exit(2, { silent: true });
974
1413
  return;
975
1414
  }
976
1415
  if (paths.length === 0) {
977
1416
  if (command.data != null) {
978
- let regex2;
979
- try {
980
- regex2 = new RegExp(pattern, ignoreCase ? "gi" : "g");
981
- } catch {
982
- context.writer.writeError(`grep: invalid pattern '${pattern}'`);
983
- return;
984
- }
985
1417
  const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
986
- this.grepContent(content, null, regex2, {
1418
+ const found2 = this.grepContent(content, null, regex, {
987
1419
  ignoreCase,
988
1420
  showLineNum,
989
1421
  countOnly,
990
1422
  filesOnly,
991
- invert
1423
+ invert,
1424
+ onlyMatching,
1425
+ quiet
992
1426
  }, context, false);
1427
+ this.exitStatus(context, found2, false);
993
1428
  return;
994
1429
  }
995
1430
  context.writer.writeError(
996
1431
  "grep: missing file operand. Usage: grep [options] <pattern> <file>"
997
1432
  );
998
- return;
999
- }
1000
- let regex;
1001
- try {
1002
- regex = new RegExp(pattern, ignoreCase ? "gi" : "g");
1003
- } catch {
1004
- context.writer.writeError(`grep: invalid pattern '${pattern}'`);
1433
+ context.process.exit(2, { silent: true });
1005
1434
  return;
1006
1435
  }
1007
1436
  const filePaths = [];
1437
+ let failed = false;
1008
1438
  for (const p of paths) {
1009
1439
  try {
1010
1440
  if (!fs.exists(p)) {
1011
1441
  context.writer.writeError(
1012
1442
  `grep: ${p}: No such file or directory`
1013
1443
  );
1444
+ failed = true;
1014
1445
  continue;
1015
1446
  }
1016
1447
  if (fs.isDirectory(p)) {
@@ -1018,6 +1449,7 @@ var CliGrepCommandProcessor = class {
1018
1449
  context.writer.writeError(
1019
1450
  `grep: ${p}: Is a directory`
1020
1451
  );
1452
+ failed = true;
1021
1453
  continue;
1022
1454
  }
1023
1455
  this.collectFiles(fs, fs.resolvePath(p), fs.getNode(p), filePaths);
@@ -1026,26 +1458,40 @@ var CliGrepCommandProcessor = class {
1026
1458
  }
1027
1459
  } catch (e) {
1028
1460
  context.writer.writeError(`grep: ${e.message}`);
1461
+ failed = true;
1029
1462
  }
1030
1463
  }
1031
1464
  const multiFile = filePaths.length > 1;
1465
+ let found = 0;
1032
1466
  for (const filePath of filePaths) {
1033
1467
  try {
1034
1468
  const content = fs.readFile(filePath) ?? "";
1035
- this.grepContent(content, filePath, regex, {
1469
+ found += this.grepContent(content, filePath, regex, {
1036
1470
  ignoreCase,
1037
1471
  showLineNum,
1038
1472
  countOnly,
1039
1473
  filesOnly,
1040
- invert
1474
+ invert,
1475
+ onlyMatching,
1476
+ quiet
1041
1477
  }, context, multiFile);
1042
1478
  } catch (e) {
1043
1479
  context.writer.writeError(`grep: ${filePath}: ${e.message}`);
1480
+ failed = true;
1044
1481
  }
1045
1482
  }
1483
+ this.exitStatus(context, found, failed);
1484
+ }
1485
+ /**
1486
+ * As Unix grep: 0 when a line matched, 1 when none did, 2 after an
1487
+ * error (so `if grep -q ...` and `grep x f && ...` work).
1488
+ */
1489
+ exitStatus(context, found, failed) {
1490
+ if (found > 0) return;
1491
+ context.process.exit(failed ? 2 : 1, { silent: true });
1046
1492
  }
1047
1493
  grepContent(content, filePath, regex, options, context, multiFile) {
1048
- const lines = content.split("\n");
1494
+ const lines = splitLines(content);
1049
1495
  let matchCount = 0;
1050
1496
  const matchingLines = [];
1051
1497
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
@@ -1058,6 +1504,7 @@ var CliGrepCommandProcessor = class {
1058
1504
  matchingLines.push({ num: lineIdx + 1, text: line });
1059
1505
  }
1060
1506
  }
1507
+ if (options.quiet) return matchCount;
1061
1508
  if (options.filesOnly) {
1062
1509
  if (matchCount > 0 && filePath) {
1063
1510
  context.writer.writeln(filePath);
@@ -1076,6 +1523,18 @@ var CliGrepCommandProcessor = class {
1076
1523
  parts.push(context.writer.wrapInColor(String(m.num), cliCore.CliForegroundColor.Green));
1077
1524
  parts.push(":");
1078
1525
  }
1526
+ if (options.onlyMatching && !options.invert) {
1527
+ regex.lastIndex = 0;
1528
+ const prefix = parts.join("");
1529
+ for (const match of m.text.match(regex) ?? []) {
1530
+ if (match !== "") {
1531
+ context.writer.writeln(
1532
+ prefix + context.writer.wrapInColor(match, cliCore.CliForegroundColor.Red)
1533
+ );
1534
+ }
1535
+ }
1536
+ continue;
1537
+ }
1079
1538
  if (!options.invert) {
1080
1539
  regex.lastIndex = 0;
1081
1540
  const highlighted = m.text.replace(
@@ -1089,10 +1548,11 @@ var CliGrepCommandProcessor = class {
1089
1548
  context.writer.writeln(parts.join(""));
1090
1549
  }
1091
1550
  }
1551
+ return matchCount;
1092
1552
  }
1093
1553
  parseArgs(command) {
1094
1554
  const raw = command.value || "";
1095
- const tokens = raw.split(/\s+/).filter(Boolean);
1555
+ const tokens = splitArgs(raw).filter(Boolean);
1096
1556
  if (tokens.length === 0) {
1097
1557
  return { pattern: null, paths: [] };
1098
1558
  }
@@ -1105,8 +1565,9 @@ var CliGrepCommandProcessor = class {
1105
1565
  };
1106
1566
  }
1107
1567
  collectFiles(fs, basePath, node, result) {
1108
- if (!node.children) return;
1109
- for (const child of node.children) {
1568
+ const children = readableChildren(fs, basePath);
1569
+ if (!children) return;
1570
+ for (const child of children) {
1110
1571
  const childPath = basePath === "/" ? `/${child.name}` : `${basePath}/${child.name}`;
1111
1572
  if (child.type === "file") {
1112
1573
  result.push(childPath);
@@ -1126,8 +1587,25 @@ var CliTacCommandProcessor = class {
1126
1587
  this.valueRequired = true;
1127
1588
  this.metadata = { icon: "\u{1F504}", module: "file management" };
1128
1589
  }
1590
+ writeDescription(context) {
1591
+ const t = context.translator;
1592
+ cliCore.writeWrapped(
1593
+ context,
1594
+ t.t("cli.tac.help.print_file_lines_reverse", "Print a file with its lines in reverse order, last line first. Works on piped input too.")
1595
+ );
1596
+ context.writer.writeln();
1597
+ cliCore.writeUsage(context, [
1598
+ ["tac <file>", t.t("cli.tac.help.print_file_bottom_top", "Print a file bottom to top")],
1599
+ ["<command> | tac", t.t("cli.tac.help.reverse_piped_input", "Reverse piped input")]
1600
+ ]);
1601
+ context.writer.writeln();
1602
+ cliCore.writeExamples(context, [
1603
+ ["tac app.log", t.t("cli.tac.help.newest_log_lines_first", "Newest log lines first")],
1604
+ ["cat list.txt | tac", t.t("cli.tac.help.reverse_list", "Reverse a list")]
1605
+ ]);
1606
+ }
1129
1607
  async processCommand(command, context) {
1130
- const fs = context.services.get(
1608
+ const fs = context.services.getRequired(
1131
1609
  IFileSystemService_TOKEN
1132
1610
  );
1133
1611
  const paths = this.parsePaths(command);
@@ -1171,7 +1649,7 @@ var CliTacCommandProcessor = class {
1171
1649
  }
1172
1650
  parsePaths(command) {
1173
1651
  const raw = command.value || "";
1174
- return raw.split(/\s+/).filter(Boolean);
1652
+ return splitArgs(raw).filter(Boolean);
1175
1653
  }
1176
1654
  };
1177
1655
  var CliBasenameCommandProcessor = class {
@@ -1184,6 +1662,24 @@ var CliBasenameCommandProcessor = class {
1184
1662
  this.valueRequired = true;
1185
1663
  this.metadata = { icon: "\u{1F4DB}", module: "file management" };
1186
1664
  }
1665
+ writeDescription(context) {
1666
+ const t = context.translator;
1667
+ cliCore.writeWrapped(
1668
+ context,
1669
+ t.t("cli.basename.help.print_last_part_path", "Print the last part of a path, the file or directory name, without the directories in front. If a suffix is given, it is removed from the end too.")
1670
+ );
1671
+ context.writer.writeln();
1672
+ cliCore.writeUsage(context, [
1673
+ ["basename <path>", t.t("cli.basename.help.name_without_directories", "Name without the directories")],
1674
+ ["basename <path> <suffix>", t.t("cli.basename.help.also_remove_trailing_suffix", "Also remove a trailing suffix")]
1675
+ ]);
1676
+ context.writer.writeln();
1677
+ cliCore.writeExamples(context, [
1678
+ ["basename /home/user/notes.txt", t.t("cli.basename.help.prints_notes_txt", "Prints notes.txt")],
1679
+ ["basename /home/user/notes.txt .txt", t.t("cli.basename.help.prints_notes", "Prints notes")],
1680
+ ["basename /home/user/", t.t("cli.basename.help.prints_user", "Prints user")]
1681
+ ]);
1682
+ }
1187
1683
  async processCommand(command, context) {
1188
1684
  const tokens = this.parseTokens(command);
1189
1685
  if (tokens.length === 0) {
@@ -1209,7 +1705,7 @@ var CliBasenameCommandProcessor = class {
1209
1705
  }
1210
1706
  parseTokens(command) {
1211
1707
  const raw = command.value || "";
1212
- return raw.split(/\s+/).filter(Boolean);
1708
+ return splitArgs(raw).filter(Boolean);
1213
1709
  }
1214
1710
  };
1215
1711
  var CliDirnameCommandProcessor = class {
@@ -1222,6 +1718,23 @@ var CliDirnameCommandProcessor = class {
1222
1718
  this.valueRequired = true;
1223
1719
  this.metadata = { icon: "\u{1F4C2}", module: "file management" };
1224
1720
  }
1721
+ writeDescription(context) {
1722
+ const t = context.translator;
1723
+ cliCore.writeWrapped(
1724
+ context,
1725
+ t.t("cli.dirname.help.print_directory_part_path", "Print the directory part of a path, everything before the last /. A bare name with no / gives . (the current directory).")
1726
+ );
1727
+ context.writer.writeln();
1728
+ cliCore.writeUsage(context, [
1729
+ ["dirname <path>", t.t("cli.dirname.help.path_without_last_part", "Path without its last part")]
1730
+ ]);
1731
+ context.writer.writeln();
1732
+ cliCore.writeExamples(context, [
1733
+ ["dirname /home/user/notes.txt", t.t("cli.dirname.help.prints_home_user", "Prints /home/user")],
1734
+ ["dirname notes.txt", t.t("cli.dirname.help.prints", "Prints .")],
1735
+ ["dirname /home", t.t("cli.dirname.help.prints_2", "Prints /")]
1736
+ ]);
1737
+ }
1225
1738
  async processCommand(command, context) {
1226
1739
  const tokens = this.parseTokens(command);
1227
1740
  if (tokens.length === 0) {
@@ -1248,7 +1761,7 @@ var CliDirnameCommandProcessor = class {
1248
1761
  }
1249
1762
  parseTokens(command) {
1250
1763
  const raw = command.value || "";
1251
- return raw.split(/\s+/).filter(Boolean);
1764
+ return splitArgs(raw).filter(Boolean);
1252
1765
  }
1253
1766
  };
1254
1767
  var CliSortCommandProcessor = class {
@@ -1298,117 +1811,97 @@ var CliSortCommandProcessor = class {
1298
1811
  }
1299
1812
  ];
1300
1813
  }
1814
+ writeDescription(context) {
1815
+ const t = context.translator;
1816
+ cliCore.writeWrapped(
1817
+ context,
1818
+ t.t("cli.sort.help.sort_lines_file_piped", "Sort the lines of a file or piped input, alphabetically by default. Sort numerically, in reverse, by a field, or drop duplicates. Several files are sorted together into one list.")
1819
+ );
1820
+ context.writer.writeln();
1821
+ cliCore.writeUsage(context, [
1822
+ ["sort <file>", t.t("cli.sort.help.sort_lines_alphabetically", "Sort lines alphabetically")],
1823
+ [
1824
+ "sort -k <n> -t <sep> <file>",
1825
+ t.t("cli.sort.help.sort_field_split", "Sort by field <n>, split on <sep>")
1826
+ ],
1827
+ ["<command> | sort", t.t("cli.sort.help.sort_piped_input", "Sort piped input")]
1828
+ ]);
1829
+ context.writer.writeln();
1830
+ cliCore.writeExamples(context, [
1831
+ ["sort names.txt", t.t("cli.sort.help.z", "A to Z")],
1832
+ ["sort -n -r scores.txt", t.t("cli.sort.help.largest_number_first", "Largest number first")],
1833
+ ["sort -t , -k 2 data.csv", t.t("cli.sort.help.sort_csv_second_column", "Sort a CSV by its second column")],
1834
+ ["cat words.txt | sort -u", t.t("cli.sort.help.sorted_without_duplicates", "Sorted, without duplicates")]
1835
+ ]);
1836
+ }
1301
1837
  async processCommand(command, context) {
1302
- const fs = context.services.get(
1838
+ const fs = context.services.getRequired(
1303
1839
  IFileSystemService_TOKEN
1304
1840
  );
1305
1841
  const reverse = !!command.args["r"] || !!command.args["reverse"];
1306
1842
  const numeric = !!command.args["n"] || !!command.args["numeric-sort"];
1307
1843
  const unique = !!command.args["u"] || !!command.args["unique"];
1308
- const keyField = command.args["k"] || command.args["key"];
1309
- const delimiter = command.args["t"] || command.args["field-separator"];
1844
+ const keyField = command.args["k"] ?? command.args["key"];
1845
+ const delimiter = command.args["t"] ?? command.args["field-separator"];
1310
1846
  const paths = this.parsePaths(command);
1847
+ let lines = [];
1311
1848
  if (paths.length === 0) {
1312
- if (command.data != null) {
1313
- const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1314
- let lines = content.split("\n");
1315
- if (lines.length > 0 && lines[lines.length - 1] === "") {
1316
- lines.pop();
1317
- }
1318
- const keyIndex = keyField ? parseInt(keyField, 10) - 1 : void 0;
1319
- lines.sort((a, b) => {
1320
- let va = a;
1321
- let vb = b;
1322
- if (keyIndex !== void 0 && keyIndex >= 0) {
1323
- const sepRegex = delimiter ? new RegExp(
1324
- delimiter.replace(
1325
- /[.*+?^${}()|[\]\\]/g,
1326
- "\\$&"
1327
- )
1328
- ) : /\s+/;
1329
- const fieldsA = a.split(sepRegex);
1330
- const fieldsB = b.split(sepRegex);
1331
- va = fieldsA[keyIndex] || "";
1332
- vb = fieldsB[keyIndex] || "";
1333
- }
1334
- if (numeric) {
1335
- const na = parseFloat(va) || 0;
1336
- const nb = parseFloat(vb) || 0;
1337
- return na - nb;
1338
- }
1339
- return va.localeCompare(vb);
1340
- });
1341
- if (unique) {
1342
- lines = lines.filter(
1343
- (line, i, arr) => i === 0 || line !== arr[i - 1]
1344
- );
1345
- }
1346
- if (reverse) {
1347
- lines.reverse();
1348
- }
1349
- context.writer.writeln(lines.join("\n"));
1849
+ if (command.data == null) {
1850
+ context.writer.writeError("sort: missing file operand");
1350
1851
  return;
1351
1852
  }
1352
- context.writer.writeError("sort: missing file operand");
1353
- return;
1354
- }
1355
- for (const path of paths) {
1356
- try {
1357
- if (fs.isDirectory(path)) {
1358
- context.writer.writeError(`sort: ${path}: Is a directory`);
1359
- continue;
1360
- }
1361
- const content = fs.readFile(path);
1362
- if (content === null) {
1363
- context.writer.writeError(
1364
- `sort: ${path}: No such file or directory`
1365
- );
1366
- continue;
1367
- }
1368
- let lines = content.split("\n");
1369
- if (lines.length > 0 && lines[lines.length - 1] === "") {
1370
- lines.pop();
1371
- }
1372
- const keyIndex = keyField ? parseInt(keyField, 10) - 1 : void 0;
1373
- lines.sort((a, b) => {
1374
- let va = a;
1375
- let vb = b;
1376
- if (keyIndex !== void 0 && keyIndex >= 0) {
1377
- const sepRegex = delimiter ? new RegExp(
1378
- delimiter.replace(
1379
- /[.*+?^${}()|[\]\\]/g,
1380
- "\\$&"
1381
- )
1382
- ) : /\s+/;
1383
- const fieldsA = a.split(sepRegex);
1384
- const fieldsB = b.split(sepRegex);
1385
- va = fieldsA[keyIndex] || "";
1386
- vb = fieldsB[keyIndex] || "";
1853
+ lines = splitLines(pipedText(command.data));
1854
+ } else {
1855
+ for (const path of paths) {
1856
+ try {
1857
+ if (fs.isDirectory(path)) {
1858
+ context.writer.writeError(
1859
+ `sort: ${path}: Is a directory`
1860
+ );
1861
+ continue;
1387
1862
  }
1388
- if (numeric) {
1389
- const na = parseFloat(va) || 0;
1390
- const nb = parseFloat(vb) || 0;
1391
- return na - nb;
1863
+ const content = fs.readFile(path);
1864
+ if (content === null) {
1865
+ context.writer.writeError(
1866
+ `sort: ${path}: No such file or directory`
1867
+ );
1868
+ continue;
1392
1869
  }
1393
- return va.localeCompare(vb);
1394
- });
1395
- if (unique) {
1396
- lines = lines.filter(
1397
- (line, i, arr) => i === 0 || line !== arr[i - 1]
1398
- );
1870
+ lines.push(...splitLines(content));
1871
+ } catch (e) {
1872
+ context.writer.writeError(`sort: ${e.message}`);
1399
1873
  }
1400
- if (reverse) {
1401
- lines.reverse();
1402
- }
1403
- context.writer.writeln(lines.join("\n"));
1404
- } catch (e) {
1405
- context.writer.writeError(`sort: ${e.message}`);
1406
1874
  }
1407
1875
  }
1876
+ const keyIndex = keyField != null && keyField !== true ? parseInt(String(keyField), 10) - 1 : void 0;
1877
+ const sepRegex = delimiter != null && delimiter !== true ? new RegExp(
1878
+ String(delimiter).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1879
+ ) : /\s+/;
1880
+ const keyOf = (line) => keyIndex !== void 0 && keyIndex >= 0 ? line.split(sepRegex)[keyIndex] || "" : line;
1881
+ const compare = (a, b) => {
1882
+ const va = keyOf(a);
1883
+ const vb = keyOf(b);
1884
+ if (numeric) {
1885
+ return (parseFloat(va) || 0) - (parseFloat(vb) || 0);
1886
+ }
1887
+ return va.localeCompare(vb);
1888
+ };
1889
+ lines.sort(compare);
1890
+ if (unique) {
1891
+ lines = lines.filter(
1892
+ (line, i, arr) => i === 0 || compare(line, arr[i - 1]) !== 0
1893
+ );
1894
+ }
1895
+ if (reverse) {
1896
+ lines.reverse();
1897
+ }
1898
+ if (lines.length > 0) {
1899
+ context.writer.writeln(lines.join("\n"));
1900
+ }
1408
1901
  }
1409
1902
  parsePaths(command) {
1410
1903
  const raw = command.value || "";
1411
- return raw.split(/\s+/).filter(Boolean);
1904
+ return splitArgs(raw).filter(Boolean);
1412
1905
  }
1413
1906
  };
1414
1907
  var CliUniqCommandProcessor = class {
@@ -1451,8 +1944,26 @@ var CliUniqCommandProcessor = class {
1451
1944
  }
1452
1945
  ];
1453
1946
  }
1947
+ writeDescription(context) {
1948
+ const t = context.translator;
1949
+ cliCore.writeWrapped(
1950
+ context,
1951
+ t.t("cli.uniq.help.collapse_repeated_lines_next", "Collapse repeated lines that are next to each other into one. Only adjacent duplicates are merged, so sort the input first to catch them all.")
1952
+ );
1953
+ context.writer.writeln();
1954
+ cliCore.writeUsage(context, [
1955
+ ["uniq <file>", t.t("cli.uniq.help.drop_adjacent_duplicate_lines", "Drop adjacent duplicate lines")],
1956
+ ["<command> | uniq", t.t("cli.uniq.help.filter_piped_input", "Filter piped input")]
1957
+ ]);
1958
+ context.writer.writeln();
1959
+ cliCore.writeExamples(context, [
1960
+ ["uniq log.txt", t.t("cli.uniq.help.collapse_repeated_lines", "Collapse repeated lines")],
1961
+ ["sort words.txt | uniq -c", t.t("cli.uniq.help.count_how_often_each", "Count how often each line appears")],
1962
+ ["sort words.txt | uniq -d", t.t("cli.uniq.help.show_only_duplicated_lines", "Show only the duplicated lines")]
1963
+ ]);
1964
+ }
1454
1965
  async processCommand(command, context) {
1455
- const fs = context.services.get(
1966
+ const fs = context.services.getRequired(
1456
1967
  IFileSystemService_TOKEN
1457
1968
  );
1458
1969
  const showCount = !!command.args["c"] || !!command.args["count"];
@@ -1463,7 +1974,7 @@ var CliUniqCommandProcessor = class {
1463
1974
  if (paths.length === 0) {
1464
1975
  if (command.data != null) {
1465
1976
  const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1466
- let lines = content.split("\n");
1977
+ const lines = content.split("\n");
1467
1978
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1468
1979
  lines.pop();
1469
1980
  }
@@ -1511,7 +2022,7 @@ var CliUniqCommandProcessor = class {
1511
2022
  );
1512
2023
  continue;
1513
2024
  }
1514
- let lines = content.split("\n");
2025
+ const lines = content.split("\n");
1515
2026
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1516
2027
  lines.pop();
1517
2028
  }
@@ -1546,7 +2057,7 @@ var CliUniqCommandProcessor = class {
1546
2057
  }
1547
2058
  parsePaths(command) {
1548
2059
  const raw = command.value || "";
1549
- return raw.split(/\s+/).filter(Boolean);
2060
+ return splitArgs(raw).filter(Boolean);
1550
2061
  }
1551
2062
  };
1552
2063
  var CliCutCommandProcessor = class {
@@ -1582,26 +2093,45 @@ var CliCutCommandProcessor = class {
1582
2093
  }
1583
2094
  ];
1584
2095
  }
2096
+ writeDescription(context) {
2097
+ const t = context.translator;
2098
+ cliCore.writeWrapped(
2099
+ context,
2100
+ t.t("cli.cut.help.print_selected_parts_each", "Print selected parts of each line: fields split by a delimiter (-f, tab by default) or character positions (-c). A list can be single numbers, ranges or both, such as 1,3 or 2-4 or 3-.")
2101
+ );
2102
+ context.writer.writeln();
2103
+ cliCore.writeUsage(context, [
2104
+ ["cut -d <sep> -f <list> <file>", t.t("cli.cut.help.select_fields", "Select fields")],
2105
+ ["cut -c <list> <file>", t.t("cli.cut.help.select_character_positions", "Select character positions")],
2106
+ ["<command> | cut -f <list>", t.t("cli.cut.help.cut_piped_input", "Cut piped input")]
2107
+ ]);
2108
+ context.writer.writeln();
2109
+ cliCore.writeExamples(context, [
2110
+ ["cut -d , -f 1,3 data.csv", t.t("cli.cut.help.first_third_csv_column", "First and third CSV column")],
2111
+ ["cut -c 1-10 notes.txt", t.t("cli.cut.help.first_10_characters_each", "First 10 characters of each line")],
2112
+ ["cat users.txt | cut -d : -f 1-2", t.t("cli.cut.help.first_two_fields_split", "First two fields split on :")]
2113
+ ]);
2114
+ }
1585
2115
  async processCommand(command, context) {
1586
- const fs = context.services.get(
2116
+ const fs = context.services.getRequired(
1587
2117
  IFileSystemService_TOKEN
1588
2118
  );
1589
2119
  const delimiter = command.args["d"] || command.args["delimiter"] || " ";
1590
- const fieldsSpec = command.args["f"] || command.args["fields"];
1591
- const charsSpec = command.args["c"] || command.args["characters"];
1592
- if (!fieldsSpec && !charsSpec) {
2120
+ const fieldsSpec = command.args["f"] ?? command.args["fields"];
2121
+ const charsSpec = command.args["c"] ?? command.args["characters"];
2122
+ if ((fieldsSpec === void 0 || fieldsSpec === "") && (charsSpec === void 0 || charsSpec === "")) {
1593
2123
  context.writer.writeError(
1594
2124
  "cut: you must specify either -f (fields) or -c (characters)"
1595
2125
  );
1596
2126
  return;
1597
2127
  }
1598
- const spec = fieldsSpec || charsSpec;
2128
+ const spec = String(fieldsSpec ?? charsSpec);
1599
2129
  const indices = this.parseSpec(spec);
1600
2130
  const paths = this.parsePaths(command);
1601
2131
  if (paths.length === 0) {
1602
2132
  if (command.data != null) {
1603
2133
  const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1604
- let lines = content.split("\n");
2134
+ const lines = content.split("\n");
1605
2135
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1606
2136
  lines.pop();
1607
2137
  }
@@ -1643,7 +2173,7 @@ var CliCutCommandProcessor = class {
1643
2173
  );
1644
2174
  continue;
1645
2175
  }
1646
- let lines = content.split("\n");
2176
+ const lines = content.split("\n");
1647
2177
  if (lines.length > 0 && lines[lines.length - 1] === "") {
1648
2178
  lines.pop();
1649
2179
  }
@@ -1703,7 +2233,7 @@ var CliCutCommandProcessor = class {
1703
2233
  }
1704
2234
  parsePaths(command) {
1705
2235
  const raw = command.value || "";
1706
- return raw.split(/\s+/).filter(Boolean);
2236
+ return splitArgs(raw).filter(Boolean);
1707
2237
  }
1708
2238
  };
1709
2239
  var CliPasteCommandProcessor = class {
@@ -1732,23 +2262,47 @@ var CliPasteCommandProcessor = class {
1732
2262
  }
1733
2263
  ];
1734
2264
  }
2265
+ writeDescription(context) {
2266
+ const t = context.translator;
2267
+ cliCore.writeWrapped(
2268
+ context,
2269
+ t.t("cli.paste.help.join_files_side_side", "Join files side by side: line 1 of each file goes on one output line, separated by a tab, then line 2, and so on. With -s, each file is joined onto a single line instead. Piped input is read as the file - (or as the only input when no file is named); -d takes a list of delimiters used in turn.")
2270
+ );
2271
+ context.writer.writeln();
2272
+ cliCore.writeUsage(context, [
2273
+ ["paste <file1> <file2...>", t.t("cli.paste.help.merge_files_line_line", "Merge files line by line")],
2274
+ ["paste -s <file>", t.t("cli.paste.help.join_all_lines_file", "Join all lines of a file into one")],
2275
+ ["<command> | paste - <file>", t.t("cli.paste.help.merge_piped_input_file", "Merge piped input with a file")]
2276
+ ]);
2277
+ context.writer.writeln();
2278
+ cliCore.writeExamples(context, [
2279
+ ["paste names.txt ages.txt", t.t("cli.paste.help.two_columns_tab_separated", "Two columns, tab separated")],
2280
+ ["paste -d , a.txt b.txt", t.t("cli.paste.help.merge_comma_separated", "Merge as comma-separated")],
2281
+ ["paste -s -d , list.txt", t.t("cli.paste.help.turn_list_one_csv", "Turn a list into one CSV line")]
2282
+ ]);
2283
+ }
1735
2284
  async processCommand(command, context) {
1736
- const fs = context.services.get(
2285
+ const fs = context.services.getRequired(
1737
2286
  IFileSystemService_TOKEN
1738
2287
  );
1739
- const delimiter = command.args["d"] || command.args["delimiters"] || " ";
2288
+ const delimiterArg = command.args["d"] ?? command.args["delimiters"] ?? " ";
2289
+ const delimiters = this.parseDelimiters(String(delimiterArg));
1740
2290
  const serial = !!command.args["s"] || !!command.args["serial"];
1741
2291
  const paths = this.parsePaths(command);
2292
+ const hasStdin = command.data != null;
1742
2293
  if (paths.length === 0) {
1743
- context.writer.writeError("paste: missing file operand");
1744
- return;
1745
- }
1746
- if (!serial && paths.length < 2) {
1747
- context.writer.writeError("paste: missing file operand");
1748
- return;
2294
+ if (!hasStdin) {
2295
+ context.writer.writeError("paste: missing file operand");
2296
+ return;
2297
+ }
2298
+ paths.push("-");
1749
2299
  }
1750
2300
  const fileLines = [];
1751
2301
  for (const path of paths) {
2302
+ if (path === "-" && hasStdin) {
2303
+ fileLines.push(splitLines(pipedText(command.data)));
2304
+ continue;
2305
+ }
1752
2306
  try {
1753
2307
  if (fs.isDirectory(path)) {
1754
2308
  context.writer.writeError(
@@ -1763,20 +2317,18 @@ var CliPasteCommandProcessor = class {
1763
2317
  );
1764
2318
  return;
1765
2319
  }
1766
- let lines = content.split("\n");
1767
- if (lines.length > 0 && lines[lines.length - 1] === "") {
1768
- lines.pop();
1769
- }
1770
- fileLines.push(lines);
2320
+ fileLines.push(splitLines(content));
1771
2321
  } catch (e) {
1772
2322
  context.writer.writeError(`paste: ${e.message}`);
1773
2323
  return;
1774
2324
  }
1775
2325
  }
2326
+ const join = (parts) => parts.reduce(
2327
+ (line, part, i) => i === 0 ? part : line + delimiters[(i - 1) % delimiters.length] + part,
2328
+ ""
2329
+ );
1776
2330
  if (serial) {
1777
- const output = fileLines.map(
1778
- (lines) => lines.join(delimiter)
1779
- );
2331
+ const output = fileLines.map((lines) => join(lines));
1780
2332
  context.writer.writeln(output.join("\n"));
1781
2333
  } else {
1782
2334
  const maxLines = Math.max(
@@ -1784,21 +2336,59 @@ var CliPasteCommandProcessor = class {
1784
2336
  );
1785
2337
  const output = [];
1786
2338
  for (let i = 0; i < maxLines; i++) {
1787
- const parts = fileLines.map((lines) => lines[i] || "");
1788
- output.push(parts.join(delimiter));
2339
+ output.push(join(fileLines.map((lines) => lines[i] ?? "")));
1789
2340
  }
1790
2341
  context.writer.writeln(output.join("\n"));
1791
2342
  }
1792
2343
  }
2344
+ /** `-d` list: each character in turn, with \t, \n, \\ and \0 (empty) escapes. */
2345
+ parseDelimiters(list) {
2346
+ const result = [];
2347
+ for (let i = 0; i < list.length; i++) {
2348
+ if (list[i] === "\\" && i + 1 < list.length) {
2349
+ const next = list[++i];
2350
+ result.push(
2351
+ next === "t" ? " " : next === "n" ? "\n" : next === "0" ? "" : next
2352
+ );
2353
+ } else {
2354
+ result.push(list[i]);
2355
+ }
2356
+ }
2357
+ return result.length > 0 ? result : [" "];
2358
+ }
2359
+ /**
2360
+ * The file operands. The engine drops a lone `-` from the value, so with
2361
+ * piped input they are read from the raw command line to keep it.
2362
+ */
1793
2363
  parsePaths(command) {
1794
- const raw = command.value || "";
1795
- return raw.split(/\s+/).filter(Boolean);
2364
+ const fromValue = splitArgs(command.value || "").filter(Boolean);
2365
+ if (command.data == null || !command.rawCommand) return fromValue;
2366
+ const words = splitArgs(command.rawCommand).slice(1);
2367
+ if (!words.includes("-")) return fromValue;
2368
+ const result = [];
2369
+ for (let i = 0; i < words.length; i++) {
2370
+ const word = words[i];
2371
+ if (word === "-" || !word.startsWith("-")) {
2372
+ result.push(word);
2373
+ } else if (/^(-d|--delimiters)$/.test(word)) {
2374
+ i++;
2375
+ }
2376
+ }
2377
+ return result;
1796
2378
  }
1797
2379
  };
1798
2380
  function expandSet(set) {
1799
2381
  const chars = [];
1800
2382
  let i = 0;
1801
2383
  while (i < set.length) {
2384
+ if (set[i] === "\\" && i + 1 < set.length) {
2385
+ const next = set[i + 1];
2386
+ chars.push(
2387
+ next === "n" ? "\n" : next === "t" ? " " : next === "r" ? "\r" : next
2388
+ );
2389
+ i += 2;
2390
+ continue;
2391
+ }
1802
2392
  if (set[i] === "[" && set[i + 1] === ":") {
1803
2393
  const end = set.indexOf(":]", i + 2);
1804
2394
  if (end !== -1) {
@@ -1808,7 +2398,7 @@ function expandSet(set) {
1808
2398
  continue;
1809
2399
  }
1810
2400
  }
1811
- if (i + 2 < set.length && set[i + 1] === "-" && set[i + 2] !== void 0) {
2401
+ if (i + 2 < set.length && set[i + 1] === "-" && set[i + 2] !== "\\") {
1812
2402
  const start = set.charCodeAt(i);
1813
2403
  const finish = set.charCodeAt(i + 2);
1814
2404
  if (start <= finish) {
@@ -1878,7 +2468,7 @@ function parseTokens(raw) {
1878
2468
  var CliTrCommandProcessor = class {
1879
2469
  constructor() {
1880
2470
  this.command = "tr";
1881
- this.description = "Translate or delete characters in a file";
2471
+ this.description = "Translate or delete characters in piped input";
1882
2472
  this.author = cliCore.DefaultLibraryAuthor;
1883
2473
  this.version = LIBRARY_VERSION;
1884
2474
  this.acceptsRawInput = true;
@@ -1886,103 +2476,76 @@ var CliTrCommandProcessor = class {
1886
2476
  this.metadata = { icon: "\u{1F500}", module: "file management" };
1887
2477
  this.parameters = [
1888
2478
  {
1889
- name: "d",
2479
+ name: "delete",
2480
+ aliases: ["d"],
1890
2481
  description: "Delete characters in SET1",
1891
2482
  required: false,
1892
2483
  type: "boolean"
1893
2484
  },
1894
2485
  {
1895
- name: "s",
2486
+ name: "squeeze-repeats",
2487
+ aliases: ["s"],
1896
2488
  description: "Squeeze repeated characters in last operand set",
1897
2489
  required: false,
1898
2490
  type: "boolean"
1899
2491
  }
1900
2492
  ];
1901
2493
  }
1902
- async processCommand(command, context) {
1903
- const fs = context.services.get(
1904
- IFileSystemService_TOKEN
2494
+ writeDescription(context) {
2495
+ const t = context.translator;
2496
+ cliCore.writeWrapped(
2497
+ context,
2498
+ t.t("cli.tr.help.translate_characters_set1_matching", "Translate characters from SET1 into the matching characters of SET2, delete them (-d) or squeeze repeats into one (-s). Sets can use ranges like a-z and classes like [:upper:], [:lower:], [:digit:], [:alpha:], [:alnum:] and [:space:]. Escapes \\n, \\t and \\\\ are understood. Like Unix tr, it reads only piped input and takes no file operands: to transform a file, pipe it in with cat.")
1905
2499
  );
2500
+ context.writer.writeln();
2501
+ cliCore.writeUsage(context, [
2502
+ ["<command> | tr <set1> <set2>", t.t("cli.tr.help.replace_characters", "Replace characters")],
2503
+ ["<command> | tr -d <set1>", t.t("cli.tr.help.delete_characters", "Delete characters")],
2504
+ ["<command> | tr -s <set1> [set2]", t.t("cli.tr.help.squeeze_repeated_characters", "Squeeze repeated characters")]
2505
+ ]);
2506
+ context.writer.writeln();
2507
+ cliCore.writeExamples(context, [
2508
+ ["cat notes.txt | tr a-z A-Z", t.t("cli.tr.help.convert_file_upper_case", "Convert a file to upper case")],
2509
+ ["echo hello | tr a-z A-Z", t.t("cli.tr.help.upper_case_piped_text", "Upper-case piped text")],
2510
+ ["cat notes.txt | tr -d 0-9", t.t("cli.tr.help.remove_all_digits", "Remove all digits")],
2511
+ ["cat notes.txt | tr -s ' '", t.t("cli.tr.help.collapse_runs_spaces", "Collapse runs of spaces")]
2512
+ ]);
2513
+ }
2514
+ async processCommand(command, context) {
1906
2515
  const raw = command.value || "";
1907
2516
  const positional = parseTokens(raw);
1908
- const deleteMode = !!command.args["d"];
1909
- const squeezeMode = !!command.args["s"];
2517
+ const deleteMode = !!command.args["d"] || !!command.args["delete"];
2518
+ const squeezeMode = !!command.args["s"] || !!command.args["squeeze-repeats"];
1910
2519
  if (positional.length === 0) {
1911
- context.writer.writeError("tr: missing operand");
2520
+ this.fail(context, "tr: missing operand");
1912
2521
  return;
1913
2522
  }
1914
- let set1Str;
1915
- let set2Str;
1916
- let filePath;
1917
- if (deleteMode && squeezeMode) {
1918
- if (positional.length < 2) {
1919
- context.writer.writeError("tr: missing operand");
1920
- return;
1921
- }
1922
- set1Str = positional[0];
1923
- set2Str = positional[1];
1924
- if (positional.length >= 3) {
1925
- filePath = positional[2];
1926
- }
1927
- } else if (deleteMode) {
1928
- if (positional.length < 1) {
1929
- context.writer.writeError("tr: missing operand");
1930
- return;
1931
- }
1932
- set1Str = positional[0];
1933
- if (positional.length >= 2) {
1934
- filePath = positional[1];
1935
- }
2523
+ let setCount;
2524
+ if (deleteMode) {
2525
+ setCount = squeezeMode ? 2 : 1;
1936
2526
  } else if (squeezeMode) {
1937
- if (positional.length < 1) {
1938
- context.writer.writeError("tr: missing operand");
1939
- return;
1940
- }
1941
- if (positional.length >= 3) {
1942
- set1Str = positional[0];
1943
- set2Str = positional[1];
1944
- filePath = positional[2];
1945
- } else if (positional.length === 2) {
1946
- set1Str = positional[0];
1947
- filePath = positional[1];
1948
- } else {
1949
- set1Str = positional[0];
1950
- }
2527
+ setCount = Math.min(positional.length, 2);
1951
2528
  } else {
1952
- if (positional.length < 2) {
1953
- context.writer.writeError("tr: missing operand");
1954
- return;
1955
- }
1956
- set1Str = positional[0];
1957
- set2Str = positional[1];
1958
- if (positional.length >= 3) {
1959
- filePath = positional[2];
1960
- }
2529
+ setCount = 2;
1961
2530
  }
1962
- let content;
1963
- if (filePath) {
1964
- try {
1965
- if (fs.isDirectory(filePath)) {
1966
- context.writer.writeError(`tr: ${filePath}: Is a directory`);
1967
- return;
1968
- }
1969
- content = fs.readFile(filePath);
1970
- if (content === null) {
1971
- context.writer.writeError(
1972
- `tr: ${filePath}: No such file or directory`
1973
- );
1974
- return;
1975
- }
1976
- } catch (e) {
1977
- context.writer.writeError(`tr: ${e.message}`);
1978
- return;
1979
- }
1980
- } else if (command.data != null) {
1981
- content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1982
- } else {
1983
- context.writer.writeError("tr: missing file operand");
2531
+ if (positional.length < setCount) {
2532
+ this.fail(context, "tr: missing operand");
2533
+ return;
2534
+ }
2535
+ if (positional.length > setCount) {
2536
+ this.fail(context, `tr: extra operand '${positional[setCount]}'`);
2537
+ return;
2538
+ }
2539
+ if (command.data == null) {
2540
+ this.fail(
2541
+ context,
2542
+ "tr: no input: tr reads only piped input and takes no file operands; use 'cat file | tr a-z A-Z'"
2543
+ );
1984
2544
  return;
1985
2545
  }
2546
+ const set1Str = positional[0];
2547
+ const set2Str = setCount === 2 ? positional[1] : void 0;
2548
+ const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
1986
2549
  try {
1987
2550
  const set1 = expandSet(set1Str);
1988
2551
  const set1Set = new Set(set1);
@@ -2005,9 +2568,13 @@ var CliTrCommandProcessor = class {
2005
2568
  }
2006
2569
  context.writer.writeln(result);
2007
2570
  } catch (e) {
2008
- context.writer.writeError(`tr: ${e.message}`);
2571
+ this.fail(context, `tr: ${e.message}`);
2009
2572
  }
2010
2573
  }
2574
+ fail(context, message) {
2575
+ context.writer.writeError(message);
2576
+ context.process.exit(1, { silent: true });
2577
+ }
2011
2578
  deleteChars(content, deleteSet) {
2012
2579
  let result = "";
2013
2580
  for (const ch of content) {
@@ -2054,8 +2621,24 @@ var CliStatCommandProcessor = class {
2054
2621
  this.metadata = { icon: "\u{1F4CA}", module: "file management" };
2055
2622
  this.parameters = [];
2056
2623
  }
2624
+ writeDescription(context) {
2625
+ const t = context.translator;
2626
+ cliCore.writeWrapped(
2627
+ context,
2628
+ t.t("cli.stat.help.show_details_about_files", "Show details about files or directories: full path, size, type, permissions, owner, group, and when it was created and last modified.")
2629
+ );
2630
+ context.writer.writeln();
2631
+ cliCore.writeUsage(context, [
2632
+ ["stat <path...>", t.t("cli.stat.help.show_details_each_path", "Show details for each path")]
2633
+ ]);
2634
+ context.writer.writeln();
2635
+ cliCore.writeExamples(context, [
2636
+ ["stat notes.txt", t.t("cli.stat.help.details_one_file", "Details of one file")],
2637
+ ["stat ~/docs notes.txt", t.t("cli.stat.help.several_paths_once", "Several paths at once")]
2638
+ ]);
2639
+ }
2057
2640
  async processCommand(command, context) {
2058
- const fs = context.services.get(
2641
+ const fs = context.services.getRequired(
2059
2642
  IFileSystemService_TOKEN
2060
2643
  );
2061
2644
  const paths = this.parsePaths(command);
@@ -2065,6 +2648,10 @@ var CliStatCommandProcessor = class {
2065
2648
  }
2066
2649
  for (const path of paths) {
2067
2650
  const resolved = fs.resolvePath(path);
2651
+ if (fs.canTraverse && !fs.canTraverse(resolved)) {
2652
+ context.writer.writeError(`stat: cannot stat '${path}': Permission denied`);
2653
+ continue;
2654
+ }
2068
2655
  const node = fs.getNode(resolved);
2069
2656
  if (!node) {
2070
2657
  context.writer.writeError(
@@ -2086,7 +2673,7 @@ var CliStatCommandProcessor = class {
2086
2673
  }
2087
2674
  parsePaths(command) {
2088
2675
  const raw = command.value || "";
2089
- return raw.split(/\s+/).filter(Boolean);
2676
+ return splitArgs(raw).filter(Boolean);
2090
2677
  }
2091
2678
  };
2092
2679
  var CliChmodCommandProcessor = class {
@@ -2107,8 +2694,30 @@ var CliChmodCommandProcessor = class {
2107
2694
  }
2108
2695
  ];
2109
2696
  }
2697
+ writeDescription(context) {
2698
+ const t = context.translator;
2699
+ cliCore.writeWrapped(
2700
+ context,
2701
+ t.t("cli.chmod.help.change_who_can_read", "Change who can read (r), write (w) and execute (x) a file. Use an octal mode such as 755 or a symbolic one: who (u user, g group, o others, a all), then + to add, - to remove or = to set, then the permissions.")
2702
+ );
2703
+ context.writer.writeln();
2704
+ cliCore.writeUsage(context, [
2705
+ ["chmod <mode> <file...>", t.t("cli.chmod.help.set_permissions", "Set permissions")],
2706
+ [
2707
+ "chmod -R <mode> <dir>",
2708
+ t.t("cli.chmod.help.apply_directory_everything", "Apply to a directory and everything in it")
2709
+ ]
2710
+ ]);
2711
+ context.writer.writeln();
2712
+ cliCore.writeExamples(context, [
2713
+ ["chmod 644 notes.txt", t.t("cli.chmod.help.rw_r_r_owner", "rw-r--r--: owner writes, others read")],
2714
+ ["chmod u+x script.sh", t.t("cli.chmod.help.let_owner_execute", "Let the owner execute it")],
2715
+ ["chmod go-w shared.txt", t.t("cli.chmod.help.take_away_write_group", "Take away write for group and others")],
2716
+ ["chmod -R 755 site", t.t("cli.chmod.help.set_whole_directory_tree", "Set a whole directory tree")]
2717
+ ]);
2718
+ }
2110
2719
  async processCommand(command, context) {
2111
- const fs = context.services.get(
2720
+ const fs = context.services.getRequired(
2112
2721
  IFileSystemService_TOKEN
2113
2722
  );
2114
2723
  const { recursive, mode, paths } = this.parseArgs(command);
@@ -2140,7 +2749,7 @@ var CliChmodCommandProcessor = class {
2140
2749
  }
2141
2750
  parseArgs(command) {
2142
2751
  const raw = command.value || "";
2143
- const tokens = raw.split(/\s+/).filter(Boolean);
2752
+ const tokens = splitArgs(raw).filter(Boolean);
2144
2753
  const recursive = command.args["R"] || false;
2145
2754
  const mode = tokens.length > 0 ? tokens[0] : null;
2146
2755
  const paths = tokens.slice(1);
@@ -2227,8 +2836,33 @@ var CliChownCommandProcessor = class {
2227
2836
  }
2228
2837
  ];
2229
2838
  }
2839
+ writeDescription(context) {
2840
+ const t = context.translator;
2841
+ cliCore.writeWrapped(
2842
+ context,
2843
+ t.t("cli.chown.help.change_owner_group_files", "Change the owner and/or group of files. Give user, user:group, user: (owner only) or :group (group only).")
2844
+ );
2845
+ context.writer.writeln();
2846
+ cliCore.writeUsage(context, [
2847
+ [
2848
+ "chown <user>[:<group>] <file...>",
2849
+ t.t("cli.chown.help.change_owner_group_if", "Change owner, and group if given")
2850
+ ],
2851
+ ["chown :<group> <file>", t.t("cli.chown.help.change_only_group", "Change only the group")],
2852
+ [
2853
+ "chown -R <user> <dir>",
2854
+ t.t("cli.chown.help.apply_directory_everything", "Apply to a directory and everything in it")
2855
+ ]
2856
+ ]);
2857
+ context.writer.writeln();
2858
+ cliCore.writeExamples(context, [
2859
+ ["chown alice notes.txt", t.t("cli.chown.help.make_alice_owner", "Make alice the owner")],
2860
+ ["chown alice:staff report.txt", t.t("cli.chown.help.set_owner_group", "Set owner and group")],
2861
+ ["chown -R bob ~/shared", t.t("cli.chown.help.give_bob_whole_directory", "Give bob a whole directory")]
2862
+ ]);
2863
+ }
2230
2864
  async processCommand(command, context) {
2231
- const fs = context.services.get(
2865
+ const fs = context.services.getRequired(
2232
2866
  IFileSystemService_TOKEN
2233
2867
  );
2234
2868
  const { recursive, ownerSpec, paths } = this.parseArgs(command);
@@ -2260,7 +2894,7 @@ var CliChownCommandProcessor = class {
2260
2894
  }
2261
2895
  parseArgs(command) {
2262
2896
  const raw = command.value || "";
2263
- const tokens = raw.split(/\s+/).filter(Boolean);
2897
+ const tokens = splitArgs(raw).filter(Boolean);
2264
2898
  const recursive = command.args["R"] || false;
2265
2899
  const ownerSpec = tokens.length > 0 ? tokens[0] : null;
2266
2900
  const paths = tokens.slice(1);
@@ -2298,7 +2932,6 @@ var CliDuCommandProcessor = class {
2298
2932
  this.author = cliCore.DefaultLibraryAuthor;
2299
2933
  this.version = LIBRARY_VERSION;
2300
2934
  this.acceptsRawInput = true;
2301
- this.valueRequired = true;
2302
2935
  this.metadata = { icon: "\u{1F4CF}", module: "file management" };
2303
2936
  this.parameters = [
2304
2937
  {
@@ -2322,17 +2955,39 @@ var CliDuCommandProcessor = class {
2322
2955
  }
2323
2956
  ];
2324
2957
  }
2958
+ writeDescription(context) {
2959
+ const t = context.translator;
2960
+ cliCore.writeWrapped(
2961
+ context,
2962
+ t.t("cli.du.help.show_how_much_space", "Show how much space files and directories use, in bytes. For a directory, every subdirectory is listed with its total size; use -s for just the total or -d to limit how deep it goes.")
2963
+ );
2964
+ context.writer.writeln();
2965
+ cliCore.writeUsage(context, [
2966
+ ["du [path...]", t.t("cli.du.help.sizes_path_default_current", "Sizes of a path (default: the current directory) and its subdirectories")],
2967
+ ["du -s <path>", t.t("cli.du.help.only_total", "Only the total")],
2968
+ ["du -d <n> <path>", t.t("cli.du.help.only_levels_deep", "Only <n> levels deep")]
2969
+ ]);
2970
+ context.writer.writeln();
2971
+ cliCore.writeExamples(context, [
2972
+ ["du ~/docs", t.t("cli.du.help.size_each_directory_docs", "Size of each directory in ~/docs")],
2973
+ ["du -h -s ~", t.t("cli.du.help.total_home_k_m", "Total of your home in K/M/G")],
2974
+ ["du -h -d 1 .", t.t("cli.du.help.size_each_top_level", "Size of each top-level folder")]
2975
+ ]);
2976
+ }
2325
2977
  async processCommand(command, context) {
2326
- const fs = context.services.get(
2978
+ const fs = context.services.getRequired(
2327
2979
  IFileSystemService_TOKEN
2328
2980
  );
2329
2981
  const { humanReadable, summaryOnly, maxDepth, paths } = this.parseArgs(command);
2330
2982
  if (paths.length === 0) {
2331
- context.writer.writeError("du: missing operand");
2332
- return;
2983
+ paths.push(fs.getCurrentDirectory());
2333
2984
  }
2334
2985
  for (const path of paths) {
2335
2986
  const resolved = fs.resolvePath(path);
2987
+ if (fs.canTraverse && !fs.canTraverse(resolved)) {
2988
+ context.writer.writeError(`du: cannot access '${path}': Permission denied`);
2989
+ continue;
2990
+ }
2336
2991
  const node = fs.getNode(resolved);
2337
2992
  if (!node) {
2338
2993
  context.writer.writeError(
@@ -2346,6 +3001,7 @@ var CliDuCommandProcessor = class {
2346
3001
  );
2347
3002
  } else {
2348
3003
  const entries = [];
3004
+ this.fs = fs;
2349
3005
  this.collectSizes(node, resolved, 0, maxDepth, summaryOnly, entries);
2350
3006
  for (const entry of entries) {
2351
3007
  context.writer.writeln(
@@ -2357,10 +3013,12 @@ var CliDuCommandProcessor = class {
2357
3013
  }
2358
3014
  parseArgs(command) {
2359
3015
  const raw = command.value || "";
2360
- const paths = raw.split(/\s+/).filter(Boolean);
3016
+ const paths = splitArgs(raw).filter(Boolean);
2361
3017
  const humanReadable = !!command.args["h"];
2362
3018
  const summaryOnly = !!command.args["s"];
2363
- const maxDepth = command.args["d"] || command.args["max-depth"] ? parseInt(command.args["d"] || command.args["max-depth"] || "0", 10) : Infinity;
3019
+ const depthArg = command.args["max-depth"] ?? command.args["d"];
3020
+ const parsedDepth = parseInt(String(depthArg ?? ""), 10);
3021
+ const maxDepth = depthArg != null && depthArg !== true && !isNaN(parsedDepth) ? parsedDepth : Infinity;
2364
3022
  return { humanReadable, summaryOnly, maxDepth, paths };
2365
3023
  }
2366
3024
  collectSizes(node, path, depth, maxDepth, summaryOnly, entries) {
@@ -2368,8 +3026,9 @@ var CliDuCommandProcessor = class {
2368
3026
  return node.size;
2369
3027
  }
2370
3028
  let total = 0;
2371
- if (node.children) {
2372
- for (const child of node.children) {
3029
+ const children = this.fs ? readableChildren(this.fs, path) : node.children;
3030
+ if (children) {
3031
+ for (const child of children) {
2373
3032
  const childPath = path === "/" ? `/${child.name}` : `${path}/${child.name}`;
2374
3033
  total += this.collectSizes(
2375
3034
  child,
@@ -2416,11 +3075,34 @@ var CliLnCommandProcessor = class {
2416
3075
  description: "Create a symbolic link",
2417
3076
  required: false,
2418
3077
  type: "boolean"
3078
+ },
3079
+ {
3080
+ name: "force",
3081
+ aliases: ["f"],
3082
+ description: "Replace an existing file at the link path",
3083
+ required: false,
3084
+ type: "boolean"
2419
3085
  }
2420
3086
  ];
2421
3087
  }
3088
+ writeDescription(context) {
3089
+ const t = context.translator;
3090
+ cliCore.writeWrapped(
3091
+ context,
3092
+ t.t("cli.ln.help.create_symbolic_link_new", "Create a symbolic link: a new path that points to another file or directory. Only symbolic links (-s) are supported, and the target does not need to exist yet.")
3093
+ );
3094
+ context.writer.writeln();
3095
+ cliCore.writeUsage(context, [
3096
+ ["ln -s <target> <link>", t.t("cli.ln.help.create_pointing", "Create <link> pointing at <target>")]
3097
+ ]);
3098
+ context.writer.writeln();
3099
+ cliCore.writeExamples(context, [
3100
+ ["ln -s ~/docs/notes.txt notes", t.t("cli.ln.help.shortcut_file", "Shortcut to a file")],
3101
+ ["ln -s /var/log logs", t.t("cli.ln.help.shortcut_directory", "Shortcut to a directory")]
3102
+ ]);
3103
+ }
2422
3104
  async processCommand(command, context) {
2423
- const fs = context.services.get(
3105
+ const fs = context.services.getRequired(
2424
3106
  IFileSystemService_TOKEN
2425
3107
  );
2426
3108
  const { symbolic, target, linkName } = this.parseArgs(command);
@@ -2436,6 +3118,10 @@ var CliLnCommandProcessor = class {
2436
3118
  }
2437
3119
  const resolvedLink = fs.resolvePath(linkName);
2438
3120
  const resolvedTarget = fs.resolvePath(target);
3121
+ const force = !!(command.args["force"] || command.args["f"]);
3122
+ if (force && fs.exists(resolvedLink) && !fs.isDirectory(resolvedLink)) {
3123
+ fs.remove(resolvedLink);
3124
+ }
2439
3125
  if (fs.exists(resolvedLink)) {
2440
3126
  context.writer.writeError(
2441
3127
  `ln: failed to create symbolic link '${linkName}': File exists`
@@ -2452,7 +3138,7 @@ var CliLnCommandProcessor = class {
2452
3138
  }
2453
3139
  parseArgs(command) {
2454
3140
  const raw = command.value || "";
2455
- const tokens = raw.split(/\s+/).filter(Boolean);
3141
+ const tokens = splitArgs(raw).filter(Boolean);
2456
3142
  const symbolic = command.args["s"] || false;
2457
3143
  return {
2458
3144
  symbolic,
@@ -2487,25 +3173,75 @@ var CliSedCommandProcessor = class {
2487
3173
  {
2488
3174
  name: "expression",
2489
3175
  aliases: ["e"],
2490
- description: "Add the expression to the commands to be executed",
3176
+ description: "Add the expression to the commands to be executed (repeatable)",
2491
3177
  required: false,
2492
- type: "string"
3178
+ type: "array"
3179
+ },
3180
+ {
3181
+ name: "regexp-extended",
3182
+ aliases: ["E", "r"],
3183
+ description: "Use extended regular expressions: (, ), +, ?, | and { } without backslashes",
3184
+ required: false,
3185
+ type: "boolean"
2493
3186
  }
2494
3187
  ];
2495
3188
  }
3189
+ writeDescription(context) {
3190
+ const t = context.translator;
3191
+ cliCore.writeWrapped(
3192
+ context,
3193
+ t.t("cli.sed.help.edit_text_line_line", "Edit text line by line with a script: substitute (s/old/new/), delete (d), print (p) or quit (q), optionally limited to a line number, a range (1,5, 3,$ or /start/,/end/) or lines matching /regex/; ! after an address inverts it and ; separates commands. The replacement can use & (the match) and \\1 to \\9 (groups, written \\(...\\) or, with -E, (...)). Reads files or piped input and prints the result, or rewrites the files with -i.")
3194
+ );
3195
+ context.writer.writeln();
3196
+ cliCore.writeUsage(context, [
3197
+ ["sed '<script>' <file>", t.t("cli.sed.help.print_edited_file", "Print the edited file")],
3198
+ ["sed -i '<script>' <file>", t.t("cli.sed.help.save_edits_file", "Save the edits into the file")],
3199
+ ["sed -n '<script>p' <file>", t.t("cli.sed.help.print_only_selected_lines", "Print only selected lines")],
3200
+ ["<command> | sed '<script>'", t.t("cli.sed.help.edit_piped_input", "Edit piped input")]
3201
+ ]);
3202
+ context.writer.writeln();
3203
+ cliCore.writeExamples(context, [
3204
+ ["sed 's/cat/dog/g' pets.txt", t.t("cli.sed.help.replace_every_cat_dog", "Replace every cat with dog")],
3205
+ ["sed -i 's/v1/v2/' config.txt", t.t("cli.sed.help.edit_file_place", "Edit the file in place")],
3206
+ ["sed '/^#/d' config.txt", t.t("cli.sed.help.drop_comment_lines", "Drop comment lines")],
3207
+ ["sed -n '1,5p' notes.txt", t.t("cli.sed.help.print_lines_1_5", "Print lines 1 to 5")],
3208
+ ["sed -E 's/(\\w+) (\\w+)/\\2 \\1/' names.txt", t.t("cli.sed.help.swap_two_words", "Swap two words")]
3209
+ ]);
3210
+ }
2496
3211
  async processCommand(command, context) {
2497
- const fs = context.services.get(
3212
+ const fs = context.services.getRequired(
2498
3213
  IFileSystemService_TOKEN
2499
3214
  );
2500
- const { inPlace, suppress, expressions, filePath } = this.parseRawCommand(command);
3215
+ const { inPlace, suppress, extended, expressions, filePaths } = this.parseRawCommand(command);
2501
3216
  if (expressions.length === 0) {
2502
3217
  context.writer.writeError(
2503
3218
  "sed: missing expression. Usage: sed [options] 'expression' <file>"
2504
3219
  );
2505
3220
  return;
2506
3221
  }
2507
- let content;
2508
- if (filePath) {
3222
+ const parse = () => {
3223
+ try {
3224
+ return expressions.flatMap((e) => this.parseScript(e, extended));
3225
+ } catch (e) {
3226
+ context.writer.writeError(`sed: ${e.message}`);
3227
+ return null;
3228
+ }
3229
+ };
3230
+ if (filePaths.length === 0) {
3231
+ if (command.data == null) {
3232
+ context.writer.writeError(
3233
+ "sed: missing file operand. Usage: sed [options] 'expression' <file>"
3234
+ );
3235
+ return;
3236
+ }
3237
+ const script2 = parse();
3238
+ if (!script2) return;
3239
+ const content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
3240
+ this.writeLines(context, this.run(script2, splitLines(content), suppress));
3241
+ return;
3242
+ }
3243
+ const contents = [];
3244
+ for (const filePath of filePaths) {
2509
3245
  const resolved = fs.resolvePath(filePath);
2510
3246
  if (!fs.exists(resolved)) {
2511
3247
  context.writer.writeError(
@@ -2517,116 +3253,125 @@ var CliSedCommandProcessor = class {
2517
3253
  context.writer.writeError(`sed: ${filePath}: Is a directory`);
2518
3254
  return;
2519
3255
  }
2520
- content = fs.readFile(resolved) ?? "";
2521
- } else if (command.data != null) {
2522
- content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
2523
- } else {
2524
- context.writer.writeError(
2525
- "sed: missing file operand. Usage: sed [options] 'expression' <file>"
2526
- );
2527
- return;
2528
- }
2529
- const parsedExpressions = [];
2530
- for (const expr of expressions) {
2531
3256
  try {
2532
- const parsed = this.parseExpression(expr);
2533
- parsedExpressions.push(parsed);
3257
+ contents.push(fs.readFile(resolved) ?? "");
2534
3258
  } catch (e) {
2535
3259
  context.writer.writeError(`sed: ${e.message}`);
2536
3260
  return;
2537
3261
  }
2538
3262
  }
2539
- const lines = content.split("\n");
2540
- const outputLines = [];
3263
+ if (inPlace) {
3264
+ for (let i = 0; i < filePaths.length; i++) {
3265
+ const script2 = parse();
3266
+ if (!script2) return;
3267
+ const output = this.run(script2, splitLines(contents[i]), suppress);
3268
+ const keepNewline = contents[i].endsWith("\n") && output.length > 0;
3269
+ try {
3270
+ fs.writeFile(
3271
+ fs.resolvePath(filePaths[i]),
3272
+ output.join("\n") + (keepNewline ? "\n" : "")
3273
+ );
3274
+ } catch (e) {
3275
+ context.writer.writeError(`sed: ${e.message}`);
3276
+ return;
3277
+ }
3278
+ }
3279
+ await fs.persist();
3280
+ return;
3281
+ }
3282
+ const script = parse();
3283
+ if (!script) return;
3284
+ const lines = contents.flatMap((c) => splitLines(c));
3285
+ this.writeLines(context, this.run(script, lines, suppress));
3286
+ }
3287
+ writeLines(context, lines) {
3288
+ for (const line of lines) {
3289
+ context.writer.writeln(line);
3290
+ }
3291
+ }
3292
+ /** Runs the script over the lines and returns the output lines. */
3293
+ run(script, lines, suppress) {
3294
+ const output = [];
2541
3295
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
2542
3296
  let line = lines[lineIdx];
2543
3297
  let deleted = false;
2544
- let printed = false;
2545
- for (const expr of parsedExpressions) {
2546
- if (deleted) break;
2547
- if (!this.addressMatches(
2548
- expr.address,
2549
- lineIdx + 1,
2550
- lines.length,
2551
- line
2552
- )) {
3298
+ let quit = false;
3299
+ for (const expr of script) {
3300
+ if (!this.addressMatches(expr, lineIdx + 1, lines.length, line)) {
2553
3301
  continue;
2554
3302
  }
2555
- switch (expr.command) {
2556
- case "s": {
2557
- const pattern = expr.pattern;
2558
- pattern.lastIndex = 0;
2559
- const newLine = line.replace(pattern, (...args) => {
2560
- const match = args[0];
2561
- return expr.replacement.replace(/&/g, match);
2562
- });
2563
- const matched = newLine !== line;
2564
- line = newLine;
2565
- if (matched && expr.printFlag) {
2566
- printed = true;
3303
+ if (expr.command === "s") {
3304
+ let replaced = false;
3305
+ let seen = 0;
3306
+ const pattern = expr.pattern;
3307
+ pattern.lastIndex = 0;
3308
+ line = line.replace(pattern, (...args) => {
3309
+ seen++;
3310
+ const nth = expr.occurrence;
3311
+ if (nth && (expr.globalFlag ? seen < nth : seen !== nth)) {
3312
+ return args[0];
2567
3313
  }
2568
- break;
2569
- }
2570
- case "d":
2571
- deleted = true;
2572
- break;
2573
- case "p":
2574
- printed = true;
2575
- break;
3314
+ replaced = true;
3315
+ const groups = args.slice(0, expr.groupCount + 1);
3316
+ return this.expandReplacement(expr.replacement, groups);
3317
+ });
3318
+ if (replaced && expr.printFlag) output.push(line);
3319
+ } else if (expr.command === "p") {
3320
+ output.push(line);
3321
+ } else if (expr.command === "d") {
3322
+ deleted = true;
3323
+ break;
3324
+ } else if (expr.command === "q") {
3325
+ quit = true;
3326
+ break;
2576
3327
  }
2577
3328
  }
2578
- if (!deleted) {
2579
- if (suppress) {
2580
- if (printed) {
2581
- outputLines.push(line);
2582
- }
3329
+ if (!deleted && !suppress) output.push(line);
3330
+ if (quit) break;
3331
+ }
3332
+ return output;
3333
+ }
3334
+ /** `&` is the match, `\1`..`\9` the groups, `\n` a newline, `\x` a literal x. */
3335
+ expandReplacement(replacement, groups) {
3336
+ let out = "";
3337
+ for (let i = 0; i < replacement.length; i++) {
3338
+ const ch = replacement[i];
3339
+ if (ch === "\\" && i + 1 < replacement.length) {
3340
+ const next = replacement[++i];
3341
+ if (next >= "0" && next <= "9") {
3342
+ out += groups[Number(next)] ?? "";
3343
+ } else if (next === "n") {
3344
+ out += "\n";
3345
+ } else if (next === "t") {
3346
+ out += " ";
2583
3347
  } else {
2584
- outputLines.push(line);
3348
+ out += next;
2585
3349
  }
3350
+ } else if (ch === "&") {
3351
+ out += groups[0];
3352
+ } else {
3353
+ out += ch;
2586
3354
  }
2587
3355
  }
2588
- const result = outputLines.join("\n");
2589
- if (inPlace && filePath) {
2590
- const resolved = fs.resolvePath(filePath);
2591
- fs.writeFile(resolved, result);
2592
- await fs.persist();
2593
- } else {
2594
- const resultLines = result.split("\n");
2595
- for (const line of resultLines) {
2596
- context.writer.writeln(line);
2597
- }
2598
- }
3356
+ return out;
2599
3357
  }
2600
3358
  parseRawCommand(command) {
2601
3359
  const raw = command.value || "";
2602
3360
  const tokens = this.tokenize(raw);
2603
3361
  const inPlace = !!command.args["in-place"] || !!command.args["i"];
2604
3362
  const suppress = !!command.args["quiet"] || !!command.args["n"];
3363
+ const extended = !!command.args["regexp-extended"] || !!command.args["E"] || !!command.args["r"];
2605
3364
  const expressions = [];
2606
- let filePath = null;
2607
- const nonFlagTokens = [];
2608
- if (command.args["expression"] || command.args["e"]) {
2609
- const eValue = command.args["expression"] || command.args["e"];
2610
- if (Array.isArray(eValue)) {
2611
- for (const v of eValue) {
2612
- if (v && typeof v === "string") {
2613
- expressions.push(v);
2614
- }
2615
- }
2616
- } else if (eValue && typeof eValue === "string") {
2617
- expressions.push(eValue);
3365
+ const eValue = command.args["expression"] ?? command.args["e"];
3366
+ for (const v of Array.isArray(eValue) ? eValue : [eValue]) {
3367
+ if (v != null && v !== true && v !== "") {
3368
+ expressions.push(String(v));
2618
3369
  }
2619
3370
  }
2620
- for (const t of tokens) {
2621
- nonFlagTokens.push(t);
2622
- }
2623
- if (expressions.length === 0 && nonFlagTokens.length > 0) {
2624
- expressions.push(nonFlagTokens.shift());
3371
+ if (expressions.length === 0 && tokens.length > 0) {
3372
+ expressions.push(tokens.shift());
2625
3373
  }
2626
- if (nonFlagTokens.length > 0) {
2627
- filePath = nonFlagTokens[nonFlagTokens.length - 1];
2628
- }
2629
- return { inPlace, suppress, expressions, filePath };
3374
+ return { inPlace, suppress, extended, expressions, filePaths: tokens };
2630
3375
  }
2631
3376
  tokenize(raw) {
2632
3377
  const tokens = [];
@@ -2636,73 +3381,85 @@ var CliSedCommandProcessor = class {
2636
3381
  i++;
2637
3382
  continue;
2638
3383
  }
2639
- if (raw[i] === "'" || raw[i] === '"') {
2640
- const quote = raw[i];
2641
- i++;
2642
- let token2 = "";
2643
- while (i < raw.length && raw[i] !== quote) {
2644
- token2 += raw[i];
2645
- i++;
2646
- }
2647
- if (i < raw.length) i++;
2648
- tokens.push(token2);
2649
- continue;
2650
- }
2651
3384
  let token = "";
2652
3385
  while (i < raw.length && raw[i] !== " " && raw[i] !== " ") {
2653
- token += raw[i];
2654
- i++;
3386
+ if (raw[i] === "'" || raw[i] === '"') {
3387
+ const quote = raw[i];
3388
+ i++;
3389
+ while (i < raw.length && raw[i] !== quote) {
3390
+ token += raw[i];
3391
+ i++;
3392
+ }
3393
+ if (i < raw.length) i++;
3394
+ } else {
3395
+ token += raw[i];
3396
+ i++;
3397
+ }
2655
3398
  }
2656
3399
  tokens.push(token);
2657
3400
  }
2658
- return tokens;
3401
+ return tokens;
3402
+ }
3403
+ /** Commands of a script, separated by `;` or newlines. */
3404
+ parseScript(script, extended) {
3405
+ const result = [];
3406
+ let pos = 0;
3407
+ const skipSeparators = () => {
3408
+ while (pos < script.length && /[\s;]/.test(script[pos])) pos++;
3409
+ };
3410
+ skipSeparators();
3411
+ while (pos < script.length) {
3412
+ const parsed = this.parseCommand(script, pos, extended);
3413
+ result.push(parsed.expression);
3414
+ pos = parsed.pos;
3415
+ while (pos < script.length && /[ \t]/.test(script[pos])) pos++;
3416
+ if (pos < script.length && !/[;\n]/.test(script[pos])) {
3417
+ throw new Error(`extra characters after command: '${script.slice(pos)}'`);
3418
+ }
3419
+ skipSeparators();
3420
+ }
3421
+ if (result.length === 0) {
3422
+ throw new Error(`invalid expression: '${script}'`);
3423
+ }
3424
+ return result;
2659
3425
  }
2660
- parseExpression(expr) {
2661
- let pos = 0;
2662
- let address = null;
2663
- if (pos < expr.length) {
2664
- const addrResult = this.parseAddress(expr, pos);
2665
- if (addrResult) {
2666
- address = addrResult.address;
2667
- pos = addrResult.pos;
2668
- if (pos < expr.length && expr[pos] === ",") {
2669
- pos++;
2670
- const endResult = this.parseAddress(expr, pos);
2671
- if (endResult) {
2672
- if (endResult.address.type === "last") {
2673
- address = {
2674
- type: "range",
2675
- startLine: address.line,
2676
- endLine: 0,
2677
- endIsLast: true
2678
- };
2679
- } else {
2680
- address = {
2681
- type: "range",
2682
- startLine: address.line,
2683
- endLine: endResult.address.line
2684
- };
2685
- }
2686
- pos = endResult.pos;
2687
- }
3426
+ parseCommand(expr, pos, extended) {
3427
+ let start = null;
3428
+ let end = null;
3429
+ const first = this.parseAddress(expr, pos, extended);
3430
+ if (first) {
3431
+ start = first.address;
3432
+ pos = first.pos;
3433
+ if (expr[pos] === ",") {
3434
+ const second = this.parseAddress(expr, pos + 1, extended);
3435
+ if (!second) {
3436
+ throw new Error(`unexpected ',' in '${expr}'`);
2688
3437
  }
3438
+ end = second.address;
3439
+ pos = second.pos;
2689
3440
  }
2690
3441
  }
3442
+ while (pos < expr.length && /[ \t]/.test(expr[pos])) pos++;
3443
+ let negate = false;
3444
+ if (expr[pos] === "!") {
3445
+ negate = true;
3446
+ pos++;
3447
+ while (pos < expr.length && /[ \t]/.test(expr[pos])) pos++;
3448
+ }
2691
3449
  if (pos >= expr.length) {
2692
3450
  throw new Error(`invalid expression: '${expr}'`);
2693
3451
  }
3452
+ const base = { start, end, negate, active: false };
2694
3453
  const cmd = expr[pos];
2695
3454
  if (cmd === "s") {
2696
- return this.parseSubstitution(expr, pos, address);
2697
- } else if (cmd === "d") {
2698
- return { address, command: "d" };
2699
- } else if (cmd === "p") {
2700
- return { address, command: "p" };
2701
- } else {
2702
- throw new Error(`unknown command: '${cmd}'`);
3455
+ return this.parseSubstitution(expr, pos, base, extended);
3456
+ }
3457
+ if (cmd === "d" || cmd === "p" || cmd === "q") {
3458
+ return { expression: { ...base, command: cmd }, pos: pos + 1 };
2703
3459
  }
3460
+ throw new Error(`unknown command: '${cmd}'`);
2704
3461
  }
2705
- parseAddress(expr, pos) {
3462
+ parseAddress(expr, pos, extended) {
2706
3463
  if (pos >= expr.length) return null;
2707
3464
  if (expr[pos] >= "0" && expr[pos] <= "9") {
2708
3465
  let numStr = "";
@@ -2719,51 +3476,73 @@ var CliSedCommandProcessor = class {
2719
3476
  return { address: { type: "last" }, pos: pos + 1 };
2720
3477
  }
2721
3478
  if (expr[pos] === "/") {
2722
- pos++;
2723
- let pattern = "";
2724
- while (pos < expr.length && expr[pos] !== "/") {
2725
- if (expr[pos] === "\\" && pos + 1 < expr.length) {
2726
- pattern += expr[pos + 1];
2727
- pos += 2;
2728
- } else {
2729
- pattern += expr[pos];
2730
- pos++;
2731
- }
2732
- }
2733
- if (pos < expr.length) pos++;
3479
+ const parsed = this.readDelimited(expr, pos + 1, "/");
2734
3480
  try {
2735
3481
  return {
2736
- address: { type: "regex", regex: new RegExp(pattern) },
2737
- pos
3482
+ address: {
3483
+ type: "regex",
3484
+ regex: new RegExp(this.toJsRegex(parsed.text, extended))
3485
+ },
3486
+ pos: parsed.pos
2738
3487
  };
2739
3488
  } catch {
2740
- throw new Error(`invalid regex in address: '${pattern}'`);
3489
+ throw new Error(`invalid regex in address: '${parsed.text}'`);
2741
3490
  }
2742
3491
  }
2743
3492
  return null;
2744
3493
  }
2745
- parseSubstitution(expr, pos, address) {
2746
- pos++;
2747
- if (pos >= expr.length) {
2748
- throw new Error(`invalid substitution expression: '${expr}'`);
2749
- }
2750
- const delimiter = expr[pos];
2751
- pos++;
2752
- let pattern = "";
3494
+ /**
3495
+ * Text up to the next unescaped delimiter, escapes kept (an escaped
3496
+ * delimiter becomes the plain character when it has no regex meaning).
3497
+ */
3498
+ readDelimited(expr, pos, delimiter) {
3499
+ let text = "";
2753
3500
  while (pos < expr.length && expr[pos] !== delimiter) {
2754
3501
  if (expr[pos] === "\\" && pos + 1 < expr.length) {
2755
- pattern += "\\" + expr[pos + 1];
3502
+ const next = expr[pos + 1];
3503
+ text += next === delimiter && /\w|\//.test(next) ? next : "\\" + next;
2756
3504
  pos += 2;
2757
3505
  } else {
2758
- pattern += expr[pos];
3506
+ text += expr[pos];
2759
3507
  pos++;
2760
3508
  }
2761
3509
  }
2762
3510
  if (pos < expr.length) pos++;
3511
+ return { text, pos };
3512
+ }
3513
+ /**
3514
+ * Basic regular expressions use \( \) \{ \} \+ \? \| for groups and
3515
+ * operators. When a pattern uses them (or never in -E mode), they are
3516
+ * turned into the JavaScript form and the bare characters made literal.
3517
+ */
3518
+ toJsRegex(pattern, extended) {
3519
+ if (extended || !/\\[(){}+?|]/.test(pattern)) return pattern;
3520
+ let out = "";
3521
+ for (let i = 0; i < pattern.length; i++) {
3522
+ const ch = pattern[i];
3523
+ if (ch === "\\" && i + 1 < pattern.length) {
3524
+ const next = pattern[++i];
3525
+ out += "(){}+?|".includes(next) ? next : "\\" + next;
3526
+ } else if ("(){}+?|".includes(ch)) {
3527
+ out += "\\" + ch;
3528
+ } else {
3529
+ out += ch;
3530
+ }
3531
+ }
3532
+ return out;
3533
+ }
3534
+ parseSubstitution(expr, pos, base, extended) {
3535
+ pos++;
3536
+ if (pos >= expr.length) {
3537
+ throw new Error(`invalid substitution expression: '${expr}'`);
3538
+ }
3539
+ const delimiter = expr[pos];
3540
+ const pattern = this.readDelimited(expr, pos + 1, delimiter);
3541
+ pos = pattern.pos;
2763
3542
  let replacement = "";
2764
3543
  while (pos < expr.length && expr[pos] !== delimiter) {
2765
3544
  if (expr[pos] === "\\" && pos + 1 < expr.length) {
2766
- replacement += expr[pos + 1];
3545
+ replacement += expr[pos + 1] === delimiter ? delimiter : "\\" + expr[pos + 1];
2767
3546
  pos += 2;
2768
3547
  } else {
2769
3548
  replacement += expr[pos];
@@ -2774,47 +3553,75 @@ var CliSedCommandProcessor = class {
2774
3553
  let globalFlag = false;
2775
3554
  let caseInsensitive = false;
2776
3555
  let printFlag = false;
2777
- while (pos < expr.length) {
3556
+ let occurrence = "";
3557
+ while (pos < expr.length && !/[;\n\s}]/.test(expr[pos])) {
2778
3558
  const f = expr[pos];
2779
3559
  if (f === "g") globalFlag = true;
2780
- else if (f === "i") caseInsensitive = true;
3560
+ else if (f === "i" || f === "I") caseInsensitive = true;
2781
3561
  else if (f === "p") printFlag = true;
3562
+ else if (f >= "0" && f <= "9") occurrence += f;
3563
+ else throw new Error(`unknown option to 's': '${f}'`);
2782
3564
  pos++;
2783
3565
  }
3566
+ const nth = occurrence ? parseInt(occurrence, 10) : void 0;
3567
+ if (nth === 0) {
3568
+ throw new Error("number option to 's' command may not be zero");
3569
+ }
2784
3570
  let regexFlags = "";
2785
3571
  if (caseInsensitive) regexFlags += "i";
2786
- if (globalFlag) regexFlags += "g";
3572
+ if (globalFlag || nth) regexFlags += "g";
2787
3573
  let regex;
3574
+ let groupCount;
2788
3575
  try {
2789
- regex = new RegExp(pattern, regexFlags);
3576
+ const source = this.toJsRegex(pattern.text, extended);
3577
+ regex = new RegExp(source, regexFlags);
3578
+ groupCount = (new RegExp(`${source}|`).exec("")?.length ?? 1) - 1;
2790
3579
  } catch {
2791
- throw new Error(`invalid regex: '${pattern}'`);
3580
+ throw new Error(`invalid regex: '${pattern.text}'`);
2792
3581
  }
2793
3582
  return {
2794
- address,
2795
- command: "s",
2796
- pattern: regex,
2797
- replacement,
2798
- globalFlag,
2799
- printFlag
3583
+ expression: {
3584
+ ...base,
3585
+ command: "s",
3586
+ pattern: regex,
3587
+ replacement,
3588
+ groupCount,
3589
+ printFlag,
3590
+ occurrence: nth,
3591
+ globalFlag
3592
+ },
3593
+ pos
2800
3594
  };
2801
3595
  }
2802
- addressMatches(address, lineNum, totalLines, lineText) {
2803
- if (!address) return true;
3596
+ addressMatches(expr, lineNum, totalLines, lineText) {
3597
+ let matches;
3598
+ if (!expr.start) {
3599
+ matches = true;
3600
+ } else if (!expr.end) {
3601
+ matches = this.singleMatches(expr.start, lineNum, totalLines, lineText);
3602
+ } else if (!expr.active) {
3603
+ matches = this.singleMatches(expr.start, lineNum, totalLines, lineText);
3604
+ if (matches) {
3605
+ const end = expr.end;
3606
+ const endsHere = end.type === "line" && end.line <= lineNum || end.type === "last" && lineNum === totalLines;
3607
+ expr.active = !endsHere;
3608
+ }
3609
+ } else {
3610
+ matches = true;
3611
+ const end = expr.end;
3612
+ const endsHere = end.type === "line" ? lineNum >= end.line : this.singleMatches(end, lineNum, totalLines, lineText);
3613
+ if (endsHere) expr.active = false;
3614
+ }
3615
+ return expr.negate ? !matches : matches;
3616
+ }
3617
+ singleMatches(address, lineNum, totalLines, lineText) {
2804
3618
  switch (address.type) {
2805
3619
  case "line":
2806
3620
  return lineNum === address.line;
2807
3621
  case "last":
2808
3622
  return lineNum === totalLines;
2809
- case "range": {
2810
- const start = address.startLine;
2811
- const end = address.endIsLast ? totalLines : address.endLine;
2812
- return lineNum >= start && lineNum <= end;
2813
- }
2814
3623
  case "regex":
2815
3624
  return address.regex.test(lineText);
2816
- default:
2817
- return true;
2818
3625
  }
2819
3626
  }
2820
3627
  };
@@ -2836,11 +3643,34 @@ var CliAwkCommandProcessor = class {
2836
3643
  }
2837
3644
  ];
2838
3645
  }
3646
+ writeDescription(context) {
3647
+ const t = context.translator;
3648
+ cliCore.writeWrapped(
3649
+ context,
3650
+ t.t("cli.awk.help.process_text_records_fields", "Process text as records and fields: each line is split into fields $1, $2, ... ($0 is the whole line), and each rule runs its { action } on matching lines. Supports BEGIN and END blocks, /regex/ and field comparison patterns, print, variables, NR and NF. Quote the program; a simple one also works unquoted (awk {print $1} file).")
3651
+ );
3652
+ context.writer.writeln();
3653
+ cliCore.writeUsage(context, [
3654
+ ["awk '<program>' <file>", t.t("cli.awk.help.run_program_over_file", "Run a program over a file")],
3655
+ [
3656
+ "awk -F <sep> '<program>' <file>",
3657
+ t.t("cli.awk.help.split_fields_instead_spaces", "Split fields on <sep> instead of spaces")
3658
+ ],
3659
+ ["<command> | awk '<program>'", t.t("cli.awk.help.run_program_over_piped", "Run a program over piped input")]
3660
+ ]);
3661
+ context.writer.writeln();
3662
+ cliCore.writeExamples(context, [
3663
+ ["awk '{print $1}' data.txt", t.t("cli.awk.help.print_first_column", "Print the first column")],
3664
+ ["awk -F , '{print $2}' data.csv", t.t("cli.awk.help.second_column_csv", "Second column of a CSV")],
3665
+ ["awk '$3 > 100 {print $1}' sales.txt", t.t("cli.awk.help.filter_rows_field", "Filter rows by a field")],
3666
+ ["awk '{s += $2} END {print s}' data.txt", t.t("cli.awk.help.sum_second_column", "Sum the second column")]
3667
+ ]);
3668
+ }
2839
3669
  async processCommand(command, context) {
2840
- const fs = context.services.get(
3670
+ const fs = context.services.getRequired(
2841
3671
  IFileSystemService_TOKEN
2842
3672
  );
2843
- const { program, filePath, fieldSep } = this.parseArgs(command);
3673
+ const { program, filePaths, fieldSep } = this.parseArgs(command, fs);
2844
3674
  if (!program) {
2845
3675
  context.writer.writeError(
2846
3676
  "awk: missing program. Usage: awk [-F sep] 'program' file"
@@ -2848,18 +3678,21 @@ var CliAwkCommandProcessor = class {
2848
3678
  return;
2849
3679
  }
2850
3680
  let content = "";
2851
- if (filePath) {
2852
- try {
2853
- if (!fs.exists(filePath)) {
2854
- context.writer.writeError(
2855
- `awk: ${filePath}: No such file or directory`
2856
- );
3681
+ if (filePaths.length > 0) {
3682
+ for (const filePath of filePaths) {
3683
+ try {
3684
+ if (!fs.exists(filePath)) {
3685
+ context.writer.writeError(
3686
+ `awk: ${filePath}: No such file or directory`
3687
+ );
3688
+ return;
3689
+ }
3690
+ const text = fs.readFile(filePath) ?? "";
3691
+ content += text === "" || text.endsWith("\n") ? text : text + "\n";
3692
+ } catch (e) {
3693
+ context.writer.writeError(`awk: ${e.message}`);
2857
3694
  return;
2858
3695
  }
2859
- content = fs.readFile(filePath) ?? "";
2860
- } catch (e) {
2861
- context.writer.writeError(`awk: ${e.message}`);
2862
- return;
2863
3696
  }
2864
3697
  } else if (command.data != null) {
2865
3698
  content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
@@ -2921,24 +3754,58 @@ var CliAwkCommandProcessor = class {
2921
3754
  context.writer.writeln(line);
2922
3755
  }
2923
3756
  }
2924
- parseArgs(command) {
2925
- const raw = command.value || "";
2926
- const fieldSep = command.args["F"] || null;
2927
- let rest = raw;
2928
- let program = null;
2929
- const sqMatch = rest.match(/^'((?:[^'\\]|\\.)*)'\s*/);
2930
- if (sqMatch) {
2931
- program = sqMatch[1];
2932
- rest = rest.slice(sqMatch[0].length);
2933
- } else {
2934
- const dqMatch = rest.match(/^"((?:[^"\\]|\\.)*)"\s*/);
2935
- if (dqMatch) {
2936
- program = dqMatch[1];
2937
- rest = rest.slice(dqMatch[0].length);
3757
+ /**
3758
+ * The program is the first operand, usually quoted. Unquoted, it may span
3759
+ * several words (`awk {print $1} file`): it is then everything before the
3760
+ * trailing operands that are existing files, or the whole value with
3761
+ * piped input.
3762
+ */
3763
+ parseArgs(command, fs) {
3764
+ const raw = (command.value || "").trim();
3765
+ const fieldArg = command.args["F"];
3766
+ const fieldSep = fieldArg != null && fieldArg !== true ? String(fieldArg) : null;
3767
+ const quoted = raw.match(/^'((?:[^'\\]|\\.)*)'(?:\s+|$)/) ?? raw.match(/^"((?:[^"\\]|\\.)*)"(?:\s+|$)/);
3768
+ if (quoted) {
3769
+ return {
3770
+ program: quoted[1],
3771
+ filePaths: splitArgs(raw.slice(quoted[0].length)),
3772
+ fieldSep
3773
+ };
3774
+ }
3775
+ const words = raw.split(/\s+/).filter(Boolean);
3776
+ if (words.length === 0) {
3777
+ return { program: null, filePaths: [], fieldSep };
3778
+ }
3779
+ const isFile = (word) => {
3780
+ try {
3781
+ const path = splitArgs(word).join(" ");
3782
+ return fs.exists(path) && !fs.isDirectory(path);
3783
+ } catch {
3784
+ return false;
2938
3785
  }
3786
+ };
3787
+ let split = words.length;
3788
+ while (split > 1 && isFile(words[split - 1])) split--;
3789
+ if (split === words.length && command.data == null && words.length > 1) {
3790
+ const program = words.slice(0, -1).join(" ");
3791
+ const last = words[words.length - 1];
3792
+ if (this.bracesBalanced(program) && !/[{}]/.test(last)) {
3793
+ split = words.length - 1;
3794
+ }
3795
+ }
3796
+ return {
3797
+ program: words.slice(0, split).join(" "),
3798
+ filePaths: words.slice(split).map((w) => splitArgs(w).join(" ")),
3799
+ fieldSep
3800
+ };
3801
+ }
3802
+ bracesBalanced(text) {
3803
+ let depth = 0;
3804
+ for (const ch of text) {
3805
+ if (ch === "{") depth++;
3806
+ else if (ch === "}" && --depth < 0) return false;
2939
3807
  }
2940
- const filePath = rest.trim() || null;
2941
- return { program, filePath, fieldSep };
3808
+ return depth === 0;
2942
3809
  }
2943
3810
  parseProgram(program) {
2944
3811
  const rules = [];
@@ -3312,8 +4179,26 @@ var CliDiffCommandProcessor = class {
3312
4179
  }
3313
4180
  ];
3314
4181
  }
4182
+ writeDescription(context) {
4183
+ const t = context.translator;
4184
+ cliCore.writeWrapped(
4185
+ context,
4186
+ t.t("cli.diff.help.compare_two_files_line", "Compare two files line by line and show what changed: lines starting with < (or -) are only in the first file, lines starting with > (or +) only in the second.")
4187
+ );
4188
+ context.writer.writeln();
4189
+ cliCore.writeUsage(context, [
4190
+ ["diff <file1> <file2>", t.t("cli.diff.help.show_differences", "Show the differences")],
4191
+ ["diff -u <file1> <file2>", t.t("cli.diff.help.unified_format_context", "Unified format with context")]
4192
+ ]);
4193
+ context.writer.writeln();
4194
+ cliCore.writeExamples(context, [
4195
+ ["diff old.txt new.txt", t.t("cli.diff.help.what_changed_between_versions", "What changed between versions")],
4196
+ ["diff -u config.bak config.txt", t.t("cli.diff.help.review_edits_git_style", "Review edits, git style")],
4197
+ ["diff -i a.txt b.txt", t.t("cli.diff.help.ignore_upper_lower_case", "Ignore upper/lower case")]
4198
+ ]);
4199
+ }
3315
4200
  async processCommand(command, context) {
3316
- const fs = context.services.get(
4201
+ const fs = context.services.getRequired(
3317
4202
  IFileSystemService_TOKEN
3318
4203
  );
3319
4204
  const unified = command.args["unified"] || command.args["u"] || false;
@@ -3476,13 +4361,13 @@ var CliDiffCommandProcessor = class {
3476
4361
  }
3477
4362
  parsePaths(command) {
3478
4363
  const raw = command.value || "";
3479
- return raw.split(/\s+/).filter(Boolean);
4364
+ return splitArgs(raw).filter(Boolean);
3480
4365
  }
3481
4366
  };
3482
4367
  var CliTeeCommandProcessor = class {
3483
4368
  constructor() {
3484
4369
  this.command = "tee";
3485
- this.description = "Read from a file and write to output files and stdout";
4370
+ this.description = "Copy piped input to files and to the output";
3486
4371
  this.author = cliCore.DefaultLibraryAuthor;
3487
4372
  this.version = LIBRARY_VERSION;
3488
4373
  this.acceptsRawInput = true;
@@ -3498,72 +4383,56 @@ var CliTeeCommandProcessor = class {
3498
4383
  }
3499
4384
  ];
3500
4385
  }
4386
+ writeDescription(context) {
4387
+ const t = context.translator;
4388
+ cliCore.writeWrapped(
4389
+ context,
4390
+ t.t("cli.tee.help.pass_piped_input_through", "Pass piped input through unchanged and save a copy of it to every file named, overwriting them or appending with -a. Like Unix tee, it reads only piped input: to copy a file, pipe it in with cat.")
4391
+ );
4392
+ context.writer.writeln();
4393
+ cliCore.writeUsage(context, [
4394
+ ["<command> | tee <file...>", t.t("cli.tee.help.show_piped_output_save", "Show piped output and save it")],
4395
+ ["<command> | tee -a <file>", t.t("cli.tee.help.append_instead_overwriting", "Append instead of overwriting")]
4396
+ ]);
4397
+ context.writer.writeln();
4398
+ cliCore.writeExamples(context, [
4399
+ ["cat notes.txt | tee copy.txt", t.t("cli.tee.help.show_save_copy", "Show and save a copy")],
4400
+ ["echo done | tee -a log.txt", t.t("cli.tee.help.add_line_log", "Add a line to a log")],
4401
+ [
4402
+ "cat source.txt | tee a.txt b.txt",
4403
+ t.t("cli.tee.help.copy_source_txt_txt", "Copy source.txt into a.txt and b.txt")
4404
+ ]
4405
+ ]);
4406
+ }
3501
4407
  async processCommand(command, context) {
3502
- const fs = context.services.get(
4408
+ const fs = context.services.getRequired(
3503
4409
  IFileSystemService_TOKEN
3504
4410
  );
3505
4411
  const append = command.args["append"] || command.args["a"] || false;
3506
- const parsed = this.parseArgs(command);
3507
- let content;
3508
- if (command.data != null) {
3509
- content = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
3510
- const outputFiles = this.getAllPaths(command);
3511
- context.writer.writeln(content);
3512
- for (const outFile of outputFiles) {
3513
- if (append && fs.exists(outFile)) {
3514
- fs.writeFile(outFile, content, true);
3515
- } else {
3516
- fs.writeFile(outFile, content);
3517
- }
3518
- }
3519
- await fs.persist();
3520
- } else {
3521
- if (parsed.outputFiles.length === 0 && !parsed.inputFile) {
3522
- context.writer.writeError("tee: missing operand");
3523
- return;
3524
- }
3525
- if (!parsed.inputFile) {
3526
- context.writer.writeError("tee: missing input file");
3527
- return;
3528
- }
3529
- if (parsed.outputFiles.length === 0) {
3530
- context.writer.writeError("tee: missing output file");
3531
- return;
3532
- }
3533
- if (!fs.exists(parsed.inputFile)) {
3534
- context.writer.writeError(
3535
- `tee: ${parsed.inputFile}: No such file or directory`
3536
- );
3537
- return;
3538
- }
3539
- content = fs.readFile(parsed.inputFile) || "";
3540
- context.writer.writeln(content);
3541
- for (const outFile of parsed.outputFiles) {
3542
- if (append && fs.exists(outFile)) {
3543
- fs.writeFile(outFile, content, true);
3544
- } else {
3545
- fs.writeFile(outFile, content);
3546
- }
4412
+ if (command.data == null) {
4413
+ context.writer.writeError(
4414
+ "tee: no input: tee copies piped input to files and the output; use 'cat file | tee out.txt'"
4415
+ );
4416
+ context.process.exit(1, { silent: true });
4417
+ return;
4418
+ }
4419
+ const content = pipedText(command.data);
4420
+ context.writer.writeln(content);
4421
+ const fileText = content === "" || content.endsWith("\n") ? content : content + "\n";
4422
+ let changed = false;
4423
+ for (const outFile of this.getAllPaths(command)) {
4424
+ try {
4425
+ fs.writeFile(outFile, fileText, append);
4426
+ changed = true;
4427
+ } catch (e) {
4428
+ context.writer.writeError(`tee: ${e.message}`);
3547
4429
  }
3548
- await fs.persist();
3549
4430
  }
4431
+ if (changed) await fs.persist();
3550
4432
  }
3551
4433
  getAllPaths(command) {
3552
4434
  const raw = command.value || "";
3553
- return raw.split(/\s+/).filter(Boolean);
3554
- }
3555
- parseArgs(command) {
3556
- const raw = command.value || "";
3557
- const paths = raw.split(/\s+/).filter(Boolean);
3558
- if (paths.length < 2) {
3559
- return {
3560
- outputFiles: [],
3561
- inputFile: paths[0] || null
3562
- };
3563
- }
3564
- const inputFile = paths[paths.length - 1];
3565
- const outputFiles = paths.slice(0, paths.length - 1);
3566
- return { outputFiles, inputFile };
4435
+ return splitArgs(raw).filter(Boolean);
3567
4436
  }
3568
4437
  };
3569
4438
  var CliXargsCommandProcessor = class {
@@ -3592,65 +4461,91 @@ var CliXargsCommandProcessor = class {
3592
4461
  }
3593
4462
  ];
3594
4463
  }
4464
+ writeDescription(context) {
4465
+ const t = context.translator;
4466
+ cliCore.writeWrapped(
4467
+ context,
4468
+ t.t("cli.xargs.help.run_command_arguments_taken", "Run a command with arguments taken from input, separated by blanks or newlines. By default all of them are appended to one command; -n runs it in batches and -I runs it once per line, replacing a placeholder. xargs options go before the command; everything after it, flags included, belongs to the command. Input is piped text or, without a pipe, the last file named.")
4469
+ );
4470
+ context.writer.writeln();
4471
+ cliCore.writeUsage(context, [
4472
+ ["<command> | xargs <cmd>", t.t("cli.xargs.help.append_input_lines", "Append input lines to <cmd>")],
4473
+ ["<command> | xargs -I {} <cmd {}>", t.t("cli.xargs.help.run_once_per_line", "Run <cmd> once per line")],
4474
+ ["xargs <cmd> <file>", t.t("cli.xargs.help.read_arguments_file", "Read the arguments from a file")]
4475
+ ]);
4476
+ context.writer.writeln();
4477
+ cliCore.writeExamples(context, [
4478
+ ['find . --name "*.tmp" | xargs rm', t.t("cli.xargs.help.delete_every_tmp_file", "Delete every .tmp file")],
4479
+ [
4480
+ "cat files.txt | xargs -I {} cp {} backup",
4481
+ t.t("cli.xargs.help.copy_each_listed_file", "Copy each listed file")
4482
+ ],
4483
+ ["cat dirs.txt | xargs -n 1 mkdir", t.t("cli.xargs.help.one_mkdir_per_line", "One mkdir per line")],
4484
+ ['find . --name "*.bak" | xargs rm -r', t.t("cli.xargs.help.inner_command_keeps_flags", "The inner command keeps its flags")]
4485
+ ]);
4486
+ }
3595
4487
  async processCommand(command, context) {
3596
- const fs = context.services.get(
4488
+ const fs = context.services.getRequired(
3597
4489
  IFileSystemService_TOKEN
3598
4490
  );
3599
- const replaceStr = command.args["replace"] || command.args["I"] || null;
3600
- const maxArgs = parseInt(
3601
- command.args["max-args"] || command.args["n"] || "0",
3602
- 10
3603
- );
3604
- const parsed = this.parseArgs(command);
4491
+ let parsed;
4492
+ try {
4493
+ parsed = command.rawCommand ? parseXargsLine(command.rawCommand) : {
4494
+ replace: stringArg(command.args["replace"] ?? command.args["I"]),
4495
+ maxArgs: parseInt(
4496
+ String(command.args["max-args"] ?? command.args["n"] ?? ""),
4497
+ 10
4498
+ ) || void 0,
4499
+ words: splitWords(command.value ?? "")
4500
+ };
4501
+ } catch (e) {
4502
+ context.writer.writeError(`xargs: ${e.message}`);
4503
+ return;
4504
+ }
4505
+ const replaceStr = parsed.replace;
4506
+ const maxArgs = parsed.maxArgs ?? 0;
3605
4507
  let commandTemplate;
3606
4508
  let argContent;
3607
4509
  if (command.data != null) {
3608
- commandTemplate = this.getAllNonFlagTokens(command).join(" ");
3609
- if (!commandTemplate) {
3610
- context.writer.writeError("xargs: missing command");
3611
- return;
3612
- }
3613
- argContent = typeof command.data === "string" ? command.data : JSON.stringify(command.data);
4510
+ commandTemplate = parsed.words.map((w) => w.raw).join(" ") || "echo";
4511
+ argContent = pipedText(command.data);
3614
4512
  } else {
3615
- if (!parsed.commandTemplate) {
4513
+ if (parsed.words.length === 0) {
3616
4514
  context.writer.writeError("xargs: missing command");
3617
4515
  return;
3618
4516
  }
3619
- commandTemplate = parsed.commandTemplate;
3620
- if (!parsed.inputFile) {
4517
+ if (parsed.words.length < 2) {
3621
4518
  context.writer.writeError("xargs: missing input file");
3622
4519
  return;
3623
4520
  }
3624
- if (!fs.exists(parsed.inputFile)) {
4521
+ const inputFile = parsed.words[parsed.words.length - 1].value;
4522
+ commandTemplate = parsed.words.slice(0, -1).map((w) => w.raw).join(" ");
4523
+ if (!fs.exists(inputFile)) {
3625
4524
  context.writer.writeError(
3626
- `xargs: ${parsed.inputFile}: No such file or directory`
4525
+ `xargs: ${inputFile}: No such file or directory`
3627
4526
  );
3628
4527
  return;
3629
4528
  }
3630
- argContent = fs.readFile(parsed.inputFile) || "";
4529
+ argContent = fs.readFile(inputFile) || "";
3631
4530
  }
3632
- const args = argContent.split("\n").map((l) => l.trim()).filter(Boolean);
3633
- if (args.length === 0) return;
3634
4531
  const commands = [];
3635
4532
  if (replaceStr) {
3636
- for (const arg of args) {
3637
- const cmd = commandTemplate.replace(
3638
- new RegExp(this.escapeRegex(replaceStr), "g"),
3639
- arg
4533
+ const lines = argContent.split("\n").map((l) => l.trim()).filter(Boolean);
4534
+ const placeholder = new RegExp(this.escapeRegex(replaceStr), "g");
4535
+ for (const line of lines) {
4536
+ commands.push(
4537
+ commandTemplate.replace(placeholder, () => quoteArg(line))
3640
4538
  );
3641
- commands.push(cmd);
3642
4539
  }
3643
- } else if (maxArgs > 0) {
3644
- for (let i = 0; i < args.length; i += maxArgs) {
3645
- const group = args.slice(i, i + maxArgs);
4540
+ } else {
4541
+ const args = splitArgs(argContent).map(quoteArg);
4542
+ if (args.length === 0) return;
4543
+ const size = maxArgs > 0 ? maxArgs : args.length;
4544
+ for (let i = 0; i < args.length; i += size) {
3646
4545
  commands.push(
3647
- `${commandTemplate} ${group.join(" ")}`
4546
+ `${commandTemplate} ${args.slice(i, i + size).join(" ")}`
3648
4547
  );
3649
4548
  }
3650
- } else {
3651
- commands.push(
3652
- `${commandTemplate} ${args.join(" ")}`
3653
- );
3654
4549
  }
3655
4550
  const executor = context.executor;
3656
4551
  for (const cmd of commands) {
@@ -3665,27 +4560,64 @@ var CliXargsCommandProcessor = class {
3665
4560
  }
3666
4561
  }
3667
4562
  }
3668
- getAllNonFlagTokens(command) {
3669
- const raw = command.value || "";
3670
- return raw.split(/\s+/).filter(Boolean);
3671
- }
3672
4563
  escapeRegex(str) {
3673
4564
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3674
4565
  }
3675
- parseArgs(command) {
3676
- const raw = command.value || "";
3677
- const nonFlagTokens = raw.split(/\s+/).filter(Boolean);
3678
- if (nonFlagTokens.length < 2) {
3679
- return {
3680
- commandTemplate: nonFlagTokens[0] || null,
3681
- inputFile: null
3682
- };
4566
+ };
4567
+ function parseXargsLine(raw) {
4568
+ const words = splitWords(raw).slice(1);
4569
+ const result = { words: [] };
4570
+ let i = 0;
4571
+ const valueOf = (option, inline) => {
4572
+ if (inline !== "") return inline;
4573
+ if (i + 1 >= words.length) {
4574
+ throw new Error(`option requires an argument -- '${option}'`);
4575
+ }
4576
+ return words[++i].value;
4577
+ };
4578
+ for (; i < words.length; i++) {
4579
+ const word = words[i].raw;
4580
+ if (word === "--") {
4581
+ i++;
4582
+ break;
4583
+ }
4584
+ if (!word.startsWith("-") || word === "-") break;
4585
+ let match;
4586
+ if (match = word.match(/^(?:-I|--replace(?:=|$))(.*)$/)) {
4587
+ result.replace = valueOf("I", unquote(match[1]));
4588
+ } else if (word === "-i") {
4589
+ result.replace = "{}";
4590
+ } else if (match = word.match(/^(?:-n|--max-args(?:=|$))(.*)$/)) {
4591
+ const value = valueOf("n", unquote(match[1]));
4592
+ const count = parseInt(value, 10);
4593
+ if (isNaN(count) || count < 1) {
4594
+ throw new Error(`invalid number of arguments: '${value}'`);
4595
+ }
4596
+ result.maxArgs = count;
4597
+ } else {
4598
+ throw new Error(`invalid option -- '${word.replace(/^-+/, "")}'`);
3683
4599
  }
3684
- const inputFile = nonFlagTokens[nonFlagTokens.length - 1];
3685
- const commandTemplate = nonFlagTokens.slice(0, nonFlagTokens.length - 1).join(" ");
3686
- return { commandTemplate, inputFile };
3687
4600
  }
3688
- };
4601
+ result.words = words.slice(i);
4602
+ return result;
4603
+ }
4604
+ function splitWords(raw) {
4605
+ const words = [];
4606
+ const regex = /(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']|["'])+/g;
4607
+ for (const match of raw.matchAll(regex)) {
4608
+ words.push({ raw: match[0], value: splitArgs(match[0]).join(" ") });
4609
+ }
4610
+ return words;
4611
+ }
4612
+ function unquote(value) {
4613
+ return splitArgs(value).join(" ");
4614
+ }
4615
+ function stringArg(value) {
4616
+ return value == null || value === true || value === "" ? void 0 : String(value);
4617
+ }
4618
+ function quoteArg(arg) {
4619
+ return cliCore.quoteShellWord(arg);
4620
+ }
3689
4621
  var CliShCommandProcessor = class {
3690
4622
  constructor() {
3691
4623
  this.command = "sh";
@@ -3706,17 +4638,14 @@ var CliShCommandProcessor = class {
3706
4638
  ];
3707
4639
  }
3708
4640
  async processCommand(command, context) {
3709
- const fs = context.services.get(
4641
+ const fs = context.services.getRequired(
3710
4642
  IFileSystemService_TOKEN
3711
4643
  );
3712
- let filePath = (command.value || "").trim();
3713
- if (!filePath && command.rawCommand) {
3714
- const raw = command.rawCommand.trim();
3715
- const firstSpace = raw.indexOf(" ");
3716
- if (firstSpace > -1) {
3717
- filePath = raw.slice(firstSpace + 1).trim();
3718
- }
4644
+ let words = splitArgs(command.value || "");
4645
+ if (words.length === 0 && command.rawCommand) {
4646
+ words = splitArgs(command.rawCommand).slice(1).filter((w) => !w.startsWith("-"));
3719
4647
  }
4648
+ const filePath = words[0] ?? "";
3720
4649
  if (!filePath) {
3721
4650
  context.writer.writeError("sh: missing file operand");
3722
4651
  context.process.exit(1, { silent: true });
@@ -3749,10 +4678,10 @@ var CliShCommandProcessor = class {
3749
4678
  await executeScript(content, context);
3750
4679
  }
3751
4680
  writeDescription(context) {
3752
- const { writer } = context;
3753
- writer.writeln(this.description);
4681
+ const { writer, translator: t } = context;
4682
+ writer.writeln(t.t("cli.sh.description", this.description));
3754
4683
  writer.writeln();
3755
- writer.writeln("Usage:");
4684
+ writer.writeln(t.t("cli.common.usage", "Usage:"));
3756
4685
  writer.writeln(
3757
4686
  ` ${writer.wrapInColor("sh <script.sh>", cliCore.CliForegroundColor.Cyan)}`
3758
4687
  );
@@ -3763,83 +4692,57 @@ var CliShCommandProcessor = class {
3763
4692
  ` ${writer.wrapInColor(". <script.sh>", cliCore.CliForegroundColor.Cyan)}`
3764
4693
  );
3765
4694
  writer.writeln(
3766
- ` ${writer.wrapInColor("./script.sh", cliCore.CliForegroundColor.Cyan)} (requires execute permission)`
4695
+ ` ${writer.wrapInColor("./script.sh", cliCore.CliForegroundColor.Cyan)} ${t.t("cli.sh.help.requires_execute", "(requires execute permission)")}`
3767
4696
  );
3768
4697
  writer.writeln();
3769
- writer.writeln("Script syntax:");
3770
- writer.writeln(" # Lines starting with # are comments");
3771
- writer.writeln(" VAR=value # Variable assignment");
3772
- writer.writeln(" echo $VAR # Variable substitution");
3773
- writer.writeln(" set -e # Stop on error (default)");
3774
- writer.writeln(" set +e # Continue on errors");
4698
+ writer.writeln(t.t("cli.sh.help.script_syntax", "Script syntax:"));
4699
+ writer.writeln(` # ${t.t("cli.sh.help.comments", "Lines starting with # are comments")}`);
4700
+ writer.writeln(` cmd1; cmd2 # ${t.t("cli.sh.help.several_commands", "Several commands on one line")}`);
4701
+ writer.writeln(` VAR=value # ${t.t("cli.sh.help.assignment", "Variable assignment")}`);
4702
+ writer.writeln(` echo $VAR # ${t.t("cli.sh.help.substitution", "Variable substitution")}`);
4703
+ writer.writeln(` set -e # ${t.t("cli.sh.help.stop_on_error", "Stop on error (default)")}`);
4704
+ writer.writeln(` set +e # ${t.t("cli.sh.help.continue_on_errors", "Continue on errors")}`);
4705
+ writer.writeln(` exit [n] # ${t.t("cli.sh.help.exit", "Stop the script with status n")}`);
4706
+ writer.writeln();
4707
+ writer.writeln(t.t("cli.sh.help.control_flow", "Control flow:"));
4708
+ writer.writeln(" if cmd; then ...; elif cmd; then ...; else ...; fi");
4709
+ writer.writeln(" for NAME in a b *.txt; do ...; done");
4710
+ writer.writeln(` while cmd; do ...; done ${t.t("cli.sh.help.until", "(until cmd: the opposite)")}`);
4711
+ writer.writeln(` break / continue [n] # ${t.t("cli.sh.help.break_continue", "Leave / skip in a loop")}`);
4712
+ writer.writeln(` ! cmd # ${t.t("cli.sh.help.negate", "Negate the exit status")}`);
4713
+ writer.writeln(
4714
+ ` ${t.t("cli.sh.help.conditions", "Conditions are commands:")} ${writer.wrapInColor("[ -f file ]", cliCore.CliForegroundColor.Cyan)}, ${writer.wrapInColor('test "$A" = b', cliCore.CliForegroundColor.Cyan)}, ${writer.wrapInColor("true", cliCore.CliForegroundColor.Cyan)}, ${writer.wrapInColor("grep x file", cliCore.CliForegroundColor.Cyan)}`
4715
+ );
4716
+ writer.writeln(` ${t.t("cli.sh.help.conditions_status", "(status 0 = true; see help test). Loops stop after 10000 iterations")}`);
4717
+ writer.writeln(` ${t.t("cli.sh.help.conditions_loops", "of while/until; Ctrl+C stops the script.")}`);
3775
4718
  writer.writeln();
3776
- writer.writeln("Example script:");
4719
+ writer.writeln(t.t("cli.sh.help.example_script", "Example script:"));
3777
4720
  writer.writeln(
3778
4721
  writer.wrapInColor(
3779
- " #!/bin/sh\n # Setup script\n NAME=world\n echo Hello $NAME\n mkdir -p /tmp/test\n echo done > /tmp/test/status.txt",
4722
+ " #!/bin/sh\n # Setup script\n NAME=world\n echo Hello $NAME\n mkdir -p /tmp/test\n for f in a b c; do\n echo $f > /tmp/test/$f.txt\n done\n if [ -f /tmp/test/a.txt ]; then\n echo done > /tmp/test/status.txt\n fi",
3780
4723
  cliCore.CliForegroundColor.Green
3781
4724
  )
3782
4725
  );
3783
4726
  }
3784
4727
  };
4728
+ var SCRIPT_RUNNER_TOKEN = "cli-script-runner";
3785
4729
  async function executeScript(content, context) {
3786
- const lines = content.split("\n");
3787
- const variables = {};
3788
- let stopOnError = true;
3789
- for (const rawLine of lines) {
3790
- const line = rawLine.trim();
3791
- if (!line || line.startsWith("#")) {
3792
- continue;
3793
- }
3794
- if (line === "set -e") {
3795
- stopOnError = true;
3796
- continue;
3797
- }
3798
- if (line === "set +e") {
3799
- stopOnError = false;
3800
- continue;
3801
- }
3802
- const assignMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
3803
- if (assignMatch) {
3804
- const varName = assignMatch[1];
3805
- let varValue = assignMatch[2];
3806
- if (varValue.startsWith('"') && varValue.endsWith('"') || varValue.startsWith("'") && varValue.endsWith("'")) {
3807
- varValue = varValue.slice(1, -1);
3808
- }
3809
- varValue = substituteVariables(varValue, variables);
3810
- variables[varName] = varValue;
3811
- continue;
3812
- }
3813
- const expanded = substituteVariables(line, variables);
3814
- await context.executor.executeCommand(expanded, context);
3815
- const exitCode = context.process.exitCode;
3816
- if (stopOnError && exitCode !== void 0 && exitCode !== 0) {
3817
- context.writer.writeError(
3818
- `sh: script aborted at line: ${line}`
3819
- );
3820
- return;
3821
- }
4730
+ const runner = context.services.get(SCRIPT_RUNNER_TOKEN);
4731
+ if (!runner) {
4732
+ context.writer.writeError("sh: script runner not available (update @qodalis/cli)");
4733
+ context.process.exit(1, { silent: true });
4734
+ return;
3822
4735
  }
4736
+ await runner.run(content, context, { name: "sh" });
3823
4737
  }
3824
- function substituteVariables(input, variables) {
3825
- let result = input.replace(
3826
- /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
3827
- (_, name) => variables[name] ?? ""
3828
- );
3829
- result = result.replace(
3830
- /\$([A-Za-z_][A-Za-z0-9_]*)/g,
3831
- (_, name) => variables[name] ?? ""
3832
- );
3833
- return result;
3834
- }
3835
-
3836
- // src/lib/services/indexed-db-file-system.service.ts
3837
4738
  var DB_NAME = "qodalis-cli-filesystem";
3838
4739
  var STORE_NAME = "filesystem";
3839
4740
  var ROOT_KEY = "root";
3840
4741
  var CWD_KEY = "cwd";
3841
4742
  var HOME_KEY = "home";
3842
4743
  var DEFAULT_HOME = "/";
4744
+ var SYSTEM_OWNERSHIP = { uid: "root", gid: "admin" };
4745
+ var ANONYMOUS_OWNERSHIP = { uid: "nobody", gid: "nogroup" };
3843
4746
  function createSeedFileSystem() {
3844
4747
  const now = Date.now();
3845
4748
  return {
@@ -3887,6 +4790,88 @@ var IndexedDbFileSystemService = class {
3887
4790
  this.homePath = DEFAULT_HOME;
3888
4791
  this.currentUid = null;
3889
4792
  this.currentGroups = [];
4793
+ this.systemDepth = 0;
4794
+ }
4795
+ // --- Access control ---
4796
+ setAccessPolicy(policy) {
4797
+ this.accessPolicy = policy;
4798
+ }
4799
+ runAsSystem(fn) {
4800
+ this.systemDepth++;
4801
+ try {
4802
+ return fn();
4803
+ } finally {
4804
+ this.systemDepth--;
4805
+ }
4806
+ }
4807
+ reassignOwner(fromUid, toUid) {
4808
+ const from = fromUid.toLowerCase();
4809
+ if (!from || !toUid) return 0;
4810
+ let changed = 0;
4811
+ const visit = (node) => {
4812
+ if (node.ownership && node.ownership.uid.toLowerCase() === from) {
4813
+ node.ownership = { ...node.ownership, uid: toUid };
4814
+ changed++;
4815
+ }
4816
+ node.children?.forEach(visit);
4817
+ };
4818
+ visit(this.root);
4819
+ return changed;
4820
+ }
4821
+ canTraverse(path) {
4822
+ try {
4823
+ this.assertTraverse(this.resolvePath(path), "stat", path);
4824
+ return true;
4825
+ } catch {
4826
+ return false;
4827
+ }
4828
+ }
4829
+ get enforcing() {
4830
+ return !!this.accessPolicy && this.systemDepth === 0;
4831
+ }
4832
+ deny(op, path, reason = "Permission denied") {
4833
+ throw new cliCore.CliAccessDeniedError(`${op}: ${path}: ${reason}`);
4834
+ }
4835
+ /** `x` on every existing directory on the way to `resolved` (excluding the target). */
4836
+ assertTraverse(resolved, op, display) {
4837
+ if (!this.enforcing) return;
4838
+ const parts = resolved.split("/").filter(Boolean);
4839
+ let current = this.root;
4840
+ for (let i = 0; i < parts.length; i++) {
4841
+ if (!current || current.type !== "directory") return;
4842
+ if (!this.accessPolicy.canAccess(current, "execute")) this.deny(op, display);
4843
+ current = current.children?.find((c) => c.name === parts[i]);
4844
+ }
4845
+ }
4846
+ assertAccess(node, action, op, display) {
4847
+ if (this.enforcing && !this.accessPolicy.canAccess(node, action)) this.deny(op, display);
4848
+ }
4849
+ /** Recursive delete: every directory inside must allow removing its entries (`w` + `x`, readable). */
4850
+ assertRemovableTree(dir, dirPath, display) {
4851
+ if (!this.enforcing) return;
4852
+ this.assertAccess(dir, "read", "rm", display);
4853
+ this.assertAccess(dir, "execute", "rm", display);
4854
+ if ((dir.children?.length ?? 0) > 0) this.assertAccess(dir, "write", "rm", display);
4855
+ for (const child of dir.children ?? []) {
4856
+ if (child.type === "directory") this.assertRemovableTree(child, `${dirPath}/${child.name}`, display);
4857
+ }
4858
+ }
4859
+ /** Copying reads everything it copies: `r` on every file, `r` + `x` on every directory. */
4860
+ assertReadableTree(node, display) {
4861
+ if (!this.enforcing) return;
4862
+ this.assertAccess(node, "read", "cp", display);
4863
+ if (node.type !== "directory") return;
4864
+ this.assertAccess(node, "execute", "cp", display);
4865
+ for (const child of node.children ?? []) {
4866
+ this.assertReadableTree(child, `${display.replace(/\/+$/, "")}/${child.name}`);
4867
+ }
4868
+ }
4869
+ /** Creating/removing entries needs `w` and `x` on the containing directory. */
4870
+ assertModifyDir(dir, dirPath, op, display) {
4871
+ if (!this.enforcing) return;
4872
+ this.assertTraverse(dirPath, op, display);
4873
+ this.assertAccess(dir, "execute", op, display);
4874
+ this.assertAccess(dir, "write", op, display);
3890
4875
  }
3891
4876
  // --- User context ---
3892
4877
  setCurrentUser(uid, groups) {
@@ -3894,7 +4879,9 @@ var IndexedDbFileSystemService = class {
3894
4879
  this.currentGroups = groups;
3895
4880
  }
3896
4881
  getDefaultOwnership() {
3897
- if (!this.currentUid) return void 0;
4882
+ if (!this.currentUid) {
4883
+ return this.accessPolicy ? { ...ANONYMOUS_OWNERSHIP } : void 0;
4884
+ }
3898
4885
  return {
3899
4886
  uid: this.currentUid,
3900
4887
  gid: this.currentGroups[0] || "users"
@@ -3913,6 +4900,8 @@ var IndexedDbFileSystemService = class {
3913
4900
  if (node.type !== "directory") {
3914
4901
  throw new Error(`cd: ${path}: Not a directory`);
3915
4902
  }
4903
+ this.assertTraverse(resolved, "cd", path);
4904
+ this.assertAccess(node, "execute", "cd", path);
3916
4905
  this.cwd = resolved;
3917
4906
  }
3918
4907
  getHomePath() {
@@ -3969,6 +4958,8 @@ var IndexedDbFileSystemService = class {
3969
4958
  if (node.type !== "directory") {
3970
4959
  throw new Error(`ls: ${path}: Not a directory`);
3971
4960
  }
4961
+ this.assertTraverse(this.resolvePath(path), "ls", path);
4962
+ this.assertAccess(node, "read", "ls", path);
3972
4963
  return node.children || [];
3973
4964
  }
3974
4965
  readFile(path) {
@@ -3979,6 +4970,8 @@ var IndexedDbFileSystemService = class {
3979
4970
  if (node.type === "directory") {
3980
4971
  throw new Error(`cat: ${path}: Is a directory`);
3981
4972
  }
4973
+ this.assertTraverse(this.resolvePath(path), "cat", path);
4974
+ this.assertAccess(node, "read", "cat", path);
3982
4975
  return node.content ?? "";
3983
4976
  }
3984
4977
  exists(path) {
@@ -4003,6 +4996,10 @@ var IndexedDbFileSystemService = class {
4003
4996
  }
4004
4997
  let child = current.children.find((c) => c.name === part);
4005
4998
  if (!child) {
4999
+ if (this.enforcing) {
5000
+ this.assertAccess(current, "execute", "mkdir", path);
5001
+ this.assertAccess(current, "write", "mkdir", path);
5002
+ }
4006
5003
  const now = Date.now();
4007
5004
  child = {
4008
5005
  name: part,
@@ -4018,6 +5015,8 @@ var IndexedDbFileSystemService = class {
4018
5015
  current.modifiedAt = now;
4019
5016
  } else if (child.type !== "directory") {
4020
5017
  throw new Error(`mkdir: ${part}: Not a directory`);
5018
+ } else {
5019
+ this.assertAccess(child, "execute", "mkdir", path);
4021
5020
  }
4022
5021
  current = child;
4023
5022
  }
@@ -4036,6 +5035,7 @@ var IndexedDbFileSystemService = class {
4036
5035
  if (parent.children?.find((c) => c.name === dirName)) {
4037
5036
  throw new Error(`mkdir: ${path}: File exists`);
4038
5037
  }
5038
+ this.assertModifyDir(parent, parentPath, "mkdir", path);
4039
5039
  const now = Date.now();
4040
5040
  if (!parent.children) {
4041
5041
  parent.children = [];
@@ -4070,6 +5070,12 @@ var IndexedDbFileSystemService = class {
4070
5070
  }
4071
5071
  const existing = parent.children?.find((c) => c.name === fileName);
4072
5072
  const now = Date.now();
5073
+ if (existing) {
5074
+ this.assertTraverse(resolved, "touch", path);
5075
+ this.assertAccess(existing, "write", "touch", path);
5076
+ } else {
5077
+ this.assertModifyDir(parent, parentPath, "touch", path);
5078
+ }
4073
5079
  if (existing) {
4074
5080
  existing.modifiedAt = now;
4075
5081
  } else {
@@ -4096,6 +5102,8 @@ var IndexedDbFileSystemService = class {
4096
5102
  if (node.type === "directory") {
4097
5103
  throw new Error(`write: ${path}: Is a directory`);
4098
5104
  }
5105
+ this.assertTraverse(resolved, "write", path);
5106
+ this.assertAccess(node, "write", "write", path);
4099
5107
  const now = Date.now();
4100
5108
  node.content = append ? (node.content ?? "") + content : content;
4101
5109
  node.size = new Blob([node.content]).size;
@@ -4124,6 +5132,8 @@ var IndexedDbFileSystemService = class {
4124
5132
  if (target.type === "directory" && !recursive) {
4125
5133
  throw new Error(`rm: ${path}: Is a directory`);
4126
5134
  }
5135
+ this.assertModifyDir(parent, parentPath, "rm", path);
5136
+ if (target.type === "directory") this.assertRemovableTree(target, resolved, path);
4127
5137
  parent.children.splice(index, 1);
4128
5138
  parent.modifiedAt = Date.now();
4129
5139
  }
@@ -4138,10 +5148,13 @@ var IndexedDbFileSystemService = class {
4138
5148
  `cp: -r not specified; omitting directory '${src}'`
4139
5149
  );
4140
5150
  }
5151
+ this.assertTraverse(srcResolved, "cp", src);
5152
+ this.assertReadableTree(srcNode, src);
4141
5153
  const destResolved = this.resolvePath(dest);
4142
5154
  const destNode = this.getNode(destResolved);
4143
5155
  if (destNode && destNode.type === "directory") {
4144
- const clone = this.cloneNode(srcNode);
5156
+ this.assertModifyDir(destNode, destResolved, "cp", dest);
5157
+ const clone = this.ownedByCurrentUser(this.cloneNode(srcNode));
4145
5158
  if (!destNode.children) {
4146
5159
  destNode.children = [];
4147
5160
  }
@@ -4161,7 +5174,8 @@ var IndexedDbFileSystemService = class {
4161
5174
  if (!parent || parent.type !== "directory") {
4162
5175
  throw new Error(`cp: ${parentPath}: No such file or directory`);
4163
5176
  }
4164
- const clone = this.cloneNode(srcNode);
5177
+ this.assertModifyDir(parent, parentPath, "cp", dest);
5178
+ const clone = this.ownedByCurrentUser(this.cloneNode(srcNode));
4165
5179
  clone.name = newName;
4166
5180
  if (!parent.children) {
4167
5181
  parent.children = [];
@@ -4187,16 +5201,23 @@ var IndexedDbFileSystemService = class {
4187
5201
  }
4188
5202
  const destResolved = this.resolvePath(dest);
4189
5203
  const destNode = this.getNode(destResolved);
5204
+ if (srcNode.type === "directory" && (destResolved === srcResolved || destResolved.startsWith(srcResolved + "/"))) {
5205
+ throw new Error(
5206
+ `mv: cannot move '${src}' to a subdirectory of itself, '${dest}'`
5207
+ );
5208
+ }
4190
5209
  const srcParts = srcResolved.split("/").filter(Boolean);
4191
5210
  const srcParentPath = "/" + srcParts.slice(0, -1).join("/");
4192
5211
  const srcParent = this.getNode(srcParentPath);
4193
5212
  if (!srcParent || !srcParent.children) {
4194
5213
  throw new Error(`mv: ${src}: No such file or directory`);
4195
5214
  }
4196
- const srcIndex = srcParent.children.findIndex(
4197
- (c) => c.name === srcNode.name
4198
- );
5215
+ this.assertModifyDir(srcParent, srcParentPath, "mv", src);
5216
+ if (destResolved === srcResolved || destNode && destNode.type === "directory" && destNode === srcParent) {
5217
+ throw new Error(`mv: '${src}' and '${dest}' are the same file`);
5218
+ }
4199
5219
  if (destNode && destNode.type === "directory") {
5220
+ this.assertModifyDir(destNode, destResolved, "mv", dest);
4200
5221
  if (!destNode.children) {
4201
5222
  destNode.children = [];
4202
5223
  }
@@ -4206,7 +5227,7 @@ var IndexedDbFileSystemService = class {
4206
5227
  if (existingIdx !== -1) {
4207
5228
  destNode.children.splice(existingIdx, 1);
4208
5229
  }
4209
- srcParent.children.splice(srcIndex, 1);
5230
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4210
5231
  destNode.children.push(srcNode);
4211
5232
  destNode.modifiedAt = Date.now();
4212
5233
  } else {
@@ -4219,7 +5240,8 @@ var IndexedDbFileSystemService = class {
4219
5240
  `mv: ${destParentPath}: No such file or directory`
4220
5241
  );
4221
5242
  }
4222
- srcParent.children.splice(srcIndex, 1);
5243
+ this.assertModifyDir(destParent, destParentPath, "mv", dest);
5244
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4223
5245
  srcNode.name = newName;
4224
5246
  if (!destParent.children) {
4225
5247
  destParent.children = [];
@@ -4242,6 +5264,9 @@ var IndexedDbFileSystemService = class {
4242
5264
  if (!node) {
4243
5265
  throw new Error(`chmod: ${path}: No such file or directory`);
4244
5266
  }
5267
+ if (this.enforcing && !this.accessPolicy.canChangeOwnership(node)) {
5268
+ this.deny("chmod", path, "Operation not permitted");
5269
+ }
4245
5270
  node.permissions = permissions;
4246
5271
  }
4247
5272
  chown(path, ownership) {
@@ -4250,6 +5275,9 @@ var IndexedDbFileSystemService = class {
4250
5275
  if (!node) {
4251
5276
  throw new Error(`chown: ${path}: No such file or directory`);
4252
5277
  }
5278
+ if (this.enforcing && !this.accessPolicy.canChangeOwnership(node, ownership)) {
5279
+ this.deny("chown", path, "Operation not permitted");
5280
+ }
4253
5281
  node.ownership = ownership;
4254
5282
  }
4255
5283
  // --- Persistence ---
@@ -4280,8 +5308,10 @@ var IndexedDbFileSystemService = class {
4280
5308
  const tx = db.transaction(STORE_NAME, "readwrite");
4281
5309
  const store = tx.objectStore(STORE_NAME);
4282
5310
  store.put(this.root, ROOT_KEY);
4283
- store.put(this.cwd, CWD_KEY);
4284
- store.put(this.homePath, HOME_KEY);
5311
+ if (!this.currentUid) {
5312
+ store.put(this.cwd, CWD_KEY);
5313
+ store.put(this.homePath, HOME_KEY);
5314
+ }
4285
5315
  await new Promise((resolve, reject) => {
4286
5316
  tx.oncomplete = () => resolve();
4287
5317
  tx.onerror = () => reject(tx.error);
@@ -4291,6 +5321,18 @@ var IndexedDbFileSystemService = class {
4291
5321
  }
4292
5322
  }
4293
5323
  // --- Private helpers ---
5324
+ /** A copy belongs to whoever made it (like `cp` without `-p`) */
5325
+ ownedByCurrentUser(node) {
5326
+ const owner = this.getDefaultOwnership();
5327
+ if (owner) {
5328
+ const apply = (n) => {
5329
+ n.ownership = { ...owner };
5330
+ n.children?.forEach(apply);
5331
+ };
5332
+ apply(node);
5333
+ }
5334
+ return node;
5335
+ }
4294
5336
  cloneNode(node) {
4295
5337
  const now = Date.now();
4296
5338
  const clone = {
@@ -4332,8 +5374,6 @@ var IndexedDbFileSystemService = class {
4332
5374
  });
4333
5375
  }
4334
5376
  };
4335
-
4336
- // src/lib/services/server-file-system.service.ts
4337
5377
  var DEFAULT_HOME2 = "/home/user";
4338
5378
  function createSeedFileSystem2() {
4339
5379
  const now = Date.now();
@@ -4383,6 +5423,7 @@ var ServerFileSystemService = class {
4383
5423
  this.currentUid = null;
4384
5424
  this.currentGroups = [];
4385
5425
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
5426
+ this.headersProvider = options.headers;
4386
5427
  }
4387
5428
  // --- User context ---
4388
5429
  setCurrentUser(uid, groups) {
@@ -4699,15 +5740,20 @@ var ServerFileSystemService = class {
4699
5740
  }
4700
5741
  const destResolved = this.resolvePath(dest);
4701
5742
  const destNode = this.getNode(destResolved);
5743
+ if (srcNode.type === "directory" && (destResolved === srcResolved || destResolved.startsWith(srcResolved + "/"))) {
5744
+ throw new Error(
5745
+ `mv: cannot move '${src}' to a subdirectory of itself, '${dest}'`
5746
+ );
5747
+ }
4702
5748
  const srcParts = srcResolved.split("/").filter(Boolean);
4703
5749
  const srcParentPath = "/" + srcParts.slice(0, -1).join("/");
4704
5750
  const srcParent = this.getNode(srcParentPath);
4705
5751
  if (!srcParent || !srcParent.children) {
4706
5752
  throw new Error(`mv: ${src}: No such file or directory`);
4707
5753
  }
4708
- const srcIndex = srcParent.children.findIndex(
4709
- (c) => c.name === srcNode.name
4710
- );
5754
+ if (destResolved === srcResolved || destNode && destNode.type === "directory" && destNode === srcParent) {
5755
+ throw new Error(`mv: '${src}' and '${dest}' are the same file`);
5756
+ }
4711
5757
  if (destNode && destNode.type === "directory") {
4712
5758
  if (!destNode.children) {
4713
5759
  destNode.children = [];
@@ -4718,7 +5764,7 @@ var ServerFileSystemService = class {
4718
5764
  if (existingIdx !== -1) {
4719
5765
  destNode.children.splice(existingIdx, 1);
4720
5766
  }
4721
- srcParent.children.splice(srcIndex, 1);
5767
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4722
5768
  destNode.children.push(srcNode);
4723
5769
  destNode.modifiedAt = Date.now();
4724
5770
  } else {
@@ -4731,7 +5777,7 @@ var ServerFileSystemService = class {
4731
5777
  `mv: ${destParentPath}: No such file or directory`
4732
5778
  );
4733
5779
  }
4734
- srcParent.children.splice(srcIndex, 1);
5780
+ srcParent.children.splice(srcParent.children.indexOf(srcNode), 1);
4735
5781
  srcNode.name = newName;
4736
5782
  if (!destParent.children) {
4737
5783
  destParent.children = [];
@@ -4790,7 +5836,14 @@ var ServerFileSystemService = class {
4790
5836
  // --- Private: server communication ---
4791
5837
  async serverFetch(endpoint, options) {
4792
5838
  const url = `${this.baseUrl}/api/qcli/fs${endpoint}`;
4793
- const response = await fetch(url, options);
5839
+ const mergedHeaders = {
5840
+ ...cliCore.resolveHeaders(this.headersProvider),
5841
+ ...options?.headers ?? {}
5842
+ };
5843
+ const response = await fetch(url, {
5844
+ ...options,
5845
+ headers: mergedHeaders
5846
+ });
4794
5847
  if (!response.ok) {
4795
5848
  const body = await response.text();
4796
5849
  throw new Error(
@@ -4959,6 +6012,115 @@ var VirtualFsFileTransferService = class {
4959
6012
  return this._browserService.uploadFromBrowser(accept);
4960
6013
  }
4961
6014
  };
6015
+ var CliFilesPermissions = {
6016
+ /** Bypass all file permission checks */
6017
+ Admin: "files:admin",
6018
+ /** chmod/chown files you do not own */
6019
+ Chown: "files:chown"
6020
+ };
6021
+ var HOME_DIR_PERMISSIONS = "rwxr-x---";
6022
+ function authorization(context) {
6023
+ try {
6024
+ return context.services.get(cliCore.ICliAuthorizationService_TOKEN);
6025
+ } catch {
6026
+ return void 0;
6027
+ }
6028
+ }
6029
+ function createFileAccessPolicy(context) {
6030
+ return {
6031
+ // Nodes without an owner (system seed, legacy data) belong to root:admin
6032
+ canAccess: (node, action) => cliCore.cliCanAccess(
6033
+ context,
6034
+ node.ownership ? node : { ...node, ownership: SYSTEM_OWNERSHIP },
6035
+ action,
6036
+ [CliFilesPermissions.Admin]
6037
+ ),
6038
+ canChangeOwnership: (node, newOwnership) => {
6039
+ const auth = authorization(context);
6040
+ if (!auth) return true;
6041
+ if (auth.can(CliFilesPermissions.Admin) || auth.can(CliFilesPermissions.Chown)) return true;
6042
+ const principal = auth.getPrincipal();
6043
+ if (!principal) return false;
6044
+ const owns = !!node.ownership && (node.ownership.uid === principal.name || node.ownership.uid === principal.id);
6045
+ const keepsOwner = !newOwnership || newOwnership.uid === node.ownership?.uid || newOwnership.uid === principal.name;
6046
+ return owns && keepsOwner;
6047
+ }
6048
+ };
6049
+ }
6050
+ function followUserSession(context, fs) {
6051
+ let sessions;
6052
+ try {
6053
+ sessions = context.services.get(cliCore.ICliUserSessionService_TOKEN);
6054
+ } catch {
6055
+ sessions = void 0;
6056
+ }
6057
+ if (!sessions) return void 0;
6058
+ let previousUser = null;
6059
+ return sessions.getUserSession().subscribe((session) => {
6060
+ if (!session?.user) {
6061
+ fs.setCurrentUser("", []);
6062
+ if (previousUser !== null || !canEnter(fs, fs.getCurrentDirectory())) {
6063
+ try {
6064
+ fs.setCurrentDirectory("/");
6065
+ void fs.persist();
6066
+ } catch {
6067
+ }
6068
+ }
6069
+ previousUser = null;
6070
+ return;
6071
+ }
6072
+ const user = session.user;
6073
+ fs.setCurrentUser(user.name, user.groups ?? []);
6074
+ if (user.homeDir) {
6075
+ const home = user.homeDir;
6076
+ const system = (fn) => fs.runAsSystem ? fs.runAsSystem(fn) : fn();
6077
+ if (!fs.exists(home)) {
6078
+ system(() => {
6079
+ fs.createDirectory(home, true);
6080
+ if (home !== "/") {
6081
+ fs.chown(home, { uid: user.name, gid: user.groups?.[0] ?? "users" });
6082
+ fs.chmod(home, HOME_DIR_PERMISSIONS);
6083
+ }
6084
+ });
6085
+ void fs.persist();
6086
+ }
6087
+ fs.setHomePath(home);
6088
+ if (previousUser !== user.id) {
6089
+ try {
6090
+ fs.setCurrentDirectory(home);
6091
+ } catch {
6092
+ }
6093
+ }
6094
+ }
6095
+ previousUser = user.id;
6096
+ });
6097
+ }
6098
+ function canEnter(fs, path) {
6099
+ try {
6100
+ fs.setCurrentDirectory(path);
6101
+ return true;
6102
+ } catch {
6103
+ return false;
6104
+ }
6105
+ }
6106
+ function followUserAccounts(context, fs) {
6107
+ let events;
6108
+ try {
6109
+ events = context.services.get(cliCore.ICliUserEventService_TOKEN);
6110
+ } catch {
6111
+ events = void 0;
6112
+ }
6113
+ if (!events || !fs.reassignOwner) return void 0;
6114
+ return events.events$.subscribe((event) => {
6115
+ let changed = 0;
6116
+ if (event.type === "user_updated" && event.previousName && event.previousName !== event.user?.name) {
6117
+ changed = fs.reassignOwner(event.previousName, event.user.name);
6118
+ } else if (event.type === "user_deleted" && event.user?.name) {
6119
+ changed = fs.reassignOwner(event.user.name, SYSTEM_OWNERSHIP.uid);
6120
+ }
6121
+ if (changed > 0) void fs.persist().catch(() => void 0);
6122
+ });
6123
+ }
4962
6124
 
4963
6125
  // src/lib/completion/file-path-completion-provider.ts
4964
6126
  var FILE_COMMANDS = /* @__PURE__ */ new Set([
@@ -4979,7 +6141,26 @@ var FILE_COMMANDS = /* @__PURE__ */ new Set([
4979
6141
  "find",
4980
6142
  "head",
4981
6143
  "tail",
4982
- "wc"
6144
+ "wc",
6145
+ "sort",
6146
+ "uniq",
6147
+ "cut",
6148
+ "paste",
6149
+ "tac",
6150
+ "diff",
6151
+ "sed",
6152
+ "awk",
6153
+ "du",
6154
+ "stat",
6155
+ "chmod",
6156
+ "chown",
6157
+ "ln",
6158
+ "tee",
6159
+ "basename",
6160
+ "dirname",
6161
+ "sh",
6162
+ "source",
6163
+ "xargs"
4983
6164
  ]);
4984
6165
  var FilePathCompletionProvider = class {
4985
6166
  constructor(fs) {
@@ -4998,7 +6179,14 @@ var FilePathCompletionProvider = class {
4998
6179
  if (token.startsWith("-")) {
4999
6180
  return [];
5000
6181
  }
5001
- return this.completePath(token);
6182
+ const quote = token.startsWith('"') || token.startsWith("'") ? token[0] : "";
6183
+ const partial = quote ? token.slice(1).replace(new RegExp(`${quote}$`), "") : token;
6184
+ const paths = this.completePath(partial);
6185
+ if (!quote && !paths.some((p) => /\s/.test(p))) {
6186
+ return paths;
6187
+ }
6188
+ const q = quote || '"';
6189
+ return paths.map((p) => p.endsWith("/") ? `${q}${p}` : `${q}${p}${q}`);
5002
6190
  }
5003
6191
  completePath(partial) {
5004
6192
  let dirPath;
@@ -5059,10 +6247,10 @@ var CliUploadCommandProcessor = class {
5059
6247
  ];
5060
6248
  }
5061
6249
  async processCommand(command, context) {
5062
- const fs = context.services.get(
6250
+ const fs = context.services.getRequired(
5063
6251
  IFileSystemService_TOKEN
5064
6252
  );
5065
- const fileService = context.services.get(
6253
+ const fileService = context.services.getRequired(
5066
6254
  cliCore.ICliFileTransferService_TOKEN
5067
6255
  );
5068
6256
  if (!fileService) {
@@ -5072,6 +6260,7 @@ var CliUploadCommandProcessor = class {
5072
6260
  const accept = command.args?.["accept"];
5073
6261
  const destPath = command.value?.trim() || void 0;
5074
6262
  context.spinner?.show("Waiting for file selection...");
6263
+ context.notifier.info("Waiting for file selection");
5075
6264
  const picked = await fileService.uploadFromBrowser(accept);
5076
6265
  if (!picked) {
5077
6266
  context.spinner?.hide();
@@ -5084,6 +6273,7 @@ var CliUploadCommandProcessor = class {
5084
6273
  filename.startsWith("/") ? filename : cwd + "/" + filename
5085
6274
  );
5086
6275
  context.spinner?.show(`Saving "${picked.name}" (${picked.content.length} bytes)...`);
6276
+ context.notifier.info(`Saving ${picked.name}`);
5087
6277
  try {
5088
6278
  fs.writeFile(resolved, picked.content);
5089
6279
  await fs.persist();
@@ -5151,6 +6341,10 @@ var filesModule = {
5151
6341
  new CliShCommandProcessor(),
5152
6342
  new CliUploadCommandProcessor()
5153
6343
  ],
6344
+ permissions: [
6345
+ { name: CliFilesPermissions.Admin, description: "Bypass file permission checks" },
6346
+ { name: CliFilesPermissions.Chown, description: "chmod/chown files owned by others" }
6347
+ ],
5154
6348
  services: [
5155
6349
  {
5156
6350
  provide: IFileSystemService_TOKEN,
@@ -5182,7 +6376,7 @@ var filesModule = {
5182
6376
  return { ...this, config };
5183
6377
  },
5184
6378
  async onInit(context) {
5185
- const fs = context.services.get(
6379
+ const fs = context.services.getRequired(
5186
6380
  IFileSystemService_TOKEN
5187
6381
  );
5188
6382
  await fs.initialize();
@@ -5193,7 +6387,7 @@ var filesModule = {
5193
6387
  }
5194
6388
  let dragDrop;
5195
6389
  try {
5196
- dragDrop = context.services.get(cliCore.ICliDragDropService_TOKEN);
6390
+ dragDrop = context.services.getRequired(cliCore.ICliDragDropService_TOKEN);
5197
6391
  } catch {
5198
6392
  }
5199
6393
  if (dragDrop) {
@@ -5222,13 +6416,26 @@ var filesModule = {
5222
6416
  });
5223
6417
  }
5224
6418
  },
6419
+ async onAfterBoot(context) {
6420
+ const fs = context.services.getRequired(IFileSystemService_TOKEN);
6421
+ fs.setAccessPolicy?.(createFileAccessPolicy(context));
6422
+ this._sessionSubscription?.unsubscribe();
6423
+ this._sessionSubscription = followUserSession(context, fs);
6424
+ this._accountsSubscription?.unsubscribe();
6425
+ this._accountsSubscription = followUserAccounts(context, fs);
6426
+ },
5225
6427
  async onDestroy(_context) {
5226
6428
  const sub = this._dragDropSubscription;
5227
6429
  sub?.unsubscribe();
5228
6430
  this._dragDropSubscription = void 0;
6431
+ this._sessionSubscription?.unsubscribe();
6432
+ this._sessionSubscription = void 0;
6433
+ this._accountsSubscription?.unsubscribe();
6434
+ this._accountsSubscription = void 0;
5229
6435
  }
5230
6436
  };
5231
6437
 
6438
+ exports.ANONYMOUS_OWNERSHIP = ANONYMOUS_OWNERSHIP;
5232
6439
  exports.CliAwkCommandProcessor = CliAwkCommandProcessor;
5233
6440
  exports.CliBasenameCommandProcessor = CliBasenameCommandProcessor;
5234
6441
  exports.CliCatCommandProcessor = CliCatCommandProcessor;
@@ -5241,6 +6448,7 @@ exports.CliDiffCommandProcessor = CliDiffCommandProcessor;
5241
6448
  exports.CliDirnameCommandProcessor = CliDirnameCommandProcessor;
5242
6449
  exports.CliDuCommandProcessor = CliDuCommandProcessor;
5243
6450
  exports.CliEchoCommandProcessor = CliEchoCommandProcessor;
6451
+ exports.CliFilesPermissions = CliFilesPermissions;
5244
6452
  exports.CliFindCommandProcessor = CliFindCommandProcessor;
5245
6453
  exports.CliGrepCommandProcessor = CliGrepCommandProcessor;
5246
6454
  exports.CliHeadCommandProcessor = CliHeadCommandProcessor;
@@ -5266,11 +6474,16 @@ exports.CliUniqCommandProcessor = CliUniqCommandProcessor;
5266
6474
  exports.CliWcCommandProcessor = CliWcCommandProcessor;
5267
6475
  exports.CliXargsCommandProcessor = CliXargsCommandProcessor;
5268
6476
  exports.FilePathCompletionProvider = FilePathCompletionProvider;
6477
+ exports.HOME_DIR_PERMISSIONS = HOME_DIR_PERMISSIONS;
5269
6478
  exports.IFileSystemService_TOKEN = IFileSystemService_TOKEN;
5270
6479
  exports.IndexedDbFileSystemService = IndexedDbFileSystemService;
6480
+ exports.SYSTEM_OWNERSHIP = SYSTEM_OWNERSHIP;
5271
6481
  exports.ServerFileSystemService = ServerFileSystemService;
5272
6482
  exports.VirtualFsFileTransferService = VirtualFsFileTransferService;
6483
+ exports.createFileAccessPolicy = createFileAccessPolicy;
5273
6484
  exports.executeScript = executeScript;
5274
6485
  exports.filesModule = filesModule;
6486
+ exports.followUserAccounts = followUserAccounts;
6487
+ exports.followUserSession = followUserSession;
5275
6488
  //# sourceMappingURL=public-api.js.map
5276
6489
  //# sourceMappingURL=public-api.js.map