@smthrs/std 1.0.0-rc.6 → 1.0.0-rc.8

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 (57) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +39 -37
  3. package/dist/cjs/Bash.d.ts +10 -0
  4. package/dist/cjs/Bash.d.ts.map +1 -1
  5. package/dist/cjs/Bash.js +13 -12
  6. package/dist/cjs/Bash.js.map +3 -3
  7. package/dist/cjs/Checkpoints.d.ts +37 -0
  8. package/dist/cjs/Checkpoints.d.ts.map +1 -1
  9. package/dist/cjs/Checkpoints.js +75 -0
  10. package/dist/cjs/Checkpoints.js.map +2 -2
  11. package/dist/cjs/internal/EnvelopePrecheck.d.ts +2 -0
  12. package/dist/cjs/internal/EnvelopePrecheck.d.ts.map +1 -1
  13. package/dist/cjs/internal/EnvelopePrecheck.js +217 -34
  14. package/dist/cjs/internal/EnvelopePrecheck.js.map +3 -3
  15. package/dist/cjs/internal/Exec.d.ts +26 -14
  16. package/dist/cjs/internal/Exec.d.ts.map +1 -1
  17. package/dist/cjs/internal/Exec.js +73 -31
  18. package/dist/cjs/internal/Exec.js.map +2 -2
  19. package/dist/cjs/internal/Text.d.ts +11 -0
  20. package/dist/cjs/internal/Text.d.ts.map +1 -1
  21. package/dist/cjs/internal/Text.js +3 -0
  22. package/dist/cjs/internal/Text.js.map +2 -2
  23. package/dist/esm/Bash.d.ts +10 -0
  24. package/dist/esm/Bash.d.ts.map +1 -1
  25. package/dist/esm/Bash.js +22 -13
  26. package/dist/esm/Bash.js.map +1 -1
  27. package/dist/esm/Checkpoints.d.ts +37 -0
  28. package/dist/esm/Checkpoints.d.ts.map +1 -1
  29. package/dist/esm/Checkpoints.js +85 -0
  30. package/dist/esm/Checkpoints.js.map +1 -1
  31. package/dist/esm/internal/EnvelopePrecheck.d.ts +2 -0
  32. package/dist/esm/internal/EnvelopePrecheck.d.ts.map +1 -1
  33. package/dist/esm/internal/EnvelopePrecheck.js +295 -60
  34. package/dist/esm/internal/EnvelopePrecheck.js.map +1 -1
  35. package/dist/esm/internal/Exec.d.ts +26 -14
  36. package/dist/esm/internal/Exec.d.ts.map +1 -1
  37. package/dist/esm/internal/Exec.js +116 -30
  38. package/dist/esm/internal/Exec.js.map +1 -1
  39. package/dist/esm/internal/Text.d.ts +11 -0
  40. package/dist/esm/internal/Text.d.ts.map +1 -1
  41. package/dist/esm/internal/Text.js +11 -0
  42. package/dist/esm/internal/Text.js.map +1 -1
  43. package/docs/README.md +3 -1
  44. package/docs/api.md +21 -18
  45. package/docs/concepts/limits-and-disclosure.md +8 -5
  46. package/docs/guides/pin-a-checkpoint.md +20 -0
  47. package/docs/guides/run-a-shell-command.md +16 -6
  48. package/docs/installation.md +13 -9
  49. package/docs/quickstart.md +1 -1
  50. package/docs/reference/flows.md +2 -2
  51. package/docs/troubleshooting.md +15 -3
  52. package/package.json +6 -6
  53. package/src/Bash.ts +25 -13
  54. package/src/Checkpoints.ts +137 -0
  55. package/src/internal/EnvelopePrecheck.ts +295 -63
  56. package/src/internal/Exec.ts +157 -51
  57. package/src/internal/Text.ts +15 -0
@@ -21,6 +21,7 @@ import * as ChildProcess from "effect/process/ChildProcess"
21
21
  import * as Schema from "effect/Schema"
22
22
  import * as Stream from "effect/Stream"
23
23
  import * as StdError from "../StdError.ts"
24
+ import { elideMiddle } from "./Text.ts"
24
25
 
