@nimbus-sh/core 0.5.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 (145) 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/cache-stats.d.ts +23 -1
  5. package/dist/_shared/cache-stats.d.ts.map +1 -1
  6. package/dist/_shared/cache-stats.js +13 -1
  7. package/dist/_shared/exports-resolver.d.ts +1 -1
  8. package/dist/_shared/exports-resolver.js +1 -1
  9. package/dist/_shared/real-node-imports.d.ts +7 -0
  10. package/dist/_shared/real-node-imports.d.ts.map +1 -1
  11. package/dist/_shared/real-node-imports.js +8 -0
  12. package/dist/_shared/retry.js +1 -1
  13. package/dist/_shared/tarball.d.ts +23 -5
  14. package/dist/_shared/tarball.d.ts.map +1 -1
  15. package/dist/_shared/tarball.js +38 -5
  16. package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
  17. package/dist/_shared/vfs-write-ledger.js +22 -16
  18. package/dist/constants.d.ts +5 -11
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +16 -56
  21. package/dist/runtime/assets-loader.js +1 -1
  22. package/dist/runtime/comment-strip.d.ts +0 -35
  23. package/dist/runtime/comment-strip.d.ts.map +1 -1
  24. package/dist/runtime/comment-strip.js +48 -20
  25. package/dist/runtime/esbuild-service.d.ts +38 -42
  26. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  27. package/dist/runtime/esbuild-service.js +494 -197
  28. package/dist/runtime/javascript-ast.d.ts.map +1 -1
  29. package/dist/runtime/javascript-ast.js +45 -7
  30. package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
  31. package/dist/runtime/opentui-wasm-backend.js +18 -16
  32. package/dist/runtime/port-registry.d.ts +4 -2
  33. package/dist/runtime/port-registry.d.ts.map +1 -1
  34. package/dist/runtime/port-registry.js +60 -8
  35. package/dist/runtime/virtual-socket-kernel.generated.d.ts +1 -1
  36. package/dist/runtime/virtual-socket-kernel.generated.js +1 -1
  37. package/dist/shell/stdin-adapter.d.ts +11 -0
  38. package/dist/shell/stdin-adapter.d.ts.map +1 -0
  39. package/dist/shell/stdin-adapter.js +59 -0
  40. package/dist/shell/unix-commands.d.ts +2 -1
  41. package/dist/shell/unix-commands.d.ts.map +1 -1
  42. package/dist/shell/unix-commands.js +1078 -50
  43. package/dist/substrate/lifo/commands/io/dd.js +1 -1
  44. package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
  45. package/dist/substrate/lifo/commands/net/curl.js +270 -54
  46. package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
  47. package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
  48. package/dist/substrate/lifo/commands/system/npm.js +26 -43
  49. package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
  50. package/dist/substrate/lifo/commands/text/head.js +2 -1
  51. package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
  52. package/dist/substrate/lifo/commands/text/sed.js +499 -117
  53. package/dist/substrate/lifo/commands/types.d.ts +8 -4
  54. package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
  55. package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
  56. package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
  57. package/dist/substrate/lifo/node-compat/zlib.js +122 -23
  58. package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
  59. package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
  60. package/dist/substrate/lifo/shell/interpreter.js +79 -29
  61. package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
  62. package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
  63. package/dist/substrate/lifo/shell/pipe.js +62 -18
  64. package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
  65. package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
  66. package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
  67. package/dist/vfs/seed-project.d.ts.map +1 -1
  68. package/dist/vfs/seed-project.js +1 -1
  69. package/dist/vfs/sqlite-vfs.d.ts +21 -30
  70. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  71. package/dist/vfs/sqlite-vfs.js +113 -29
  72. package/dist/workspace/nimbus-workspace.d.ts +15 -0
  73. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  74. package/dist/workspace/nimbus-workspace.js +20 -3
  75. package/dist/workspace/supervisor-op.d.ts +23 -0
  76. package/dist/workspace/supervisor-op.d.ts.map +1 -0
  77. package/dist/workspace/supervisor-op.js +92 -0
  78. package/package.json +3 -2
  79. package/src/_shared/byte-stream.ts +5 -4
  80. package/src/_shared/cache-stats.ts +27 -1
  81. package/src/_shared/exports-resolver.ts +1 -1
  82. package/src/_shared/real-node-imports.ts +8 -0
  83. package/src/_shared/retry.ts +1 -1
  84. package/src/_shared/tarball.ts +56 -5
  85. package/src/_shared/vfs-write-ledger.ts +22 -16
  86. package/src/constants.ts +30 -60
  87. package/src/runtime/assets-loader.ts +1 -1
  88. package/src/runtime/comment-strip.ts +42 -23
  89. package/src/runtime/esbuild-service.ts +566 -217
  90. package/src/runtime/javascript-ast.ts +40 -9
  91. package/src/runtime/opentui-wasm-backend.ts +19 -17
  92. package/src/runtime/port-registry.ts +66 -8
  93. package/src/runtime/virtual-socket-kernel.generated.ts +1 -1
  94. package/src/shell/stdin-adapter.ts +58 -0
  95. package/src/shell/unix-commands.ts +1054 -50
  96. package/src/substrate/lifo/commands/io/dd.ts +2 -2
  97. package/src/substrate/lifo/commands/net/curl.ts +310 -58
  98. package/src/substrate/lifo/commands/system/npm.ts +58 -38
  99. package/src/substrate/lifo/commands/text/head.ts +2 -1
  100. package/src/substrate/lifo/commands/text/sed.ts +519 -109
  101. package/src/substrate/lifo/commands/types.ts +8 -5
  102. package/src/substrate/lifo/node-compat/zlib.ts +155 -26
  103. package/src/substrate/lifo/shell/interpreter.ts +74 -27
  104. package/src/substrate/lifo/shell/pipe.ts +65 -24
  105. package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
  106. package/src/vfs/seed-project.ts +3 -3
  107. package/src/vfs/sqlite-vfs.ts +128 -64
  108. package/src/workspace/nimbus-workspace.ts +26 -2
  109. package/src/workspace/supervisor-op.ts +117 -0
  110. package/dist/_shared/install-phase.d.ts +0 -47
  111. package/dist/_shared/install-phase.d.ts.map +0 -1
  112. package/dist/_shared/install-phase.js +0 -1
  113. package/dist/_shared/rpc-dispose.d.ts +0 -4
  114. package/dist/_shared/rpc-dispose.d.ts.map +0 -1
  115. package/dist/_shared/rpc-dispose.js +0 -30
  116. package/dist/_shared/w7-frame.d.ts +0 -79
  117. package/dist/_shared/w7-frame.d.ts.map +0 -1
  118. package/dist/_shared/w7-frame.js +0 -840
  119. package/dist/_shared/weighted-credit-pool.d.ts +0 -61
  120. package/dist/_shared/weighted-credit-pool.d.ts.map +0 -1
  121. package/dist/_shared/weighted-credit-pool.js +0 -196
  122. package/dist/observability/diag-counters.d.ts +0 -236
  123. package/dist/observability/diag-counters.d.ts.map +0 -1
  124. package/dist/observability/diag-counters.js +0 -195
  125. package/dist/observability/heap-estimate.d.ts +0 -225
  126. package/dist/observability/heap-estimate.d.ts.map +0 -1
  127. package/dist/observability/heap-estimate.js +0 -185
  128. package/dist/observability/heavy-alloc-coord.d.ts +0 -93
  129. package/dist/observability/heavy-alloc-coord.d.ts.map +0 -1
  130. package/dist/observability/heavy-alloc-coord.js +0 -181
  131. package/dist/observability/oom-classify.d.ts +0 -138
  132. package/dist/observability/oom-classify.d.ts.map +0 -1
  133. package/dist/observability/oom-classify.js +0 -268
  134. package/dist/observability/oom-discriminator.d.ts +0 -176
  135. package/dist/observability/oom-discriminator.d.ts.map +0 -1
  136. package/dist/observability/oom-discriminator.js +0 -272
  137. package/src/_shared/install-phase.ts +0 -56
  138. package/src/_shared/rpc-dispose.ts +0 -31
  139. package/src/_shared/w7-frame.ts +0 -1013
  140. package/src/_shared/weighted-credit-pool.ts +0 -244
  141. package/src/observability/diag-counters.ts +0 -343
  142. package/src/observability/heap-estimate.ts +0 -331
  143. package/src/observability/heavy-alloc-coord.ts +0 -241
  144. package/src/observability/oom-classify.ts +0 -260
  145. package/src/observability/oom-discriminator.ts +0 -405
