@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.
- package/CHANGELOG.md +4 -0
- package/README.md +39 -37
- package/dist/cjs/Bash.d.ts +10 -0
- package/dist/cjs/Bash.d.ts.map +1 -1
- package/dist/cjs/Bash.js +13 -12
- package/dist/cjs/Bash.js.map +3 -3
- package/dist/cjs/Checkpoints.d.ts +37 -0
- package/dist/cjs/Checkpoints.d.ts.map +1 -1
- package/dist/cjs/Checkpoints.js +75 -0
- package/dist/cjs/Checkpoints.js.map +2 -2
- package/dist/cjs/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/cjs/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/cjs/internal/EnvelopePrecheck.js +217 -34
- package/dist/cjs/internal/EnvelopePrecheck.js.map +3 -3
- package/dist/cjs/internal/Exec.d.ts +26 -14
- package/dist/cjs/internal/Exec.d.ts.map +1 -1
- package/dist/cjs/internal/Exec.js +73 -31
- package/dist/cjs/internal/Exec.js.map +2 -2
- package/dist/cjs/internal/Text.d.ts +11 -0
- package/dist/cjs/internal/Text.d.ts.map +1 -1
- package/dist/cjs/internal/Text.js +3 -0
- package/dist/cjs/internal/Text.js.map +2 -2
- package/dist/esm/Bash.d.ts +10 -0
- package/dist/esm/Bash.d.ts.map +1 -1
- package/dist/esm/Bash.js +22 -13
- package/dist/esm/Bash.js.map +1 -1
- package/dist/esm/Checkpoints.d.ts +37 -0
- package/dist/esm/Checkpoints.d.ts.map +1 -1
- package/dist/esm/Checkpoints.js +85 -0
- package/dist/esm/Checkpoints.js.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.d.ts +2 -0
- package/dist/esm/internal/EnvelopePrecheck.d.ts.map +1 -1
- package/dist/esm/internal/EnvelopePrecheck.js +295 -60
- package/dist/esm/internal/EnvelopePrecheck.js.map +1 -1
- package/dist/esm/internal/Exec.d.ts +26 -14
- package/dist/esm/internal/Exec.d.ts.map +1 -1
- package/dist/esm/internal/Exec.js +116 -30
- package/dist/esm/internal/Exec.js.map +1 -1
- package/dist/esm/internal/Text.d.ts +11 -0
- package/dist/esm/internal/Text.d.ts.map +1 -1
- package/dist/esm/internal/Text.js +11 -0
- package/dist/esm/internal/Text.js.map +1 -1
- package/docs/README.md +3 -1
- package/docs/api.md +21 -18
- package/docs/concepts/limits-and-disclosure.md +8 -5
- package/docs/guides/pin-a-checkpoint.md +20 -0
- package/docs/guides/run-a-shell-command.md +16 -6
- package/docs/installation.md +13 -9
- package/docs/quickstart.md +1 -1
- package/docs/reference/flows.md +2 -2
- package/docs/troubleshooting.md +15 -3
- package/package.json +6 -6
- package/src/Bash.ts +25 -13
- package/src/Checkpoints.ts +137 -0
- package/src/internal/EnvelopePrecheck.ts +295 -63
- package/src/internal/Exec.ts +157 -51
- package/src/internal/Text.ts +15 -0
package/src/internal/Exec.ts
CHANGED
|
@@ -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
|
|
47
|
-
*
|
|
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
|
|
72
|
+
/** UTF-8 bytes of stdout dropped to stay inside the capture bound; see `overflow`. */
|
|
70
73
|
readonly stdoutDroppedBytes: number
|
|
71
|
-
/** UTF-8 bytes
|
|
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
|
|
101
|
-
*
|
|
102
|
-
* bounded amount of memory rather than the whole of what it
|
|
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
|
-
/**
|
|
109
|
-
|
|
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
|
|
131
|
-
* bytes
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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 =
|
|
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]!
|
|
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:
|
|
234
|
+
overflow: Overflow | undefined
|
|
152
235
|
): Effect.Effect<Capture, E> =>
|
|
153
|
-
Stream.runFold(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
302
|
-
*
|
|
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
|
})
|
package/src/internal/Text.ts
CHANGED
|
@@ -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
|