@nimbus-sh/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +47 -26
  2. package/dist/_shared/byte-stream.d.ts +5 -4
  3. package/dist/_shared/byte-stream.d.ts.map +1 -1
  4. package/dist/_shared/real-node-imports.d.ts +7 -0
  5. package/dist/_shared/real-node-imports.d.ts.map +1 -1
  6. package/dist/_shared/real-node-imports.js +8 -0
  7. package/dist/_shared/tarball.d.ts +23 -5
  8. package/dist/_shared/tarball.d.ts.map +1 -1
  9. package/dist/_shared/tarball.js +38 -5
  10. package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
  11. package/dist/_shared/vfs-write-ledger.js +22 -16
  12. package/dist/runtime/comment-strip.d.ts +0 -35
  13. package/dist/runtime/comment-strip.d.ts.map +1 -1
  14. package/dist/runtime/comment-strip.js +48 -20
  15. package/dist/runtime/esbuild-service.d.ts +38 -42
  16. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  17. package/dist/runtime/esbuild-service.js +494 -197
  18. package/dist/runtime/javascript-ast.d.ts.map +1 -1
  19. package/dist/runtime/javascript-ast.js +45 -7
  20. package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
  21. package/dist/runtime/opentui-wasm-backend.js +18 -16
  22. package/dist/runtime/port-registry.d.ts +4 -2
  23. package/dist/runtime/port-registry.d.ts.map +1 -1
  24. package/dist/runtime/port-registry.js +60 -8
  25. package/dist/shell/stdin-adapter.d.ts +11 -0
  26. package/dist/shell/stdin-adapter.d.ts.map +1 -0
  27. package/dist/shell/stdin-adapter.js +59 -0
  28. package/dist/shell/unix-commands.d.ts +2 -1
  29. package/dist/shell/unix-commands.d.ts.map +1 -1
  30. package/dist/shell/unix-commands.js +1078 -50
  31. package/dist/substrate/lifo/commands/io/dd.js +1 -1
  32. package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
  33. package/dist/substrate/lifo/commands/net/curl.js +270 -54
  34. package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
  35. package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
  36. package/dist/substrate/lifo/commands/system/npm.js +26 -43
  37. package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
  38. package/dist/substrate/lifo/commands/text/head.js +2 -1
  39. package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
  40. package/dist/substrate/lifo/commands/text/sed.js +499 -117
  41. package/dist/substrate/lifo/commands/types.d.ts +8 -4
  42. package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
  43. package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
  44. package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
  45. package/dist/substrate/lifo/node-compat/zlib.js +122 -23
  46. package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
  47. package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
  48. package/dist/substrate/lifo/shell/interpreter.js +79 -29
  49. package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
  50. package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
  51. package/dist/substrate/lifo/shell/pipe.js +62 -18
  52. package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
  53. package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
  54. package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
  55. package/dist/vfs/sqlite-vfs.d.ts +19 -0
  56. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  57. package/dist/vfs/sqlite-vfs.js +106 -23
  58. package/dist/workspace/nimbus-workspace.d.ts +15 -0
  59. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  60. package/dist/workspace/nimbus-workspace.js +20 -3
  61. package/dist/workspace/supervisor-op.d.ts +23 -0
  62. package/dist/workspace/supervisor-op.d.ts.map +1 -0
  63. package/dist/workspace/supervisor-op.js +92 -0
  64. package/package.json +2 -2
  65. package/src/_shared/byte-stream.ts +5 -4
  66. package/src/_shared/real-node-imports.ts +8 -0
  67. package/src/_shared/tarball.ts +56 -5
  68. package/src/_shared/vfs-write-ledger.ts +22 -16
  69. package/src/runtime/comment-strip.ts +42 -23
  70. package/src/runtime/esbuild-service.ts +566 -217
  71. package/src/runtime/javascript-ast.ts +40 -9
  72. package/src/runtime/opentui-wasm-backend.ts +19 -17
  73. package/src/runtime/port-registry.ts +66 -8
  74. package/src/shell/stdin-adapter.ts +58 -0
  75. package/src/shell/unix-commands.ts +1054 -50
  76. package/src/substrate/lifo/commands/io/dd.ts +2 -2
  77. package/src/substrate/lifo/commands/net/curl.ts +310 -58
  78. package/src/substrate/lifo/commands/system/npm.ts +58 -38
  79. package/src/substrate/lifo/commands/text/head.ts +2 -1
  80. package/src/substrate/lifo/commands/text/sed.ts +519 -109
  81. package/src/substrate/lifo/commands/types.ts +8 -5
  82. package/src/substrate/lifo/node-compat/zlib.ts +155 -26
  83. package/src/substrate/lifo/shell/interpreter.ts +74 -27
  84. package/src/substrate/lifo/shell/pipe.ts +65 -24
  85. package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
  86. package/src/vfs/sqlite-vfs.ts +104 -23
  87. package/src/workspace/nimbus-workspace.ts +26 -2
  88. package/src/workspace/supervisor-op.ts +117 -0
@@ -8,7 +8,8 @@
8
8
  * uptime, tree, find, grep -r, head, tail, wc, diff, sort, uniq,