@@ -4,9 +4,8 @@ import type { VfsCred } from '../../../runtime/os-contracts.js';
4
4
  export interface CommandOutputStream {
5
5
  write(text: string): void;
6
6
  /**
7
- * Present on sinks that store bytes verbatim (files, `/dev/null`). Textual
8
- * sinks — the terminal, shell pipes omit it and take decoded text, since
9
- * they have no way to carry a byte that is not text.
7
+ * Present on sinks that store bytes verbatim files, `/dev/null`, and
8
+ * byte-capable shell pipes. Sinks without it take decoded text instead.
10
9
  */
11
10
  writeBytes?(bytes: Uint8Array): void;
12
11
  /**
@@ -22,9 +21,13 @@ export interface CommandInputStream {
22
21
  read(): Promise<string | null>; // null = EOF
23
22
  readAll(): Promise<string>;
24
23
  readLine?(): Promise<string | null>;
25
- readBytes?(maxLength: number): Promise<string | null>;
24
+ /**
25
+ * Bounded raw-byte read. Byte-capable sources (shell pipes, dumps) return
26
+ * the original bytes; text-only sources encode what they hold. Null means
27
+ * EOF with nothing returned.
28
+ */
29
+ readBytes?(maxLength: number): Promise<Uint8Array | null>;
26
30
  }