25
26
  /**
26
27
  * Failure codes a buffered execution can report, matching the codes the
@@ -29,7 +30,7 @@ import * as StdError from "../StdError.ts"
29
30
  * @category models
30
31
  * @since 1.0.0
31
32
  */
32
- export const ExecErrorCode = Schema.Literals(["timeout", "spawn_error", "capture_overflow"])
33
+ export const ExecErrorCode = Schema.Literals(["timeout", "spawn_error", "arguments_too_long", "capture_overflow"])
33
34
 
34
35
  /**
35
36
  * Failure codes a buffered execution can report.
@@ -43,8 +44,10 @@ export type ExecErrorCode = typeof ExecErrorCode.Type
43
44
  * A command that never produced an exit code.
44
45
  *
45
46
  * A non-zero exit is a successful {@link ExecResult}, not a failure; this
46
- * error is reserved for a command that could not start or was cut off by its
47
- * timeout, or whose captured output exceeded a refusing capture bound.
47
+ * error is reserved for a command that could not start (`arguments_too_long`
48
+ * when the operating system refused it for the size of its arguments) or was
49
+ * cut off by its timeout, or whose captured output exceeded a refusing capture
50
+ * bound.
48
51
  *
49
52
  * @category errors
50
53
  * @since 1.0.0
@@ -66,9 +69,9 @@ export interface ExecResult {
66
69
  readonly stdout: string
67
70
  readonly stderr: string
68
71
  readonly exitCode: number
69
- /** UTF-8 bytes dropped from the head of stdout to stay inside the capture bound. */
72
+ /** UTF-8 bytes of stdout dropped to stay inside the capture bound; see `overflow`. */
70
73
  readonly stdoutDroppedBytes: number
71
- /** UTF-8 bytes dropped from the head of stderr to stay inside the capture bound. */
74
+ /** UTF-8 bytes of stderr dropped to stay inside the capture bound; see `overflow`. */
72
75
  readonly stderrDroppedBytes: number
73
76
  }
74
77
 
@@ -97,18 +100,26 @@ export interface ExecOptions {
97
100
  */
98
101
  readonly stdin?: string | undefined
99
102
  /**
100
- * Bytes retained per captured stream. A stream that prints more keeps its
101
- * tail and reports what it dropped, so a command printing gigabytes costs a
102
- * bounded amount of memory rather than the whole of what it printed.
103
+ * Bytes retained per captured stream. A stream that prints more keeps what
104
+ * `overflow` names and reports what it dropped, so a command printing
105
+ * gigabytes costs a bounded amount of memory rather than the whole of what it
106
+ * printed.
103
107
  *
104
108
  * Absent means unbounded, which is what a caller wants when it needs every
105
109
  * byte a tool produced (`rg --files`, `git worktree list`).
106
110
  */
107
111
  readonly maxCaptureBytes?: number | undefined
108
- /** Defaults to `tail`; `refuse` fails if either stream exceeds the bound. */
109
- readonly overflow?: "tail" | "refuse" | undefined
112
+ /**
113
+ * What a stream past the bound keeps. `tail`, the default, keeps its last
114
+ * bytes. `ends` keeps its first and last halves and puts one line naming the
115
+ * dropped bytes where the middle was. `refuse` fails if either stream exceeds
116
+ * the bound.
117
+ */
118
+ readonly overflow?: Overflow | undefined
110
119
  }
111
120
 
121
+ type Overflow = "tail" | "ends" | "refuse"
122
+
112
123
  /** One captured stream: the text kept, and the bytes dropped to keep it bounded. */