9
9
  * sed (s///), awk (field extract), xargs, tee, chown, ln -s,
10
10
  * du, man/help, basename, dirname, printf, true, false, seq, sleep,
11
- * touch, stat, file, xxd, base64, sha256sum, id, hostname, realpath
11
+ * touch, stat, file, xxd, od, hexdump, base64, sha256sum, id, hostname,
12
+ * realpath
12
13
  */
13
14
 
14
15
  import type { CredentialedVfs, SqliteVFS } from '../vfs/sqlite-vfs.js';
@@ -27,6 +28,7 @@ import {
27
28
  import type { Command, CommandInputStream } from '../substrate/lifo/commands/types.js';
28
29
  import { runSed } from '../substrate/lifo/commands/text/sed.js';
29
30
  import { parseArgs } from '../substrate/lifo/utils/args.js';
31
+ import { encode } from '../substrate/lifo/utils/encoding.js';
30
32
  import {
31
33
  findUnixGroupName,
32
34
  findUnixUserName,
@@ -37,11 +39,10 @@ import { createSuCommand, createSudoCommand, createUmaskCommand } from './elevat
37
39
  /**
38
40
  * stdin as the shell hands it over: a pipe reader, whose `readAll` resolves
39
41
  * once upstream closes, or the terminal's own stream, which stays open past
40
- * the command and so is taken from `buffer` instead of awaited.
42
+ * the command and is drained in place via `drainBuffered`.
41
43
  */
42
44
  type ShellStdin = CommandInputStream & {
43
45
  feed?(text: string): void;
44
- buffer?: string[];
45
46
  };
46
47
 
47
48
  /**
@@ -322,6 +323,9 @@ const _CANONICAL_BIN_PATHS: Record<string, string> = {
322
323
  bundler: '/usr/bin/bundler',
323
324
  sh: '/usr/bin/sh',
324
325
  bash: '/usr/bin/bash',
326
+ // Hex dumps — the real tools live under /usr/bin on Unix
327
+ od: '/usr/bin/od',
328
+ hexdump: '/usr/bin/hexdump',
325
329
  // Framework CLIs
326
330
  astro: '/usr/local/bin/astro',
327
331
  nuxt: '/usr/local/bin/nuxt',
@@ -1595,15 +1599,27 @@ async function streamStdinBytes(ctx: Ctx, writer: SinkWriter, limit: number): Pr
1595
1599
  writer.write(enc.encode(stdin).subarray(0, limit));
1596
1600
  return;
1597
1601
  }
1598
- const reader = stdin as { read?: () => Promise<string | null>; readBytes?: (n: number) => Promise<string | null> };
1602
+ const reader = stdin as {
1603
+ read?: () => Promise<string | null>;
1604
+ readBytes?: (n: number) => Promise<Uint8Array | null>;
1605
+ };
1599
1606
  if (typeof reader?.read !== 'function') return;
1600
1607
 
1601
1608
  let copied = 0;
1602
1609
  while (copied < limit) {
1603
1610
  const want = limit - copied;
1604
- const chunk = reader.readBytes ? await reader.readBytes(want) : await reader.read();
1611
+ let chunk: Uint8Array | null;
1612
+ if (reader.readBytes) {
1613
+ chunk = await reader.readBytes(want);
1614
+ } else {
1615
+ // Text-only readers report EOF with null; encoding that null into an
1616
+ // empty chunk would spin the loop forever without advancing.
1617
+ const text = await reader.read();
1618
+ if (text === null) break;
1619
+ chunk = enc.encode(text);
1620
+ }
1605
1621
  if (chunk === null) break;
1606
- const bytes = enc.encode(chunk).subarray(0, want);
1622
+ const bytes = chunk.subarray(0, want);
1607
1623
  writer.write(bytes);
1608
1624
  copied += bytes.length;
1609
1625
  }
@@ -4191,51 +4207,1034 @@ function mkFile(vfs: UnixVfs): CmdFn {
4191
4207
  };
4192
4208
  }
4193
4209
 
4194
- function mkXxd(vfs: UnixVfs): CmdFn {
4195
- return (ctx) => {
4196
- const file = ctx.args.find(a => !a.startsWith('-'));
4197
- if (!file) { ctx.stderr.write('Usage: xxd FILE\n'); return 1; }
4198
- const fp = resolvePath(ctx.cwd, file);
4199
- try {
4200
- const data = vfs.readFile(fp);
4201
- const len = Math.min(data.length, ctx.args.includes('-l') ? parseInt(ctx.args[ctx.args.indexOf('-l') + 1]) || 256 : 256);
4202
- for (let i = 0; i < len; i += 16) {
4203
- const hex = Array.from(data.slice(i, i + 16)).map(b => b.toString(16).padStart(2, '0')).join(' ');
4204
- const ascii = Array.from(data.slice(i, i + 16)).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join('');
4205
- ctx.stdout.write(`${i.toString(16).padStart(8, '0')}: ${hex.padEnd(48)} ${ascii}\n`);
4210
+ // ── Hex dumps: od, hexdump, xxd ─────────────────────────────────────────
4211
+
4212
+ /**
4213
+ * Dump-tool byte counts: decimal, `0x` hex, leading-zero octal, and the
4214
+ * classic suffixes (`b` blocks of 512, K/KiB, KB, M, G — powers of 1024
4215
+ * except the round-decimal `KB`/`MB`/`GB` spellings). Null means the value
4216
+ * is not a count these tools accept.
4217
+ */
4218
+ function parseDumpCount(value: string): number | null {
4219
+ const match = /^(0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)([bB]|[kKmMgGtT](?:i?[bB])?)?$/.exec(value);
4220
+ if (match === null) return null;
4221
+ const digits = match[1];
4222
+ const base = digits.startsWith('0x') || digits.startsWith('0X')
4223
+ ? Number.parseInt(digits, 16)
4224
+ : /^0/.test(digits) ? Number.parseInt(digits, 8) : Number.parseInt(digits, 10);
4225
+ if (!Number.isSafeInteger(base) || base < 0) return null;
4226
+ if (match[2] === undefined || match[2] === '') return base;
4227
+ const scale: Record<string, number> = {
4228
+ b: 512, B: 512,
4229
+ k: 1024, K: 1024, KiB: 1024, kB: 1000, KB: 1000,
4230
+ m: 1024 ** 2, M: 1024 ** 2, MiB: 1024 ** 2, mB: 1000 ** 2, MB: 1000 ** 2,
4231
+ g: 1024 ** 3, G: 1024 ** 3, GiB: 1024 ** 3, gB: 1000 ** 3, GB: 1000 ** 3,
4232
+ t: 1024 ** 4, T: 1024 ** 4, TiB: 1024 ** 4, tB: 1000 ** 4, TB: 1000 ** 4,
4233
+ };
4234
+ const factor = scale[match[2]];
4235
+ if (factor === undefined) return null;
4236
+ const total = base * factor;
4237
+ return Number.isSafeInteger(total) ? total : null;
4238
+ }
4239
+
4240
+ /** Little-endian word; a short final chunk reads its missing bytes as zero. */
4241
+ function leWord(chunk: Uint8Array): number {
4242
+ return (chunk[0] ?? 0) | ((chunk[1] ?? 0) << 8);
4243
+ }
4244
+
4245
+ /**
4246
+ * Shared row suppression: a formatted row equal to the one before it prints
4247
+ * as `*`, and runs of repeats collapse into that single marker. GNU od and
4248
+ * util-linux hexdump suppress the address together with the row, so callers
4249
+ * classify the body alone and print the marker bare.
4250
+ */
4251
+ class RowDedup {
4252
+ private previous: string | null = null;
4253
+ private starred = false;
4254
+
4255
+ classify(row: string, verbose: boolean): 'print' | 'star' | 'skip' {
4256
+ if (verbose || row !== this.previous) {
4257
+ this.previous = row;
4258
+ this.starred = false;
4259
+ return 'print';
4260
+ }
4261
+ if (this.starred) return 'skip';
4262
+ this.starred = true;
4263
+ return 'star';
4264
+ }
4265
+ }
4266
+
4267
+ // ── od ──────────────────────────────────────────────────────────────────
4268
+
4269
+ type OdRadix = 'o' | 'd' | 'x' | 'n';
4270
+
4271
+ /**
4272
+ * Row addresses follow the radix (`0000000` octal or decimal, `000000`
4273
+ * lowercase hex). The closing total-length line keeps uppercase hex digits —
4274
+ * `00001B`, not `00001b` — matching od on this host byte for byte.
4275
+ */
4276
+ function odAddress(radix: OdRadix, offset: number, final: boolean): string {
4277
+ if (radix === 'n') return '';
4278
+ if (radix === 'o') return offset.toString(8).padStart(7, '0');
4279
+ if (radix === 'd') return String(offset).padStart(7, '0');
4280
+ const digits = offset.toString(16);
4281
+ return (final ? digits.toUpperCase() : digits).padStart(6, '0');
4282
+ }
4283
+
4284
+ /** `\0`-style escapes for `-tc`; other non-printables go out as `\NNN`. */
4285
+ const OD_CHAR_ESCAPES: Readonly<Record<number, string>> = {
4286
+ 0: '\\0', 7: '\\a', 8: '\\b', 9: '\\t', 10: '\\n', 11: '\\v', 12: '\\f', 13: '\\r',
4287
+ };
4288
+
4289
+ interface OdTypeSpec {
4290
+ width: number;
4291
+ /** Rendered width of one item, used to align multiple -t types. */
4292
+ natural: number;
4293
+ render: (chunk: Uint8Array) => string;
4294
+ }
4295
+
4296
+ const OD_TYPES: Readonly<Record<string, OdTypeSpec>> = {
4297
+ c: {
4298
+ width: 1,
4299
+ natural: 3,
4300
+ render: (c) => {
4301
+ const escaped = OD_CHAR_ESCAPES[c[0]];
4302
+ if (escaped !== undefined) return escaped.padStart(3);
4303
+ if (c[0] >= 32 && c[0] < 127) return String.fromCharCode(c[0]).padStart(3);
4304
+ return c[0].toString(8).padStart(3, '0');
4305
+ },
4306
+ },
4307
+ d1: { width: 1, natural: 4, render: (c) => String((c[0] << 24) >> 24).padStart(4) },
4308
+ d2: { width: 2, natural: 6, render: (c) => { const v = leWord(c); return String(v >= 0x8000 ? v - 0x10000 : v).padStart(6); } },
4309
+ u1: { width: 1, natural: 3, render: (c) => String(c[0]).padStart(3) },
4310
+ u2: { width: 2, natural: 5, render: (c) => String(leWord(c)).padStart(5) },
4311
+ o1: { width: 1, natural: 3, render: (c) => c[0].toString(8).padStart(3, '0') },
4312
+ o2: { width: 2, natural: 6, render: (c) => leWord(c).toString(8).padStart(6, '0') },
4313
+ x1: { width: 1, natural: 2, render: (c) => c[0].toString(16).padStart(2, '0') },
4314
+ x2: { width: 2, natural: 4, render: (c) => leWord(c).toString(16).padStart(4, '0') },
4315
+ };
4316
+
4317
+ type OdArgs =
4318
+ | { error: string }
4319
+ | { radix: OdRadix; types: string[]; limit: number | undefined; verbose: boolean; files: string[] };
4320
+
4321
+ /**
4322
+ * Split a `-t` value into its concatenated specifications: a letter with an
4323
+ * optional size (`x1`, `d2`, `c`, …), so `-tx1c` yields x1 then c.
4324
+ */
4325
+ function splitOdTypes(value: string): string[] {
4326
+ const specs: string[] = [];
4327
+ const pattern = /([xoducXODUC])([01248]?)/g;
4328
+ let consumed = '';
4329
+ for (const match of value.matchAll(pattern)) {
4330
+ consumed += match[0];
4331
+ const letter = match[1].toLowerCase();
4332
+ const size = match[2] === '' ? '' : match[2];
4333
+ if (letter === 'a') {
4334
+ specs.push('c');
4335
+ continue;
4336
+ }
4337
+ specs.push(`${letter}${size}`);
4338
+ }
4339
+ return consumed === value ? specs : [value];
4340
+ }
4341
+
4342
+ /** `-A[o|d|x|n]`, cumulative `-t<spec>`, `-N<count>`, `-v`. */
4343
+ function parseOdArgs(args: string[]): OdArgs {
4344
+ let radix: OdRadix = 'o';
4345
+ const types: string[] = [];
4346
+ let limit: number | undefined;
4347
+ let verbose = false;
4348
+ const files: string[] = [];
4349
+ for (let i = 0; i < args.length; i++) {
4350
+ const arg = args[i];
4351
+ if (arg === '--') { files.push(...args.slice(i + 1)); break; }
4352
+ if (!arg.startsWith('-') || arg === '-') { files.push(arg); continue; }
4353
+ const flag = arg[1];
4354
+ let value = arg.slice(2);
4355
+ if (flag === 'v' && value === '') { verbose = true; continue; }
4356
+ if (flag !== 'A' && flag !== 't' && flag !== 'N') {
4357
+ return { error: `od: invalid option -- '${flag}'` };
4358
+ }
4359
+ if (value === '') { value = args[i + 1]; if (value !== undefined) i++; }
4360
+ if (value === undefined || value === '') {
4361
+ return { error: `od: option requires an argument -- '${flag}'` };
4362
+ }
4363
+ if (flag === 'A') {
4364
+ // Exactly one radix letter per option (`-Aod` is not two glued flags);
4365
+ // repeated valid options are last-wins, like GNU od.
4366
+ if (value.length !== 1 || !'odxn'.includes(value)) {
4367
+ return { error: `od: Radix must be one of [o, d, x, n], got: ${value}` };
4206
4368
  }
4207
- return 0;
4208
- } catch { ctx.stderr.write(`xxd: ${file}: No such file\n`); return 1; }
4369
+ radix = value as OdRadix;
4370
+ } else if (flag === 't') {
4371
+ // One -t may carry concatenated specifications (`-tx1c`); repeated
4372
+ // -t flags accumulate too. Both render in request order.
4373
+ for (const spec of splitOdTypes(value)) {
4374
+ if (!(spec in OD_TYPES)) return { error: `od: unsupported type specification '${value}'` };
4375
+ types.push(spec);
4376
+ }
4377
+ } else {
4378
+ const parsed = parseDumpCount(value);
4379
+ if (parsed === null || parsed < 0) {
4380
+ return { error: `od: invalid number of bytes '${value}'` };
4381
+ }
4382
+ limit = parsed;
4383
+ }
4384
+ }
4385
+ if (types.length === 0) types.push('o2');
4386
+ return { radix, types, limit, verbose, files };
4387
+ }
4388
+
4389
+ /**
4390
+ * Sequential bytes for a dump tool: each operand in order, stdin once,
4391
+ * bounded range reads for files and bounded pulls for pipes. Only one
4392
+ * window of bytes is held at a time, so unbounded tools stream forever
4393
+ * instead of growing silently, and limits stop collection early.
4394
+ */
4395
+ class DumpByteSource {
4396
+ private operands: (string | undefined)[];
4397
+ private operandIndex = 0;
4398
+ private remaining: number;
4399
+ private currentPath = '';
4400
+ private cursor = 0;
4401
+ private haveOpen = false;
4402
+ private stdinMode = false;
4403
+ private stdinUsed = false;
4404
+ private probedFirst = false;
4405
+ // Drained-string stdin is encoded once; pulls slice the encoded bytes so
4406
+ // multibyte input never advances past bytes it did not return.
4407
+ private stdinBytes: Uint8Array | null = null;
4408
+ private stdinCursor = 0;
4409
+ private stdinOverflow: Uint8Array[] = [];
4410
+ failures = 0;
4411
+ opened = 0;
4412
+ total = 0;
4413
+
4414
+ constructor(
4415
+ private ctx: Ctx,
4416
+ private label: string,
4417
+ files: string[],
4418
+ limit: number | undefined,
4419
+ ) {
4420
+ this.operands = files.length > 0 ? files : [undefined];
4421
+ this.remaining = limit ?? Number.POSITIVE_INFINITY;
4422
+ }
4423
+
4424
+ get failed(): boolean {
4425
+ return this.failures > 0;
4426
+ }
4427
+
4428
+ /** Every named operand failed to open — distinct from successful empty. */
4429
+ get failedAll(): boolean {
4430
+ return this.operands.length > 0 && this.opened === 0 && this.failures > 0;
4431
+ }
4432
+
4433
+ private async openNextOperand(): Promise<boolean> {
4434
+ while (this.operandIndex < this.operands.length) {
4435
+ const file = this.operands[this.operandIndex++];
4436
+ try {
4437
+ if (file === undefined || file === '-') {
4438
+ if (this.stdinUsed) continue; // second '-' reads stdin already at EOF
4439
+ this.stdinMode = true;
4440
+ this.opened++; // an empty stdin still counts as successfully opened
4441
+ return true;
4442
+ }
4443
+ // Probe the file now so per-operand errors surface exactly once.
4444
+ // Named operands read through ctx.vfs — the mount-aware seam the
4445
+ // host handed the command — so /dev and other mounts resolve while
4446
+ // an embedder's credentialed view keeps its authorization.
4447
+ this.currentPath = absolutePath(this.ctx.cwd, file);
4448
+ this.ctx.vfs.readRange(this.currentPath, 0, 1);
4449
+ this.cursor = 0;
4450
+ this.haveOpen = true;
4451
+ this.opened++;
4452
+ return true;
4453
+ } catch (error) {
4454
+ this.ctx.stderr.write(`${this.label}: ${file}: ${fsErrorMessage(error)}\n`);
4455
+ this.failures++;
4456
+ }
4457
+ }
4458
+ return false;
4459
+ }
4460
+
4461
+ private closeCurrent(): void {
4462
+ this.haveOpen = false;
4463
+ this.stdinMode = false;
4464
+ this.currentPath = '';
4465
+ this.cursor = 0;
4466
+ }
4467
+
4468
+ /** One bounded pull; drained strings are encoded once and sliced by byte. */
4469
+ private async stdinPull(max: number): Promise<Uint8Array | null> {
4470
+ if (typeof this.ctx.stdin === 'string') {
4471
+ if (this.stdinBytes === null) this.stdinBytes = enc.encode(this.ctx.stdin);
4472
+ if (this.stdinCursor >= this.stdinBytes.length) return null;
4473
+ const end = Math.min(this.stdinCursor + max, this.stdinBytes.length);
4474
+ const chunk = this.stdinBytes.subarray(this.stdinCursor, end);
4475
+ this.stdinCursor = end;
4476
+ return chunk;
4477
+ }
4478
+ const reader = this.ctx.stdin as {
4479
+ read?: () => Promise<string | null>;
4480
+ readBytes?: (n: number) => Promise<Uint8Array | null>;
4481
+ };
4482
+ if (typeof reader?.read !== 'function') return null;
4483
+ // read/readAll-only embedders lose nothing: overflow bytes from a bounded
4484
+ // pull wait in stdinOverflow until the next one. A live stream hands back
4485
+ // its first available chunk — waiting to fill `max` would stall a sparse
4486
+ // producer that has written one byte and not the rest.
4487
+ let chunk: Uint8Array | null;
4488
+ if (this.stdinOverflow.length > 0) {
4489
+ chunk = this.stdinOverflow.shift() ?? null;
4490
+ } else if (reader.readBytes) {
4491
+ chunk = await reader.readBytes(Math.min(65536, max));
4492
+ } else {
4493
+ const text = await reader.read();
4494
+ chunk = text === null ? null : enc.encode(text);
4495
+ }
4496
+ if (chunk === null || chunk.length === 0) return null;
4497
+ if (chunk.length <= max) return chunk;
4498
+ this.stdinOverflow.unshift(chunk.subarray(max));
4499
+ return chunk.subarray(0, max);
4500
+ }
4501
+
4502
+ /**
4503
+ * Up to `max` bytes, filling across chunks and operands. A row-oriented
4504
+ * caller wants the whole row before it formats anything, exactly as GNU od
4505
+ * fills its 16-byte buffer, so this waits for the count it asked for.
4506
+ */
4507
+ async take(max: number): Promise<Uint8Array | null> {
4508
+ return this.collect(max, false);
4509
+ }
4510
+
4511
+ /**
4512
+ * Up to `max` bytes, waiting only for the first ones to arrive. A caller
4513
+ * that formats whatever has landed uses this: file operands still answer
4514
+ * in bulk, while a live stream is never waited on for bytes a sparse
4515
+ * producer has not written yet.
4516
+ */
4517
+ async takeReady(max: number): Promise<Uint8Array | null> {
4518
+ return this.collect(max, true);
4519
+ }
4520
+
4521
+ private async collect(max: number, ready: boolean): Promise<Uint8Array | null> {
4522
+ if (!this.probedFirst) {
4523
+ // The first named operand must be attempted even under a zero limit,
4524
+ // so `-l0 /missing` reports the open error instead of succeeding.
4525
+ this.probedFirst = true;
4526
+ if (this.operands.length > 0 && !(await this.openNextOperand())) {
4527
+ return null;
4528
+ }
4529
+ }
4530
+ if (max <= 0) return new Uint8Array(0);
4531
+ const parts: Uint8Array[] = [];
4532
+ let got = 0;
4533
+ while (got < max) {
4534
+ if (this.remaining <= 0) break;
4535
+ if (!this.haveOpen && !this.stdinMode) {
4536
+ if (!(await this.openNextOperand())) break;
4537
+ }
4538
+ const want = Math.min(max - got, this.remaining, 65536);
4539
+ let chunk: Uint8Array | null;
4540
+ if (this.stdinMode) {
4541
+ chunk = await this.stdinPull(want);
4542
+ if (chunk === null || chunk.length === 0) {
4543
+ this.stdinUsed = true;
4544
+ this.closeCurrent();
4545
+ continue;
4546
+ }
4547
+ // A live stream hands back what it has: `takeReady` stops here so a
4548
+ // sparse producer keeps rendering, while `take` loops for the rest of
4549
+ // the row it was asked for.
4550
+ parts.push(chunk);
4551
+ got += chunk.length;
4552
+ this.total += chunk.length;
4553
+ this.remaining -= chunk.length;
4554
+ if (ready) break;
4555
+ continue;
4556
+ }
4557
+ // File operands keep filling: range reads are bulk and cost nothing
4558
+ // extra, and a block may span consecutive operands.
4559
+ chunk = this.ctx.vfs.readRange(this.currentPath, this.cursor, want);
4560
+ if (chunk.length === 0) {
4561
+ this.closeCurrent();
4562
+ continue;
4563
+ }
4564
+ this.cursor += chunk.length;
4565
+ const take = chunk.length <= want ? chunk : chunk.subarray(0, want);
4566
+ parts.push(take);
4567
+ got += take.length;
4568
+ this.total += take.length;
4569
+ this.remaining -= take.length;
4570
+ }
4571
+ if (parts.length === 0) return null;
4572
+ if (parts.length === 1) return parts[0];
4573
+ const out = new Uint8Array(got);
4574
+ let at = 0;
4575
+ for (const part of parts) {
4576
+ out.set(part, at);
4577
+ at += part.length;
4578
+ }
4579
+ return out;
4580
+ }
4581
+ }
4582
+
4583
+ function mkOd(): CmdFn {
4584
+ return async (ctx) => {
4585
+ const parsed = parseOdArgs(ctx.args);
4586
+ if ('error' in parsed) {
4587
+ ctx.stderr.write(`${parsed.error}\n`);
4588
+ return 1;
4589
+ }
4590
+ const src = new DumpByteSource(ctx, 'od', parsed.files, parsed.limit);
4591
+ const specs = parsed.types.map((name) => OD_TYPES[name]);
4592
+ // With several -t types every item shares one column width (the widest
4593
+ // natural width plus one); each type prints on its own continuation
4594
+ // line indented under the address — uutils od's grid on this host.
4595
+ const columnWidth = Math.max(...specs.map((spec) => spec.natural)) + 1;
4596
+ const renderRow = (row: Uint8Array): string[] => specs.map((spec) => {
4597
+ const items: string[] = [];
4598
+ for (let i = 0; i < row.length; i += spec.width) {
4599
+ items.push(spec.render(row.subarray(i, Math.min(i + spec.width, row.length))));
4600
+ }
4601
+ if (specs.length === 1) return items.join(' ');
4602
+ return items.map((item) => item.padStart(columnWidth)).join('');
4603
+ });
4604
+ const dedup = new RowDedup();
4605
+ while (true) {
4606
+ const row = await src.take(16);
4607
+ if (row === null || row.length === 0) break;
4608
+ const address = odAddress(parsed.radix, src.total - row.length, false);
4609
+ const lines = renderRow(row);
4610
+ const key = lines.join('\n');
4611
+ const indent = address === '' ? '' : ' '.repeat(7);
4612
+ const rendered = specs.length === 1
4613
+ ? `${address} ${lines[0]}`
4614
+ : [`${address}${lines[0]}`, ...lines.slice(1).map((line) => `${indent}${line}`)].join('\n');
4615
+ switch (dedup.classify(key, parsed.verbose)) {
4616
+ case 'print':
4617
+ ctx.stdout.write(`${rendered}\n`);
4618
+ break;
4619
+ case 'star':
4620
+ ctx.stdout.write('*\n');
4621
+ break;
4622
+ }
4623
+ }
4624
+ if (parsed.radix !== 'n') {
4625
+ if (src.failedAll) return 1;
4626
+ ctx.stdout.write(`${odAddress(parsed.radix, src.total, true)}\n`);
4627
+ }
4628
+ return src.failed ? 1 : 0;
4209
4629
  };
4210
4630
  }
4211
4631
 
4212
- // ── Registration ────────────────────────────────────────────────────────
4632
+ // ── hexdump ─────────────────────────────────────────────────────────────
4213
4633
 
4634
+ interface HexdumpDirective {
4635
+ kind: 'byte' | 'addr';
4636
+ conv?: string;
4637
+ radix?: 'd' | 'o' | 'x';
4638
+ leftAlign: boolean;
4639
+ zeroPad: boolean;
4640
+ width: number | undefined;
4641
+ precision: number | undefined;
4642
+ }
4643
+
4644
+ /** One quoted piece of an `-e` format: escaped text plus parsed directives. */
4645
+ interface HexdumpPiece {
4646
+ segments: (string | HexdumpDirective)[];
4647
+ count: number;
4648
+ size: number;
4649
+ consumes: boolean;
4650
+ }
4651
+
4652
+ const HEXDUMP_WORD_SIZES: Readonly<Record<string, number>> = { '1': 1, '2': 2, '4': 4, C: 1 };
4653
+ /** Field widths an empty iteration pads to, mirroring util-linux. */
4654
+ const HEXDUMP_DIGIT_WIDTHS: Readonly<Record<string, { x: number; o: number; d: number }>> = {
4655
+ '1': { x: 2, o: 3, d: 3 },
4656
+ '2': { x: 4, o: 6, d: 5 },
4657
+ '4': { x: 8, o: 11, d: 10 },
4658
+ };
4659
+
4660
+ /** Shared empty unit: a conversion past end-of-input sees no bytes at all. */
4661
+ const HEXDUMP_EMPTY_UNIT = new Uint8Array(0);
4662
+ /** Source bytes fetched per pull while streaming `-e` blocks. */
4663
+ const HEXDUMP_PULL_BYTES = 4096;
4664
+ /** Digits an address directive can render: `Number.MAX_SAFE_INTEGER` in octal. */
4665
+ const HEXDUMP_MAX_ADDRESS_DIGITS = 20;
4214
4666
  /**
4215
- * Wrap a sync/async command so it always returns Promise<number>.
4216
- * The LIFO shell calls .then() on the return value of every command,
4217
- * so raw numbers cause "E3(...).then is not a function".
4218
- *
4219
- * Also resolves ctx.stdin from a stream object to a string.
4220
- * The shell passes stdin as an object with .readAll() when piping,
4221
- * but our commands expect a plain string.
4667
+ * Characters one `-e` block may render. A block is held twice while it is
4668
+ * classified the line and its dedup key so this ceiling bounds two
4669
+ * strings of ~2 MiB UTF-16 each, small beside a Durable Object's memory and
4670
+ * far above any format that dumps real data.
4222
4671
  */
4672
+ const HEXDUMP_MAX_BLOCK_CHARS = 1 << 20;
4673
+
4674
+ // util-linux rejects escaped delimiters inside -e units rather than
4675
+ // decoding them, so `\"` is deliberately absent here.
4676
+ const HEXDUMP_ESCAPES: Readonly<Record<string, string>> = {
4677
+ n: '\n', t: '\t', r: '\r', '\\': '\\', '0': '\0',
4678
+ };
4679
+
4680
+ function parseHexdumpDirectives(fmt: string): { segments: (string | HexdumpDirective)[] } | { error: string } {
4681
+ const segments: (string | HexdumpDirective)[] = [];
4682
+ let text = '';
4683
+ const flush = () => { if (text !== '') { segments.push(text); text = ''; } };
4684
+ const bad = (what: string) => ({ error: `hexdump: bad format {${what}}` });
4685
+
4686
+ for (let i = 0; i < fmt.length; i++) {
4687
+ const ch = fmt[i];
4688
+ if (ch === '\\') {
4689
+ const esc = fmt[++i];
4690
+ if (esc === undefined || !(esc in HEXDUMP_ESCAPES)) return bad(`\\${esc ?? ''}`);
4691
+ text += HEXDUMP_ESCAPES[esc];
4692
+ continue;
4693
+ }
4694
+ if (ch !== '%') { text += ch; continue; }
4695
+ flush();
4696
+ const directive: HexdumpDirective = { kind: 'byte', leftAlign: false, zeroPad: false, width: undefined, precision: undefined };
4697
+ let j = i + 1;
4698
+ while (fmt[j] === '-' || fmt[j] === '0') {
4699
+ if (fmt[j] === '-') directive.leftAlign = true; else directive.zeroPad = true;
4700
+ j++;
4701
+ }
4702
+ let digits = '';
4703
+ while (fmt[j] >= '0' && fmt[j] <= '9') digits += fmt[j++];
4704
+ if (digits !== '') {
4705
+ directive.width = Number(digits);
4706
+ // A width outside the safe-integer range can never render; reject the
4707
+ // format here rather than attempting an unbounded allocation later.
4708
+ if (!Number.isSafeInteger(directive.width)) return bad(`%${fmt.slice(i + 1, j + 1)}`);
4709
+ }
4710
+ if (fmt[j] === '.') {
4711
+ let prec = '';
4712
+ j++;
4713
+ while (fmt[j] >= '0' && fmt[j] <= '9') prec += fmt[j++];
4714
+ directive.precision = prec === '' ? 0 : Number(prec);
4715
+ if (!Number.isSafeInteger(directive.precision)) return bad(`%${fmt.slice(i + 1, j + 1)}`);
4716
+ }
4717
+ if (fmt[j] === '_' && fmt[j + 1] === 'a' && 'dxo'.includes(fmt[j + 2])) {
4718
+ directive.kind = 'addr';
4719
+ directive.radix = fmt[j + 2] as 'd' | 'o' | 'x';
4720
+ i = j + 2;
4721
+ } else if (j < fmt.length && 'xXduoc'.includes(fmt[j])) {
4722
+ directive.conv = fmt[j];
4723
+ i = j;
4724
+ } else {
4725
+ return bad(`%${fmt.slice(i + 1, j + 1)}`);
4726
+ }
4727
+ segments.push(directive);
4728
+ }
4729
+ flush();
4730
+ return { segments };
4731
+ }
4732
+
4733
+ function parseHexdumpPieces(value: string): { pieces: HexdumpPiece[] } | { error: string } {
4734
+ const pieces: HexdumpPiece[] = [];
4735
+ let pending: { count: number; size: number } | null = null;
4736
+ let i = 0;
4737
+ while (i < value.length) {
4738
+ const ch = value[i];
4739
+ if (ch === ' ' || ch === '\t') { i++; continue; }
4740
+ if (ch >= '0' && ch <= '9') {
4741
+ let digits = '';
4742
+ while (i < value.length && value[i] >= '0' && value[i] <= '9') digits += value[i++];
4743
+ if (value[i] !== '/') return { error: `hexdump: bad format {${value}}` };
4744
+ const sizeChar = value[i + 1] ?? '';
4745
+ const size = HEXDUMP_WORD_SIZES[sizeChar];
4746
+ if (size === undefined) return { error: `hexdump: bad format {${digits}/${sizeChar}}` };
4747
+ const count = Number(digits);
4748
+ if (!Number.isSafeInteger(count) || count <= 0) {
4749
+ return { error: `hexdump: bad format {${digits}/${sizeChar}}` };
4750
+ }
4751
+ pending = { count, size };
4752
+ i += 2;
4753
+ continue;
4754
+ }
4755
+ if (ch !== '"' && ch !== "'") return { error: `hexdump: bad format {${value.slice(i)}}` };
4756
+ // util-linux refuses an escaped quote inside a unit instead of decoding
4757
+ // it, and names the whole specification when it does.
4758
+ let close = -1;
4759
+ for (let k = i + 1; k < value.length; k++) {
4760
+ const c = value[k];
4761
+ if (c === '\\') {
4762
+ if (value[k + 1] === ch) { close = -2; break; }
4763
+ k++;
4764
+ continue;
4765
+ }
4766
+ if (c === ch) { close = k; break; }
4767
+ }
4768
+ if (close < 0) return { error: `hexdump: bad format {${value}}` };
4769
+ const parsed = parseHexdumpDirectives(value.slice(i + 1, close));
4770
+ if ('error' in parsed) return parsed;
4771
+ const conversions = parsed.segments.filter(
4772
+ (segment) => typeof segment !== 'string' && segment.kind === 'byte',
4773
+ ).length;
4774
+ // util-linux refuses a byte count feeding more than one conversion.
4775
+ if (conversions > 1) {
4776
+ return { error: 'hexdump: byte count with multiple conversion characters' };
4777
+ }
4778
+ // %c defaults to one byte and only accepts one; other conversions
4779
+ // default to four.
4780
+ const soleConv = conversions === 1
4781
+ ? (parsed.segments.find(
4782
+ (segment) => typeof segment !== 'string' && segment.kind === 'byte',
4783
+ ) as HexdumpDirective).conv
4784
+ : undefined;
4785
+ if (soleConv === 'c' && pending !== null && pending.size !== 1) {
4786
+ return { error: 'hexdump: bad byte count for conversion character c' };
4787
+ }
4788
+ pieces.push({
4789
+ segments: parsed.segments,
4790
+ count: pending?.count ?? 1,
4791
+ size: pending?.size ?? (soleConv === 'c' ? 1 : 4),
4792
+ consumes: conversions > 0,
4793
+ });
4794
+ pending = null;
4795
+ i = close + 1;
4796
+ }
4797
+ if (pending !== null) return { error: `hexdump: bad format {${value}}` };
4798
+ return { pieces };
4799
+ }
4800
+
4801
+ function hexdumpFormatNumber(directive: HexdumpDirective, digits: string): string {
4802
+ // fprintf rules: precision pads the magnitude with zeros, a sign always
4803
+ // sits in front of that padding, '-' alignment overrides '0', and an
4804
+ // explicit precision disables '0' field padding entirely.
4805
+ let sign = '';
4806
+ let magnitude = digits;
4807
+ if (magnitude.startsWith('-')) {
4808
+ sign = '-';
4809
+ magnitude = magnitude.slice(1);
4810
+ }
4811
+ if (directive.precision !== undefined) magnitude = magnitude.padStart(directive.precision, '0');
4812
+ const width = directive.width ?? 0;
4813
+ const value = sign + magnitude;
4814
+ if (directive.leftAlign) return value.padEnd(width);
4815
+ if (directive.zeroPad && directive.precision === undefined && width > 0) {
4816
+ return sign + magnitude.padStart(width - sign.length, '0');
4817
+ }
4818
+ return value.padStart(width);
4819
+ }
4820
+
4821
+ /** Field width an iteration reserves, so missing ones pad like util-linux. */
4822
+ function hexdumpFieldWidth(directive: HexdumpDirective, size: number): number {
4823
+ if (directive.kind === 'addr') return directive.width ?? 0;
4824
+ if (directive.conv === 'c') return directive.width ?? 1;
4825
+ const family = directive.conv === 'x' || directive.conv === 'X'
4826
+ ? 'x'
4827
+ : directive.conv === 'o' ? 'o' : 'd';
4828
+ return directive.width ?? HEXDUMP_DIGIT_WIDTHS[String(size)][family];
4829
+ }
4830
+
4223
4831
  /**
4224
- * SHELL-R6-B2 follow-on: wrapStreaming for commands that handle pipe
4225
- * readers directly (head, tail, etc commands that can terminate
4226
- * before the producer drains).
4832
+ * Render one `-e` block straight off the source, one unit at a time. A
4833
+ * directive past the end of input renders as field-width spaces rather than
4834
+ * dropping its slot, and address directives report the offset of the next
4835
+ * byte to display. The dedup key repeats all of it except addresses, so
4836
+ * repeat suppression ignores where each block sits.
4837
+ *
4838
+ * util-linux's nospace rule: a unit repeated more than once drops the single
4839
+ * trailing whitespace character of its own format text on its LAST
4840
+ * repetition, EOF padding included. Exactly that one character goes, which
4841
+ * is why `3/1 "%02x "` keeps one of its two spaces, `2/1 "%02x\t"` keeps
4842
+ * the padding that follows the dropped tab, and a unit repeated once keeps
4843
+ * its spacing verbatim.
4227
4844
  *
4228
- * Behavior:
4229
- * - If ctx.stdin is a terminal stdin (has .feed), drain buffered
4230
- * bytes to a string (same as wrap — terminal stdin's
4231
- * buffer-then-close pattern doesn't match streaming).
4232
- * - If ctx.stdin is a pipe reader (has .read), PASS IT THROUGH
4233
- * unchanged so the command can read line-by-line. The command
4234
- * is responsible for terminating itself (e.g. head -n N stops
4235
- * after N lines, triggering the pipeline-abort cascade from
4236
- * SHELL-R6-2).
4237
- * - String stdin / other shapes: same as wrap.
4845
+ * Input arrives unit-sized: nothing collects count*size bytes however large
4846
+ * the repetition count is, and parseHexdumpArgs has already refused any
4847
+ * format whose block could outgrow {@link HEXDUMP_MAX_BLOCK_CHARS}.
4848
+ *
4849
+ * Returns the rendered line, its address-free key, and the source bytes the
4850
+ * block consumed; null once a block consumes nothing, i.e. true end.
4851
+ */
4852
+ async function hexdumpRenderBlock(
4853
+ pieces: HexdumpPiece[],
4854
+ pullUnit: (size: number) => Promise<Uint8Array>,
4855
+ blockStart: number,
4856
+ ): Promise<{ line: string; key: string; consumed: number } | null> {
4857
+ let line = '';
4858
+ let key = '';
4859
+ let pos = 0;
4860
+ let consumed = 0;
4861
+ let ended = false;
4862
+ for (const piece of pieces) {
4863
+ const iterations = piece.consumes ? piece.count : 1;
4864
+ const tail = piece.segments[piece.segments.length - 1];
4865
+ const dropsTailSpace = iterations > 1
4866
+ && typeof tail === 'string'
4867
+ && /[ \t\n\r\v\f]$/.test(tail);
4868
+ for (let iteration = 0; iteration < iterations; iteration++) {
4869
+ const lastIteration = iteration === iterations - 1;
4870
+ let unit: Uint8Array = HEXDUMP_EMPTY_UNIT;
4871
+ if (piece.consumes && !ended) {
4872
+ unit = await pullUnit(piece.size);
4873
+ if (unit.length < piece.size) ended = true;
4874
+ }
4875
+ const missing = piece.consumes && unit.length === 0;
4876
+ // Literals always render; only byte conversions pad when input ran
4877
+ // out, so a trailing literal still reaches the line on short blocks.
4878
+ for (let index = 0; index < piece.segments.length; index++) {
4879
+ const segment = piece.segments[index];
4880
+ if (typeof segment === 'string') {
4881
+ const text = dropsTailSpace && lastIteration && index === piece.segments.length - 1
4882
+ ? segment.slice(0, -1)
4883
+ : segment;
4884
+ line += text;
4885
+ key += text;
4886
+ continue;
4887
+ }
4888
+ if (segment.kind === 'addr') {
4889
+ const shown = blockStart + pos;
4890
+ const digits = segment.radix === 'd'
4891
+ ? String(shown)
4892
+ : shown.toString(segment.radix === 'o' ? 8 : 16);
4893
+ line += hexdumpFormatNumber(segment, digits);
4894
+ continue;
4895
+ }
4896
+ if (missing) {
4897
+ const pad = ' '.repeat(hexdumpFieldWidth(segment, piece.size));
4898
+ line += pad;
4899
+ key += pad;
4900
+ continue;
4901
+ }
4902
+ let value = 0;
4903
+ for (let b = piece.size - 1; b >= 0; b--) value = value * 256 + (unit[b] ?? 0);
4904
+ pos += piece.size;
4905
+ if (segment.conv === 'c') {
4906
+ const text = hexdumpFormatNumber(segment, String.fromCharCode(value & 0xff));
4907
+ line += text;
4908
+ key += text;
4909
+ continue;
4910
+ }
4911
+ let digits: string;
4912
+ if (segment.conv === 'd') {
4913
+ const signBit = 256 ** piece.size / 2;
4914
+ digits = String(value >= signBit ? value - signBit * 2 : value);
4915
+ } else if (segment.conv === 'u') {
4916
+ digits = String(value);
4917
+ } else if (segment.conv === 'o') {
4918
+ digits = value.toString(8);
4919
+ } else {
4920
+ digits = value.toString(16);
4921
+ if (segment.conv === 'X') digits = digits.toUpperCase();
4922
+ }
4923
+ const text = hexdumpFormatNumber(segment, digits);
4924
+ line += text;
4925
+ key += text;
4926
+ }
4927
+ consumed += unit.length;
4928
+ }
4929
+ }
4930
+ if (consumed === 0) return null;
4931
+ return { line, key, consumed };
4932
+ }
4933
+
4934
+ /** Body columns for the fixed modes; the caller prefixes the address. */
4935
+ function hexdumpFixedBody(mode: 'default' | 'x' | 'd' | 'o' | 'C', row: Uint8Array): { body: string; bar: string | null } {
4936
+ if (mode === 'C') {
4937
+ const group1 = Array.from(row.subarray(0, 8), (b) => b.toString(16).padStart(2, '0'));
4938
+ const group2 = Array.from(row.subarray(8), (b) => b.toString(16).padStart(2, '0'));
4939
+ const columns = group1.join(' ') + (group2.length > 0 ? ' ' + group2.join(' ') : '');
4940
+ const bar = Array.from(row, (b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : '.')).join('');
4941
+ return { body: columns.padEnd(50), bar };
4942
+ }
4943
+ if (mode === 'default') {
4944
+ const words: string[] = [];
4945
+ for (let i = 0; i < row.length; i += 2) {
4946
+ words.push(leWord(row.subarray(i)).toString(16).padStart(4, '0'));
4947
+ }
4948
+ return { body: words.join(' ').padEnd(39), bar: null };
4949
+ }
4950
+ const slots: string[] = [];
4951
+ for (let i = 0; i < 16; i += 2) {
4952
+ const chunk = row.subarray(i);
4953
+ if (chunk.length === 0) { slots.push(' '.repeat(8)); continue; }
4954
+ const rendered = mode === 'x'
4955
+ ? leWord(chunk).toString(16).padStart(4, '0')
4956
+ : mode === 'd'
4957
+ ? String(leWord(chunk)).padStart(5, '0')
4958
+ : leWord(chunk).toString(8).padStart(6, '0');
4959
+ slots.push(rendered.padStart(i === 0 ? 7 : 8));
4960
+ }
4961
+ return { body: slots.join(''), bar: null };
4962
+ }
4963
+
4964
+ type HexdumpArgs =
4965
+ | { error: string }
4966
+ | {
4967
+ mode: 'default' | 'x' | 'd' | 'o' | 'C';
4968
+ pieces: HexdumpPiece[] | null;
4969
+ length: number | undefined;
4970
+ verbose: boolean;
4971
+ files: string[];
4972
+ };
4973
+
4974
+ /** Format flags override each other (last wins); `-e` replaces them all. */
4975
+ function parseHexdumpArgs(args: string[]): HexdumpArgs {
4976
+ let mode: 'default' | 'x' | 'd' | 'o' | 'C' = 'default';
4977
+ let pieces: HexdumpPiece[] | null = null;
4978
+ let length: number | undefined;
4979
+ let verbose = false;
4980
+ const files: string[] = [];
4981
+
4982
+ for (let i = 0; i < args.length; i++) {
4983
+ const arg = args[i];
4984
+ if (arg === '--') { files.push(...args.slice(i + 1)); break; }
4985
+ if (!arg.startsWith('-') || arg === '-') { files.push(arg); continue; }
4986
+ const flag = arg[1];
4987
+ const inlineValue = arg.slice(2);
4988
+ if (flag === 'C' || flag === 'x' || flag === 'd' || flag === 'o') {
4989
+ if (inlineValue !== '') return { error: `hexdump: invalid option -- '${flag}'` };
4990
+ mode = flag;
4991
+ continue;
4992
+ }
4993
+ if (flag === 'v' && inlineValue === '') { verbose = true; continue; }
4994
+ if (flag !== 'e' && flag !== 'n') {
4995
+ return { error: `hexdump: invalid option -- '${flag}'` };
4996
+ }
4997
+ const value = inlineValue || args[++i];
4998
+ if (value === undefined || value === '') {
4999
+ return { error: `hexdump: option requires an argument -- '${flag}'` };
5000
+ }
5001
+ if (flag === 'n') {
5002
+ const parsed = parseDumpCount(value);
5003
+ if (parsed === null || parsed < 0) {
5004
+ return { error: `hexdump: invalid length '${value}'` };
5005
+ }
5006
+ length = parsed;
5007
+ continue;
5008
+ }
5009
+ if (pieces !== null) return { error: 'hexdump: only one -e format is supported' };
5010
+ const parsed = parseHexdumpPieces(value);
5011
+ if ('error' in parsed) return parsed;
5012
+ if (!parsed.pieces.some((piece) => piece.consumes)) {
5013
+ return { error: `hexdump: bad format {${value}}` };
5014
+ }
5015
+ // The block is held twice while it is classified, so a format whose
5016
+ // rendering cannot fit is refused here — before a byte is ever read.
5017
+ let blockChars = 0;
5018
+ for (const piece of parsed.pieces) {
5019
+ let perIteration = 0;
5020
+ for (const segment of piece.segments) {
5021
+ if (typeof segment === 'string') { perIteration += segment.length; continue; }
5022
+ if (segment.kind === 'addr') {
5023
+ perIteration += Math.max(segment.width ?? 0, HEXDUMP_MAX_ADDRESS_DIGITS);
5024
+ continue;
5025
+ }
5026
+ // A narrow field cannot shrink a value: `%1u` over four bytes still
5027
+ // renders ten characters. Size each conversion by the widest of its
5028
+ // field width, its precision plus a sign, and the natural maximum
5029
+ // for its byte size.
5030
+ const digits = HEXDUMP_DIGIT_WIDTHS[String(piece.size)];
5031
+ const natural = segment.conv === 'c'
5032
+ ? 1
5033
+ : segment.conv === 'd'
5034
+ ? digits.d + 1
5035
+ : segment.conv === 'u'
5036
+ ? digits.d
5037
+ : segment.conv === 'o'
5038
+ ? digits.o
5039
+ : digits.x;
5040
+ perIteration += Math.max(segment.width ?? 0, natural, (segment.precision ?? 0) + 1);
5041
+ }
5042
+ blockChars += (piece.consumes ? piece.count : 1) * perIteration;
5043
+ }
5044
+ if (blockChars > HEXDUMP_MAX_BLOCK_CHARS) {
5045
+ return {
5046
+ error: `hexdump: format renders ${blockChars} characters per block, over the ${HEXDUMP_MAX_BLOCK_CHARS} limit`,
5047
+ };
5048
+ }
5049
+ pieces = parsed.pieces;
5050
+ }
5051
+ return { mode, pieces, length, verbose, files };
5052
+ }
5053
+
5054
+ function mkHexdump(): CmdFn {
5055
+ return async (ctx) => {
5056
+ const parsed = parseHexdumpArgs(ctx.args);
5057
+ if ('error' in parsed) {
5058
+ ctx.stderr.write(`${parsed.error}\n`);
5059
+ return 1;
5060
+ }
5061
+ const src = new DumpByteSource(ctx, 'hexdump', parsed.files, parsed.length);
5062
+ const dedup = new RowDedup();
5063
+ if (parsed.pieces !== null) {
5064
+ const lastPiece = parsed.pieces[parsed.pieces.length - 1];
5065
+ const lastSegment = lastPiece.segments[lastPiece.segments.length - 1];
5066
+ // Repeat suppression needs line boundaries; free-form formats emit
5067
+ // the whole stream, which is what -v spells on util-linux.
5068
+ const lineStructured = typeof lastSegment === 'string' && lastSegment.endsWith('\n');
5069
+ // Units arrive from a small stash fed well ahead of demand, so
5070
+ // per-unit pulls amortize into large source reads without ever
5071
+ // holding count*size bytes for a block.
5072
+ let pending = HEXDUMP_EMPTY_UNIT;
5073
+ const pullUnit = async (size: number): Promise<Uint8Array> => {
5074
+ while (pending.length < size) {
5075
+ // Ready reads: a file answers the whole block in one range read,
5076
+ // and a live producer answers with whatever it has already written.
5077
+ const chunk = await src.takeReady(Math.max(size - pending.length, HEXDUMP_PULL_BYTES));
5078
+ if (chunk === null || chunk.length === 0) break;
5079
+ const merged = new Uint8Array(pending.length + chunk.length);
5080
+ merged.set(pending);
5081
+ merged.set(chunk, pending.length);
5082
+ pending = merged;
5083
+ }
5084
+ const unit = pending.subarray(0, size);
5085
+ pending = pending.subarray(unit.length);
5086
+ return unit;
5087
+ };
5088
+ let offset = 0;
5089
+ while (true) {
5090
+ const block = await hexdumpRenderBlock(parsed.pieces, pullUnit, offset);
5091
+ if (block === null) break;
5092
+ offset += block.consumed;
5093
+ if (!lineStructured) { ctx.stdout.write(block.line); continue; }
5094
+ switch (dedup.classify(block.key, parsed.verbose)) {
5095
+ case 'print': ctx.stdout.write(block.line); break;
5096
+ case 'star': ctx.stdout.write('*\n'); break;
5097
+ }
5098
+ }
5099
+ if (src.failedAll) ctx.stderr.write('hexdump: all input file arguments failed\n');
5100
+ return src.failed ? 1 : 0;
5101
+ }
5102
+
5103
+ const wide = parsed.mode === 'C';
5104
+ while (true) {
5105
+ const row = await src.take(16);
5106
+ if (row === null || row.length === 0) break;
5107
+ const { body, bar } = hexdumpFixedBody(parsed.mode, row);
5108
+ const address = (src.total - row.length).toString(16).padStart(wide ? 8 : 7, '0');
5109
+ switch (dedup.classify(body, parsed.verbose)) {
5110
+ case 'print':
5111
+ ctx.stdout.write(wide ? `${address} ${body}|${bar}|\n` : `${address} ${body}\n`);
5112
+ break;
5113
+ case 'star':
5114
+ ctx.stdout.write('*\n');
5115
+ break;
5116
+ }
5117
+ }
5118
+ if (src.failedAll) ctx.stderr.write('hexdump: all input file arguments failed\n');
5119
+ if (src.total > 0) {
5120
+ ctx.stdout.write(`${src.total.toString(16).padStart(wide ? 8 : 7, '0')}\n`);
5121
+ }
5122
+ return src.failed ? 1 : 0;
5123
+ };
5124
+ }
5125
+
5126
+ // ── xxd ─────────────────────────────────────────────────────────────────
5127
+
5128
+ /**
5129
+ * `xxd [FILE [-] [OUTFILE]]` — pipelines are xxd's primary use, so stdin is
5130
+ * read when no input operand is given or `-` names it. A second positional
5131
+ * operand receives the dump as a file, like real xxd. `-l N` limits the dump
5132
+ * (decimal, 0x hex, leading-zero octal, with count suffixes); `-p` emits
5133
+ * continuous hex in bounded 30-byte rows. The default row layout predates
5134
+ * this fix and is preserved verbatim.
4238
5135
  */
5136
+ function mkXxd(): CmdFn {
5137
+ return async (ctx) => {
5138
+ let plain = false;
5139
+ let limit: number | undefined;
5140
+ const operands: string[] = [];
5141
+ for (let i = 0; i < ctx.args.length; i++) {
5142
+ const arg = ctx.args[i];
5143
+ if (arg === '-p') { plain = true; continue; }
5144
+ if (arg === '-' || !arg.startsWith('-')) {
5145
+ if (operands.length >= 2) {
5146
+ ctx.stderr.write(`xxd: extra operand '${arg}'\n`);
5147
+ return 1;
5148
+ }
5149
+ operands.push(arg);
5150
+ continue;
5151
+ }
5152
+ if (arg === '-l' || arg.startsWith('-l')) {
5153
+ const value = arg === '-l' ? ctx.args[++i] : arg.slice(2);
5154
+ const parsed = value === undefined ? null : parseDumpCount(value);
5155
+ if (parsed === null || parsed < 0) {
5156
+ ctx.stderr.write(`xxd: invalid length value '${value ?? ''}'\n`);
5157
+ return 1;
5158
+ }
5159
+ limit = parsed;
5160
+ continue;
5161
+ }
5162
+ ctx.stderr.write(`xxd: invalid option -- '${arg.replace(/^-+/, '')}'\n`);
5163
+ return 1;
5164
+ }
5165
+
5166
+ // Prime the source FIRST: pull the initial window (surfacing any open
5167
+ // error) before the output file exists to truncate.
5168
+ const rowSize = plain ? 30 : 16;
5169
+ const src = new DumpByteSource(ctx, 'xxd', operands.slice(0, 1), limit);
5170
+ const firstWindow = await src.take(rowSize);
5171
+ if (src.failedAll || (src.failed && src.opened === 0)) return 1;
5172
+
5173
+ // A second operand names the output file; `-` there means stdout. It
5174
+ // routes through ctx.vfs like every other named path, so dumps may land
5175
+ // on devices and mounts as they do on Unix.
5176
+ const output = operands[1];
5177
+ const outAbs = output !== undefined && output !== '-' ? absolutePath(ctx.cwd, output) : null;
5178
+
5179
+ let offset = 0;
5180
+ let fileOffset = 0;
5181
+ let pending: string[] = [];
5182
+ let pendingBytes = 0;
5183
+ let writeFailed = false;
5184
+ const flush = () => {
5185
+ if (pending.length === 0 || writeFailed || outAbs === null) return;
5186
+ try {
5187
+ ctx.vfs.writeRange(outAbs, fileOffset, encode(pending.join('')));
5188
+ } catch (error) {
5189
+ ctx.stderr.write(`xxd: ${output}: ${fsErrorMessage(error)}\n`);
5190
+ writeFailed = true;
5191
+ return;
5192
+ }
5193
+ fileOffset += pendingBytes;
5194
+ pending = [];
5195
+ pendingBytes = 0;
5196
+ };
5197
+
5198
+ const renderWindow = (rowOffset: number, window: Uint8Array): string => {
5199
+ if (plain) {
5200
+ let line = '';
5201
+ for (const byte of window) line += byte.toString(16).padStart(2, '0');
5202
+ return `${line}\n`;
5203
+ }
5204
+ const pairs = Array.from(window, (b) => b.toString(16).padStart(2, '0')).join(' ');
5205
+ const ascii = Array.from(window, (b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : '.')).join('');
5206
+ return `${rowOffset.toString(16).padStart(8, '0')}: ${pairs.padEnd(48)} ${ascii}\n`;
5207
+ };
5208
+
5209
+ if (outAbs !== null) {
5210
+ // Input proved readable above, so truncating here cannot destroy data
5211
+ // on a failed dump.
5212
+ try {
5213
+ ctx.vfs.writeFile(outAbs, '');
5214
+ } catch (error) {
5215
+ ctx.stderr.write(`xxd: ${output}: ${fsErrorMessage(error)}\n`);
5216
+ return 1;
5217
+ }
5218
+ }
5219
+
5220
+ let window = firstWindow;
5221
+ while (window !== null && window.length > 0 && !writeFailed) {
5222
+ const text = renderWindow(offset, window);
5223
+ offset += window.length;
5224
+ if (outAbs !== null) {
5225
+ pending.push(text);
5226
+ pendingBytes += text.length;
5227
+ if (pendingBytes >= 65536) flush();
5228
+ } else {
5229
+ ctx.stdout.write(text);
5230
+ }
5231
+ window = await src.take(rowSize);
5232
+ }
5233
+ if (outAbs !== null) flush();
5234
+ return src.failed || writeFailed ? 1 : 0;
5235
+ }
5236
+ }
5237
+
4239
5238
  function wrapStreaming(fn: CmdFn): (ctx: Ctx) => Promise<number> {
4240
5239
  return async (ctx: Ctx) => {
4241
5240
  try {
@@ -4243,10 +5242,10 @@ function wrapStreaming(fn: CmdFn): (ctx: Ctx) => Promise<number> {
4243
5242
  const stdinObj = ctx.stdin;
4244
5243
  const isTerminalStdin = typeof stdinObj.feed === 'function';
4245
5244
  if (isTerminalStdin) {
4246
- const buf: string[] = Array.isArray(stdinObj.buffer)
4247
- ? stdinObj.buffer.splice(0)
4248
- : [];
4249
- ctx.stdin = buf.join('');
5245
+ const drainable = stdinObj as { drainBuffered?: () => string };
5246
+ ctx.stdin = typeof drainable.drainBuffered === 'function'
5247
+ ? drainable.drainBuffered()
5248
+ : '';
4250
5249
  }
4251
5250
  // else: leave as pipe reader for the command to handle.
4252
5251
  }
@@ -4289,10 +5288,10 @@ function wrap(fn: CmdFn): (ctx: Ctx) => Promise<number> {
4289
5288
  // first command on a line; non-empty if the user typed
4290
5289
  // text + Enter before the command was dispatched). DO NOT
4291
5290
  // await — that would wait for the user's next Ctrl-D.
4292
- const buf: string[] = Array.isArray(stdinObj.buffer)
4293
- ? stdinObj.buffer.splice(0)
4294
- : [];
4295
- ctx.stdin = buf.join('');
5291
+ const drainable = stdinObj as { drainBuffered?: () => string };
5292
+ ctx.stdin = typeof drainable.drainBuffered === 'function'
5293
+ ? drainable.drainBuffered()
5294
+ : '';
4296
5295
  } else if (typeof stdinObj.readAll === 'function') {
4297
5296
  // Pipe reader — upstream will close() after writing, so
4298
5297
  // readAll() resolves bounded.
@@ -4364,7 +5363,12 @@ export function registerUnixCommands(
4364
5363
  registry.register('readlink', wrap(withInvocationVfs(sqliteVfs, mkReadlink)));
4365
5364
  registry.register('sha256sum', wrap(withInvocationVfs(sqliteVfs, mkSha256sum)));
4366
5365
  registry.register('file', wrap(withInvocationVfs(sqliteVfs, mkFile)));
4367
- registry.register('xxd', wrap(withInvocationVfs(sqliteVfs, mkXxd)));
5366
+ // od/hexdump/xxd read operands and sinks through ctx.vfs — the
5367
+ // mount-aware seam the host hands every command — so they need no
5368
+ // invocation-scoped raw view of their own.
5369
+ registry.register('xxd', wrapStreaming(mkXxd()));
5370
+ registry.register('od', wrapStreaming(mkOd()));
5371
+ registry.register('hexdump', wrapStreaming(mkHexdump()));
4368
5372
 
4369
5373
  registry.register('chown', wrap(mkChown(sqliteVfs)));
4370
5374