27
-
28
31
  export interface TerminalInputStream extends CommandInputStream {
29
32
  rawMode: boolean;
30
33
  }
@@ -2,6 +2,79 @@ import { Buffer } from './buffer.js';
2
2
 
3
3
  type ZlibCallback = (err: Error | null, result?: Buffer) => void;
4
4
 
5
+ /**
6
+ * Node's async surface: the callback is required, with options optional in
7
+ * front of it. There is no promise return to fall back on.
8
+ */
9
+ interface ZlibAsync {
10
+ (data: ZlibInput, callback: ZlibCallback): void;
11
+ (data: ZlibInput, options: object, callback: ZlibCallback): void;
12
+ }
13
+
14
+ /**
15
+ * Everything a zlib entry point accepts: strings encode to UTF-8;
16
+ * ArrayBuffers and every ArrayBufferView carry their own bytes.
17
+ */
18
+ type ZlibInput = string | ArrayBufferView | ArrayBuffer;
19
+
20
+ /**
21
+ * One funnel for every entry point. The result views the caller's exact
22
+ * byte region — byteOffset and byteLength preserved, element values never
23
+ * reinterpreted — so sniffing and the codec see identical bytes for a
24
+ * Uint8Array, a Uint16Array, an offset DataView, or a bare ArrayBuffer.
25
+ */
26
+ function toBytes(data: ZlibInput): Uint8Array {
27
+ if (typeof data === 'string') return new TextEncoder().encode(data);
28
+ if (data instanceof Uint8Array) return data;
29
+ if (ArrayBuffer.isView(data)) {
30
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
31
+ }
32
+ return new Uint8Array(data);
33
+ }
34
+
35
+ /**
36
+ * Node splits decompression failures two ways, and the engine's own failure
37
+ * is the only thing that can tell them apart: an input that ran out is
38
+ * Z_BUF_ERROR / errno -5, while everything else — bad header, bad block, and
39
+ * a checksum or length trailer that can only fail once the member is
40
+ * complete — is Z_DATA_ERROR / errno -3 carrying the engine's message. Which
41
+ * side of a write or a close the failure lands on says nothing about that,
42
+ * so it is not consulted.
43
+ */
44
+ function isUnexpectedEnd(cause: unknown): boolean {
45
+ const message = cause instanceof Error && typeof cause.message === 'string' ? cause.message : String(cause);
46
+ return /unexpected end|end of (?:file|input|stream)|premature|truncated|\bEOF\b/i.test(message);
47
+ }
48
+
49
+ function zDataError(cause: unknown): Error {
50
+ const message = cause instanceof Error && cause.message ? cause.message : String(cause);
51
+ return Object.assign(new Error(message), { code: 'Z_DATA_ERROR', errno: -3, cause });
52
+ }
53
+
54
+ function zBufError(cause: unknown): Error {
55
+ return Object.assign(new Error('unexpected end of file'), {
56
+ code: 'Z_BUF_ERROR',
57
+ errno: -5,
58
+ cause,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Node throws this synchronously rather than deferring a callback that does
64
+ * not exist.
65
+ */
66
+ function invalidCallback(value: unknown): TypeError {
67
+ const received =
68
+ value === undefined ? 'undefined'
69
+ : value === null ? 'null'
70
+ : typeof value === 'object' ? `an instance of ${value.constructor?.name ?? 'Object'}`
71
+ : `type ${typeof value} (${String(value)})`;
72
+ return Object.assign(
73
+ new TypeError(`The "callback" argument must be of type function. Received ${received}`),
74
+ { code: 'ERR_INVALID_ARG_TYPE' },
75
+ );
76
+ }
77
+
5
78
  async function processStream(
6
79
  data: Uint8Array,
7
80
  format: CompressionFormat,
@@ -13,15 +86,35 @@ async function processStream(
13
86
  : new DecompressionStream(format);
14
87
 
15
88
  const writer = stream.writable.getWriter();
16
- writer.write(data as unknown as ArrayBuffer);
17
- writer.close();
18
-
19
89
  const reader = stream.readable.getReader();
20
90
  const chunks: Uint8Array[] = [];
21
- for (;;) {
22
- const { done, value } = await reader.read();
23
- if (done) break;
24
- chunks.push(value);
91
+ // Feeding and draining run together: a single write large enough to fill
92
+ // the codec's output queue only completes while somebody reads. Both sides
93
+ // are contained so neither rejection surfaces unhandled, and the first
94
+ // failure is the one reported.
95
+ const failures: unknown[] = [];
96
+ const record = (reason: unknown): void => {
97
+ if (failures.length === 0) failures.push(reason);
98
+ };
99
+ const drain = (async () => {
100
+ for (;;) {
101
+ const { done, value } = await reader.read();
102
+ if (done) return;
103
+ chunks.push(value);
104
+ }
105
+ })().catch(record);
106
+ const feed = (async () => {
107
+ await writer.write(data);
108
+ await writer.close();
109
+ })().catch(record);
110
+ await Promise.all([drain, feed]);
111
+
112
+ if (failures.length > 0) {
113
+ const reason = failures[0];
114
+ if (type === 'decompress') {
115
+ throw isUnexpectedEnd(reason) ? zBufError(reason) : zDataError(reason);
116
+ }
117
+ throw reason instanceof Error ? reason : new Error(String(reason));
25
118
  }
26
119
 
27
120
  let totalLength = 0;
@@ -35,14 +128,28 @@ async function processStream(
35
128
  return result;
36
129
  }
37
130
 
38
- function wrapAsync(format: CompressionFormat, type: 'compress' | 'decompress') {
39
- return function (data: Uint8Array | string, optionsOrCb: unknown, cb?: ZlibCallback): void {
40
- const callback = typeof optionsOrCb === 'function' ? (optionsOrCb as ZlibCallback) : cb!;
41
- const input = typeof data === 'string' ? new TextEncoder().encode(data) : data;
42
- const raw = input instanceof Buffer ? new Uint8Array(input) : input;
43
- processStream(raw, format, type)
44
- .then((result) => callback(null, result))
45
- .catch((err) => callback(err instanceof Error ? err : new Error(String(err))));
131
+ /**
132
+ * Node's Unzip sniffs the wrapper itself: the gzip magic bytes mean gunzip,
133
+ * anything else is treated as zlib-wrapped deflate.
134
+ */
135
+ function looksLikeGzip(data: Uint8Array): boolean {
136
+ return data.length >= 2 && data[0] === 0x1f && data[1] === 0x8b;
137
+ }
138
+
139
+ function wrapAsync(
140
+ format: CompressionFormat | ((data: Uint8Array) => CompressionFormat),
141
+ type: 'compress' | 'decompress',
142
+ ): ZlibAsync {
143
+ return function (data: ZlibInput, optionsOrCb: object | ZlibCallback, cb?: ZlibCallback): void {
144
+ const callback = typeof optionsOrCb === 'function' ? optionsOrCb : cb;
145
+ if (typeof callback !== 'function') throw invalidCallback(callback);
146
+ const bytes = toBytes(data);
147
+ // Two arms, not .then().catch(): a callback that throws on success must
148
+ // not be handed its own exception as a second, failed invocation.
149
+ processStream(bytes, typeof format === 'function' ? format(bytes) : format, type).then(
150
+ (result) => callback(null, result),
151
+ (err) => callback(err instanceof Error ? err : new Error(String(err))),
152
+ );
46
153
  };
47
154
  }
48
155
 
@@ -52,20 +159,40 @@ export const deflate = wrapAsync('deflate', 'compress');
52
159
  export const inflate = wrapAsync('deflate', 'decompress');
53
160
  export const deflateRaw = wrapAsync('deflate-raw', 'compress');
54
161
  export const inflateRaw = wrapAsync('deflate-raw', 'decompress');
55
- export const unzip = wrapAsync('gzip', 'decompress');
162
+ export const unzip = wrapAsync((data) => (looksLikeGzip(data) ? 'gzip' : 'deflate'), 'decompress');
163
+
164
+ /**
165
+ * The Sync surface needs a synchronous compress primitive; browser-class
166
+ * runtimes only offer the async CompressionStream. Each Sync name refuses
167
+ * with the working async replacement spelled out instead of failing as a
168
+ * missing export or a vague "not supported".
169
+ */
170
+ function syncUnavailable(name: string, asyncName: string): never {
171
+ throw Object.assign(new Error(
172
+ `zlib.${name}: synchronous compression is not available on this runtime. Use the async zlib.${asyncName}(data, callback) form instead.`,
173
+ ), { code: 'ERR_ZLIB_SYNC_UNAVAILABLE' });
174
+ }
56
175
 
57
- // Sync variants are not supported in the browser
58
- export function gzipSync(): never {
59
- throw new Error('zlib sync operations are not supported in Lifo');
176
+ export function gzipSync(buffer?: ZlibInput, options?: object): never {
177
+ syncUnavailable('gzipSync', 'gzip');
178
+ }
179
+ export function gunzipSync(buffer?: ZlibInput, options?: object): never {
180
+ syncUnavailable('gunzipSync', 'gunzip');
181
+ }
182
+ export function deflateSync(buffer?: ZlibInput, options?: object): never {
183
+ syncUnavailable('deflateSync', 'deflate');
184
+ }
185
+ export function inflateSync(buffer?: ZlibInput, options?: object): never {
186
+ syncUnavailable('inflateSync', 'inflate');
60
187
  }
61
- export function gunzipSync(): never {
62
- throw new Error('zlib sync operations are not supported in Lifo');
188
+ export function deflateRawSync(buffer?: ZlibInput, options?: object): never {
189
+ syncUnavailable('deflateRawSync', 'deflateRaw');
63
190
  }
64
- export function deflateSync(): never {
65
- throw new Error('zlib sync operations are not supported in Lifo');
191
+ export function inflateRawSync(buffer?: ZlibInput, options?: object): never {
192
+ syncUnavailable('inflateRawSync', 'inflateRaw');
66
193
  }
67
- export function inflateSync(): never {
68
- throw new Error('zlib sync operations are not supported in Lifo');
194
+ export function unzipSync(buffer?: ZlibInput, options?: object): never {
195
+ syncUnavailable('unzipSync', 'unzip');
69
196
  }
70
197
 
71
198
  export const constants = {
@@ -90,5 +217,7 @@ export const constants = {
90
217
 
91
218
  export default {
92
219
  gzip, gunzip, deflate, inflate, deflateRaw, inflateRaw, unzip,
93
- gzipSync, gunzipSync, deflateSync, inflateSync, constants,
220
+ gzipSync, gunzipSync, deflateSync, inflateSync,
221
+ deflateRawSync, inflateRawSync, unzipSync,
222
+ constants,
94
223
  };
@@ -40,6 +40,7 @@ import { exitCodeForAbortSignal } from './signals.js';
40
40
  import { resolve } from '../utils/path.js';
41
41
  import { encode } from '../utils/encoding.js';
42
42
  import { globMatch } from '../utils/glob.js';
43
+ import { staticStdinReader } from '../../../shell/stdin-adapter.js';
43
44
 
44
45
  /**
45
46
  * Bytes a file-backed descriptor holds before committing. Matches the stream
@@ -1391,7 +1392,7 @@ export class Interpreter {
1391
1392
  const body = heredoc.quoted
1392
1393
  ? heredoc.body
1393
1394
  : await expandWord([{ text: heredoc.body, quoted: 'double' }], expandCtx);
1394
- this.setInputFd(fds, redir.fd ?? 0, { stream: this.createStringReader(body), terminal: false });
1395
+ this.setInputFd(fds, redir.fd ?? 0, { stream: staticStdinReader(body), terminal: false });
1395
1396
  continue;
1396
1397
  }
1397
1398
 
@@ -1533,11 +1534,17 @@ export class Interpreter {
1533
1534
  return { stream: terminalStdin, terminal: true };
1534
1535
  }
1535
1536
  if (target === '/dev/stdin') return this.resolveInputFd('0', fds.inputFds, fds.terminalInputFds);
1537
+ const targetPath = resolve(this.config.getCwd(), target);
1538
+ const vfs = io.vfs ?? this.config.vfs;
1536
1539
  try {
1537
- return {
1538
- stream: this.createFileReader(io.vfs ?? this.config.vfs, resolve(this.config.getCwd(), target)),
1539
- terminal: false,
1540
- };
1540
+ // Open-authorize before the command runs, the way open(2) would: a
1541
+ // missing, unreadable, or directory target fails the redirection even
1542
+ // when the command never reads a byte.
1543
+ if (vfs.stat(targetPath).type === 'directory') {
1544
+ throw Object.assign(new Error(`EISDIR: ${targetPath}`), { code: 'EISDIR' });
1545
+ }
1546
+ vfs.access(targetPath, 0o4);
1547
+ return { stream: this.createFileReader(vfs, targetPath), terminal: false };
1541
1548
  } catch (error) {
1542
1549
  throw new RedirectionOpenError(target, error);
1543
1550
  }
@@ -1607,37 +1614,77 @@ export class Interpreter {
1607
1614
  for (const stream of fds.outputFds.values()) stream.flush?.();
1608
1615
  }
1609
1616
 
1617
+ /**
1618
+ * Byte-faithful redirected input: bounded range reads keep >64 KiB
1619
+ * redirections intact and preserve bytes that are not valid UTF-8, while
1620
+ * the text view still decodes progressively across chunk boundaries.
1621
+ *
1622
+ * Only a zero-length range means EOF. Devices and some mounts answer with
1623
+ * fewer bytes than asked whenever their internal bound is hit; those short
1624
+ * nonempty reads advance the offset and continue, exactly like read(2).
1625
+ */
1610
1626
  private createFileReader(vfs: VFS, path: string): CommandInputStream {
1611
- const content = vfs.readFileString(path);
1612
- return this.createStringReader(content);
1613
- }
1614
-
1615
- private createStringReader(content: string): CommandInputStream {
1627
+ const decoder = new TextDecoder('utf-8');
1616
1628
  let offset = 0;
1629
+ let eof = false;
1630
+ const pull = (max: number): Uint8Array | null => {
1631
+ if (eof) return null;
1632
+ const chunk = vfs.readRange(path, offset, max);
1633
+ if (chunk.length === 0) {
1634
+ eof = true;
1635
+ return null;
1636
+ }
1637
+ offset += chunk.length;
1638
+ return chunk;
1639
+ };
1617
1640
  return {
1641
+ readBytes: async (maxLength: number) => {
1642
+ if (maxLength <= 0) return new Uint8Array(0);
1643
+ return pull(maxLength);
1644
+ },
1618
1645
  read: async () => {
1619
- if (offset >= content.length) return null;
1620
- const chunk = content.slice(offset);
1621
- offset = content.length;
1622
- return chunk;
1646
+ while (true) {
1647
+ const bytes = pull(65536);
1648
+ if (bytes === null) {
1649
+ const tail = decoder.decode();
1650
+ return tail.length > 0 ? tail : null;
1651
+ }
1652
+ const text = decoder.decode(bytes, { stream: true });
1653
+ if (text.length > 0) return text;
1654
+ }
1623
1655
  },
1624
1656
  readAll: async () => {
1625
- if (offset >= content.length) return '';
1626
- const chunk = content.slice(offset);
1627
- offset = content.length;
1628
- return chunk;
1657
+ let out = '';
1658
+ while (true) {
1659
+ const bytes = pull(65536);
1660
+ if (bytes === null) break;
1661
+ out += decoder.decode(bytes, { stream: true });
1662
+ }
1663
+ out += decoder.decode();
1664
+ return out;
1629
1665
  },
1630
1666
  readLine: async () => {
1631
- if (offset >= content.length) return null;
1632
- const newline = content.indexOf('\n', offset);
1633
- if (newline < 0) {
1634
- const line = content.slice(offset);
1635
- offset = content.length;
1636
- return line;
1667
+ let line = '';
1668
+ let sawAny = false;
1669
+ while (true) {
1670
+ const bytes = pull(65536);
1671
+ if (bytes === null) break;
1672
+ sawAny = true;
1673
+ // Split on the raw 0x0A byte so pushback returns ORIGINAL bytes;
1674
+ // decoded-text pushback would corrupt multibyte sequences that
1675
+ // straddle the chunk boundary or mis-measure invalid UTF-8.
1676
+ const newline = bytes.indexOf(0x0a);
1677
+ if (newline >= 0) {
1678
+ offset -= bytes.length - (newline + 1);
1679
+ eof = false;
1680
+ line += decoder.decode(bytes.subarray(0, newline), { stream: true });
1681
+ const flushed = decoder.decode();
1682
+ return line + flushed;
1683
+ }
1684
+ line += decoder.decode(bytes, { stream: true });
1637
1685
  }
1638
- const line = content.slice(offset, newline);
1639
- offset = newline + 1;
1640
- return line;
1686
+ line += decoder.decode();
1687
+ return sawAny || line.length > 0 ? line : null;
1641
1688
  },
1642
1689
  };
1643
1690
  }
@@ -1,14 +1,26 @@
1
1
  import type { CommandOutputStream, CommandInputStream } from '../commands/types.js';
2
+ import { encode } from '../utils/encoding.js';
2
3
 
4
+ /**
5
+ * A shell pipe that carries the producer's exact bytes. Text writes are
6
+ * encoded once at the write side, `writeBytes` stores bytes verbatim, and
7
+ * the text view (`read`/`readAll`/`readLine`) decodes progressively so a
8
+ * multi-byte UTF-8 sequence split across chunks survives intact.
9
+ */
3
10
  export class PipeChannel {
4
- private buffer: string[] = [];
11
+ private buffer: Uint8Array[] = [];
5
12
  private closed = false;
6
- private waiting: Array<(value: string | null) => void> = [];
13
+ private waiting: Array<(value: Uint8Array | null) => void> = [];
14
+ private decoder = new TextDecoder('utf-8');
7
15
 
8
16
  readonly writer: CommandOutputStream = {
9
17
  write: (text: string) => {
10
18
  if (this.closed) return;
11
- this.deliver(text, 'back');
19
+ this.deliver(encode(text), 'back');
20
+ },
21
+ writeBytes: (bytes: Uint8Array) => {
22
+ if (this.closed) return;
23
+ this.deliver(bytes, 'back');
12
24
  },
13
25
  };
14
26
 
@@ -19,18 +31,31 @@ export class PipeChannel {
19
31
  readBytes: (maxLength: number) => this.readBytes(maxLength),
20
32
  };
21
33
 
22
- private read(): Promise<string | null> {
34
+ /** Next queued chunk, a waiter's delivery, or null once closed and empty. */
35
+ private pull(): Promise<Uint8Array | null> {
23
36
  if (this.buffer.length > 0) {
24
37
  return Promise.resolve(this.buffer.shift() ?? null);
25
38
  }
26
39
  if (this.closed) {
27
40
  return Promise.resolve(null);
28
41
  }
29
- return new Promise<string | null>((resolve) => {
42
+ return new Promise<Uint8Array | null>((resolve) => {
30
43
  this.waiting.push(resolve);
31
44
  });
32
45
  }
33
46
 
47
+ private async read(): Promise<string | null> {
48
+ while (true) {
49
+ const bytes = await this.pull();
50
+ if (bytes === null) {
51
+ const tail = this.decoder.decode();
52
+ return tail.length > 0 ? tail : null;
53
+ }
54
+ const text = this.decoder.decode(bytes, { stream: true });
55
+ if (text.length > 0) return text;
56
+ }
57
+ }
58
+
34
59
  private async readAll(): Promise<string> {
35
60
  const parts: string[] = [];
36
61
  while (true) {
@@ -43,30 +68,45 @@ export class PipeChannel {
43
68
 
44
69
  private async readLine(): Promise<string | null> {
45
70
  let line = '';
71
+ let sawAny = false;
46
72
  while (true) {
47
- const chunk = await this.read();
48
- if (chunk === null) return line.length > 0 ? line : null;
49
-
50
- const newline = chunk.indexOf('\n');
73
+ const bytes = await this.pull();
74
+ if (bytes === null) break;
75
+ sawAny = true;
76
+ // Split on the raw 0x0A byte so pushback returns ORIGINAL bytes; a
77
+ // multibyte sequence straddling the chunk boundary then survives as
78
+ // é instead of collapsing into a replacement character.
79
+ const newline = bytes.indexOf(0x0a);
51
80
  if (newline >= 0) {
52
- const rest = chunk.slice(newline + 1);
81
+ const rest = bytes.subarray(newline + 1);
53
82
  if (rest.length > 0) this.deliver(rest, 'front');
54
- return line + chunk.slice(0, newline);
83
+ line += this.decoder.decode(bytes.subarray(0, newline), { stream: true });
84
+ const flushed = this.decoder.decode();
85
+ return line + flushed;
55
86
  }
56
-
57
- line += chunk;
87
+ line += this.decoder.decode(bytes, { stream: true });
58
88
  }
89
+ // A trailing incomplete sequence still surfaces as U+FFFD at EOF.
90
+ const tail = this.decoder.decode();
91
+ line += tail;
92
+ return sawAny || tail.length > 0 ? line : null;
59
93
  }
60
94
 
61
- private async readBytes(maxLength: number): Promise<string | null> {
62
- if (maxLength <= 0) return '';
95
+ /**
96
+ * Bounded byte read: returns whatever the producer has already delivered,
97
+ * capped at maxLength. maxLength bounds the result, it is never a fill
98
+ * target — waiting to complete it would stall every consumer downstream of
99
+ * a live open producer. A larger chunk keeps only its first maxLength
100
+ * bytes; the remainder stays queued in original order.
101
+ */
102
+ private async readBytes(maxLength: number): Promise<Uint8Array | null> {
103
+ if (maxLength <= 0) return new Uint8Array(0);
63
104
 
64
- const chunk = await this.read();
105
+ const chunk = await this.pull();
65
106
  if (chunk === null) return null;
66
107
  if (chunk.length <= maxLength) return chunk;
67
-
68
- this.deliver(chunk.slice(maxLength), 'front');
69
- return chunk.slice(0, maxLength);
108
+ this.buffer.unshift(chunk.subarray(maxLength));
109
+ return chunk.subarray(0, maxLength);
70
110
  }
71
111
 
72
112
  close(): void {
@@ -77,16 +117,17 @@ export class PipeChannel {
77
117
  }
78
118
  }
79
119
 
80
- private deliver(text: string, position: 'front' | 'back'): void {
81
- if (text.length === 0) return;
120
+ private deliver(bytes: Uint8Array, position: 'front' | 'back'): void {
121
+ if (bytes.length === 0) return;
82
122
 
83
123
  if (this.waiting.length > 0) {
84
124
  const resolve = this.waiting.shift()!;
85
- resolve(text);
125
+ resolve(bytes);
86
126
  return;
87
127
  }
88
128
 
89
- if (position === 'front') this.buffer.unshift(text);
90
- else this.buffer.push(text);
129
+ if (position === 'front') this.buffer.unshift(bytes);
130
+ else this.buffer.push(bytes);
91
131
  }
132
+
92
133
  }