113
124
  interface Capture {
114
125
  readonly text: string
@@ -121,72 +132,162 @@ interface Tail {
121
132
  dropped: number
122
133
  }
123
134
 
135
+ /** A stream's first bytes and its last ones: what `overflow: "ends"` keeps. */
136
+ interface Ends {
137
+ readonly head: Array<Uint8Array>
138
+ headBytes: number
139
+ readonly tail: Tail
140
+ }
141
+
124
142
  const decoder = new TextDecoder("utf-8")
125
143
 
144
+ const emptyTail = (): Tail => ({ chunks: [], bytes: 0, dropped: 0 })
145
+
146
+ /** A UTF-8 continuation byte is `10xxxxxx`; every other byte starts a character. */
147
+ const isContinuation = (byte: number): boolean => (byte & 0b1100_0000) === 0b1000_0000
148
+
149
+ const join = (chunks: ReadonlyArray<Uint8Array>, size: number): Uint8Array => {
150
+ const bytes = new Uint8Array(size)
151
+ let offset = 0
152
+ for (const chunk of chunks) {
153
+ bytes.set(chunk, offset)
154
+ offset += chunk.byteLength
155
+ }
156
+ return bytes
157
+ }
158
+
159
+ /** Appends one chunk and drops from the front whatever no longer fits. */
160
+ const keepTail = (state: Tail, chunk: Uint8Array, maxBytes: number): void => {
161
+ if (chunk.byteLength === 0) return
162
+ state.chunks.push(chunk)
163
+ state.bytes += chunk.byteLength
164
+ while (state.bytes > maxBytes && state.chunks.length > 0) {
165
+ const excess = state.bytes - maxBytes
166
+ const first = state.chunks[0]!
167
+ if (first.byteLength <= excess) {
168
+ state.chunks.shift()
169
+ state.bytes -= first.byteLength
170
+ state.dropped += first.byteLength
171
+ } else {
172
+ state.chunks[0] = first.subarray(excess)
173
+ state.bytes -= excess
174
+ state.dropped += excess
175
+ }
176
+ }
177
+ }
178
+
126
179
  /**
127
180
  * Joins the retained chunks and decodes them, skipping the partial code point a
128
181
  * dropped head can leave behind.
129
182
  *
130
- * A UTF-8 continuation byte is `10xxxxxx`, and a code point is at most four
131
- * bytes, so at most three leading continuation bytes can precede the first
132
- * whole character. Skipping them is counted as dropped rather than decoded to a
133
- * replacement character the process never printed.
183
+ * A code point is at most four bytes, so at most three leading continuation
184
+ * bytes can precede the first whole character. Skipping them is counted as
185
+ * dropped rather than decoded to a replacement character the process never
186
+ * printed.
134
187
  */
135
188
  const decodeTail = (state: Tail): Capture => {
136
- const bytes = new Uint8Array(state.bytes)
137
- let offset = 0
138
- for (const chunk of state.chunks) {
139
- bytes.set(chunk, offset)
140
- offset += chunk.byteLength
141
- }
189
+ const bytes = join(state.chunks, state.bytes)
142
190
  if (state.dropped === 0) return { text: decoder.decode(bytes), droppedBytes: 0 }
143
191
  let start = 0
144
- while (start < bytes.byteLength && start < 3 && (bytes[start]! & 0b1100_0000) === 0b1000_0000) start++
192
+ while (start < bytes.byteLength && start < 3 && isContinuation(bytes[start]!)) start++
145
193
  return { text: decoder.decode(bytes.subarray(start)), droppedBytes: state.dropped + start }
146
194
  }
147
195
 
196
+ /**
197
+ * The length of a cut head without the character the cut left unfinished.
198
+ *
199
+ * Its last character starts at the last byte that is not a continuation byte,
200
+ * and that byte says how many bytes the character needs. One that needs more
201
+ * than the head holds is dropped, for the same reason {@link decodeTail} skips
202
+ * a partial code point.
203
+ */
204
+ const wholeLength = (bytes: Uint8Array): number => {
205
+ for (let back = 1; back <= Math.min(3, bytes.byteLength); back++) {
206
+ const byte = bytes[bytes.byteLength - back]!
207
+ if (isContinuation(byte)) continue
208
+ const width = byte >= 0b1111_0000 ? 4 : byte >= 0b1110_0000 ? 3 : byte >= 0b1100_0000 ? 2 : 1
209
+ return width > back ? bytes.byteLength - back : bytes.byteLength
210
+ }
211
+ return bytes.byteLength
212
+ }
213
+
214
+ /**
215
+ * Decodes a stream kept by its ends. A stream that fit is decoded whole;
216
+ * otherwise each end is cut on a character boundary and one line naming every
217
+ * byte between them stands where the middle was.
218
+ */
219
+ const decodeEnds = (state: Ends): Capture => {
220
+ if (state.tail.dropped === 0) {
221
+ const whole = join([...state.head, ...state.tail.chunks], state.headBytes + state.tail.bytes)
222
+ return { text: decoder.decode(whole), droppedBytes: 0 }
223
+ }
224
+ const head = join(state.head, state.headBytes)
225
+ const kept = wholeLength(head)
226
+ const tail = decodeTail(state.tail)
227
+ const droppedBytes = head.byteLength - kept + tail.droppedBytes
228
+ return { text: elideMiddle(decoder.decode(head.subarray(0, kept)), droppedBytes, tail.text), droppedBytes }
229
+ }
230
+
148
231
  const boundedTail = <E>(
149
232
  stream: Stream.Stream<Uint8Array, E>,
150
233
  maxBytes: number,
151
- overflow: "tail" | "refuse" | undefined
234
+ overflow: Overflow | undefined
152
235
  ): Effect.Effect<Capture, E> =>
153
- Stream.runFold(
154
- stream,
155
- (): Tail => ({ chunks: [], bytes: 0, dropped: 0 }),
156
- (state, chunk) => {
157
- if (chunk.byteLength === 0) return state
158
- state.chunks.push(chunk)
159
- state.bytes += chunk.byteLength
160
- while (state.bytes > maxBytes && state.chunks.length > 0) {
161
- const excess = state.bytes - maxBytes
162
- const first = state.chunks[0]!
163
- if (first.byteLength <= excess) {
164
- state.chunks.shift()
165
- state.bytes -= first.byteLength
166
- state.dropped += first.byteLength
167
- } else {
168
- state.chunks[0] = first.subarray(excess)
169
- state.bytes -= excess
170
- state.dropped += excess
171
- }
172
- }
173
- return state
174
- }
175
- ).pipe(Effect.map((state) =>
236
+ Stream.runFold(stream, emptyTail, (state, chunk) => {
237
+ keepTail(state, chunk, maxBytes)
238
+ return state
239
+ }).pipe(Effect.map((state) =>
176
240
  overflow === "refuse" && state.dropped > 0
177
241
  ? { text: "", droppedBytes: state.dropped }
178
242
  : decodeTail(state)
179
243
  ))
180
244
 
245
+ /**
246
+ * Keeps the first half of the bound and the last half. The head fills first,
247
+ * and every byte after it passes through the tail, which drops from its front:
248
+ * what falls out of the tail is the middle.
249
+ */
250
+ const boundedEnds = <E>(stream: Stream.Stream<Uint8Array, E>, maxBytes: number): Effect.Effect<Capture, E> => {
251
+ const headBytes = Math.floor(maxBytes / 2)
252
+ return Stream.runFold(stream, (): Ends => ({ head: [], headBytes: 0, tail: emptyTail() }), (state, chunk) => {
253
+ const taken = Math.min(headBytes - state.headBytes, chunk.byteLength)
254
+ if (taken > 0) {
255
+ state.head.push(chunk.subarray(0, taken))
256
+ state.headBytes += taken
257
+ }
258
+ keepTail(state.tail, chunk.subarray(taken), maxBytes - headBytes)
259
+ return state
260
+ }).pipe(Effect.map(decodeEnds))
261
+ }
262
+
181
263
  const capture = <E>(
182
264
  stream: Stream.Stream<Uint8Array, E>,
183
265
  maxBytes: number | undefined,
184
- overflow: "tail" | "refuse" | undefined
266
+ overflow: Overflow | undefined
185
267
  ): Effect.Effect<Capture, E> =>
186
268
  maxBytes === undefined
187
269
  ? Effect.map(Stream.mkString(Stream.decodeText(stream)), (text) => ({ text, droppedBytes: 0 }))
270
+ : overflow === "ends"
271
+ ? boundedEnds(stream, maxBytes)
188
272
  : boundedTail(stream, maxBytes, overflow)
189
273
 
274
+ /**
275
+ * A command whose arguments exceed the operating system's limit fails `execve`
276
+ * with `E2BIG` before it starts: one argument over 128 KiB on Linux, all of
277
+ * them over 1 MiB on macOS. A heredoc in a command line, or a file's text
278
+ * passed as an argument, is one such argument. The platform error's own text
279
+ * repeats the whole command line, so this text is written here instead, and a
280
+ * caller may show it as it stands.
281
+ */
282
+ const argumentsTooLong = (error: { readonly cause?: unknown }): ExecError | undefined =>
283
+ (error.cause as { readonly code?: unknown } | undefined)?.code === "E2BIG"
284
+ ? new ExecError({
285
+ code: "arguments_too_long",
286
+ message:
287
+ "Command not started: its arguments exceed the operating system's size limit (E2BIG). Send long text on standard input or in a file, not in the command line or its arguments."
288
+ })
289
+ : undefined
290
+
190
291
  const unbounded = (
191
292
  command: string,
192
293
  options: ExecOptions
@@ -245,7 +346,7 @@ const unbounded = (
245
346
  }).pipe(
246
347
  Effect.scoped,
247
348
  Effect.mapError((error) =>
248
- error instanceof ExecError ? error : new ExecError({
349
+ error instanceof ExecError ? error : argumentsTooLong(error) ?? new ExecError({
249
350
  code: "spawn_error",
250
351
  message: `exec: ${command}: ${error.message}`
251
352
  })
@@ -286,20 +387,23 @@ export const exec = (
286
387
  *
287
388
  * A timeout keeps its own code, because "the command never finished" and "the
288
389
  * command never started" are different things to a caller deciding whether to
289
- * retry. Every other way of producing no exit code is a host failure.
390
+ * retry. Arguments the operating system refused for their size are the
391
+ * caller's own input, which a shell flow publishes, so they read as
392
+ * `invalid_input`. Every other way of producing no exit code is a host failure.
290
393
  *
291
394
  * @category errors
292
395
  * @since 1.0.0
293
396
  */
294
397
  export const toStdErrorCode = (error: ExecError): StdError.Code =>
295
- error.code === "timeout" ? "timeout" : "command_failed"
398
+ error.code === "timeout" ? "timeout" : error.code === "arguments_too_long" ? "invalid_input" : "command_failed"
296
399
 
297
400
  /**
298
401
  * The model-facing failure one execution error becomes.
299
402
  *
300
403
  * A timeout names the command, which is what the caller asked for and all a
301
- * timeout has to say. Any other failure carries the message the execution
302
- * produced, which already names the command.
404
+ * timeout has to say. Arguments too long to start carry the reason and the
405
+ * fix, and nothing of the command. Any other failure carries the message the
406
+ * execution produced, which already names the command.
303
407
  *
304
408
  * @category errors
305
409
  * @since 1.0.0
@@ -309,6 +413,8 @@ export const toStdError = (command: string, error: ExecError): StdError.StdError
309
413
  code: toStdErrorCode(error),
310
414
  message: error.code === "timeout"
311
415
  ? `Command timed out: ${command}`
416
+ : error.code === "arguments_too_long"
417
+ ? error.message
312
418
  : `Command failed to start: ${error.message}`,
313
419
  ...(error.limitMillis === undefined ? {} : { limitMillis: error.limitMillis })
314
420
  })
@@ -124,6 +124,21 @@ export const truncateBytes = (
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Joins the start and the end of a stream cut in the middle around one line
129
+ * naming the bytes cut between them.
130
+ *
131
+ * The line stands alone, so a start that stops mid-line is ended before it.
132
+ * Only the line's own newlines are added; `droppedBytes` counts printed bytes.
133
+ *
134
+ * @category rendering
135
+ * @since 1.0.0-rc.5
136
+ */
137
+ export const elideMiddle = (start: string, droppedBytes: number, end: string): string =>
138
+ `${start}${start === "" || start.endsWith("\n") ? "" : "\n"}[${droppedBytes} ${
139
+ droppedBytes === 1 ? "byte" : "bytes"
140
+ } omitted]\n${end}`
141
+
127
142
  /**
128
143
  * Splits on LF without counting a terminal LF as a line.
129
144
  * Carriage returns remain part of each line, including the CR in CRLF, so