goke 6.8.0 → 6.9.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.
@@ -389,4 +389,28 @@ describe('createJustBashCommand', () => {
389
389
  exitCode: 7,
390
390
  })
391
391
  })
392
+
393
+ test('truncates captured output to just-bash maxOutputSize and appends a notice', async () => {
394
+ const { Bash } = await import('just-bash')
395
+ const cli = gokeTestable('parent')
396
+
397
+ cli
398
+ .command('spam-stderr', 'Write too much stderr')
399
+ .action((options, { console }) => {
400
+ console.error('x'.repeat(60))
401
+ console.error('y'.repeat(60))
402
+ })
403
+
404
+ const bash = new Bash({
405
+ executionLimits: { maxOutputSize: 80 },
406
+ customCommands: [await cli.createJustBashCommand()],
407
+ })
408
+
409
+ const result = await bash.exec('parent spam-stderr')
410
+
411
+ expect(result.exitCode).toBe(0)
412
+ expect(result.stdout).toBe('')
413
+ expect(result.stderr.length).toBeLessThanOrEqual(80)
414
+ expect(result.stderr).toBe(`${'x'.repeat(60)}\n[output truncated]\n`)
415
+ })
392
416
  })
@@ -481,6 +481,47 @@ describe('type-level: README TypeScript examples', () => {
481
481
  })
482
482
  })
483
483
 
484
+ test('use() with sub-CLI preserves parent middleware typing', () => {
485
+ const sub = goke()
486
+ sub
487
+ .command('deploy', 'Deploy the app')
488
+ .option('--force', z.boolean())
489
+ .action((options) => {
490
+ // Sub-CLI command's action sees its own options
491
+ expectTypeOf(options.force).toEqualTypeOf<boolean | undefined>()
492
+ })
493
+
494
+ goke('test')
495
+ .option('--verbose', z.boolean().default(false).describe('Verbose'))
496
+ .use(sub)
497
+ .use((options) => {
498
+ // Parent middleware still sees parent's accumulated options after .use(subCli)
499
+ expectTypeOf(options.verbose).toEqualTypeOf<boolean>()
500
+ })
501
+ .command('build', 'Build')
502
+ .option('--target <target>', z.string())
503
+ .action((options) => {
504
+ // Parent's inline command still sees global options
505
+ expectTypeOf(options.verbose).toEqualTypeOf<boolean>()
506
+ expectTypeOf(options.target).toEqualTypeOf<string>()
507
+ })
508
+ })
509
+
510
+ test('use() with sub-CLI does not leak sub-CLI types to parent', () => {
511
+ const sub = goke()
512
+ .option('--sub-only <val>', z.string())
513
+ sub.command('sub-cmd', 'Sub command').action(() => {})
514
+
515
+ goke('test')
516
+ .option('--parent-only <val>', z.number())
517
+ .use(sub)
518
+ .use((options) => {
519
+ expectTypeOf(options.parentOnly).toEqualTypeOf<number>()
520
+ // @ts-expect-error subOnly is not declared on the parent
521
+ options.subOnly
522
+ })
523
+ })
524
+
484
525
  test('README global options and middleware example stays typed end-to-end', () => {
485
526
  // `z.boolean().default(false)` and `z.string().default(...)` are
486
527
  // effectively required at runtime: the default applies when the flag is
package/src/goke.ts CHANGED
@@ -1381,14 +1381,48 @@ class Goke<Opts = {}> extends EventEmitter {
1381
1381
  * }
1382
1382
  * })
1383
1383
  * ```
1384
+ *
1385
+ * Alternatively, pass another `Goke` instance to compose commands from
1386
+ * separate files. All commands defined on the sub-CLI are merged into
1387
+ * this CLI. Middlewares and global options from the sub-CLI are NOT
1388
+ * copied; only commands are composed.
1389
+ *
1390
+ * @example
1391
+ * ```ts
1392
+ * // selfhost.ts
1393
+ * export const selfhostCli = goke()
1394
+ * selfhostCli
1395
+ * .command('selfhost', 'Set up on your own workspace')
1396
+ * .option('-t, --token [token]', 'Admin token')
1397
+ * .action((options) => { ... })
1398
+ *
1399
+ * // main.ts
1400
+ * import { selfhostCli } from './selfhost.js'
1401
+ * goke('mycli')
1402
+ * .use(selfhostCli)
1403
+ * .help()
1404
+ * .parse(process.argv)
1405
+ * ```
1384
1406
  */
1407
+ use(subCli: Goke<any>): this
1385
1408
  use(
1386
1409
  callback: (
1387
1410
  options: Opts & DoubleDashOptions,
1388
1411
  context: GokeExecutionContext,
1389
1412
  ) => void | Promise<void>,
1413
+ ): this
1414
+ use(
1415
+ callbackOrCli:
1416
+ | Goke<any>
1417
+ | ((options: Opts & DoubleDashOptions, context: GokeExecutionContext) => void | Promise<void>),
1390
1418
  ): this {
1391
- this.middlewares.push({ action: callback })
1419
+ if (callbackOrCli instanceof Goke) {
1420
+ for (const command of callbackOrCli.commands) {
1421
+ this.commands.push(cloneCommandInto(command, this))
1422
+ }
1423
+ return this
1424
+ }
1425
+ this.middlewares.push({ action: callbackOrCli })
1392
1426
  return this
1393
1427
  }
1394
1428
 
package/src/just-bash.ts CHANGED
@@ -26,17 +26,90 @@ interface JustBashCommand {
26
26
  execute(args: string[], context?: JustBashExecutionContext): Promise<JustBashExecResult>
27
27
  }
28
28
 
29
- type JustBashExecutionContext = Pick<CommandContext, 'cwd' | 'env' | 'fs' | 'stdin'>
30
- type JustBashEncoding = 'utf8' | 'utf-8' | 'ascii' | 'binary' | 'base64' | 'hex' | 'latin1'
29
+ type JustBashExecutionContext = Pick<CommandContext, 'cwd' | 'env' | 'fs' | 'stdin' | 'limits'>
30
+
31
+ const TRUNCATION_MESSAGE = '\n[output truncated]\n'
32
+
33
+ function createTextCaptureStreams(maxLength?: number): {
34
+ stdout: GokeOutputStream
35
+ stderr: GokeOutputStream
36
+ getResult(): { stdout: string; stderr: string }
37
+ } {
38
+ const stdoutChunks: string[] = []
39
+ const stderrChunks: string[] = []
40
+ const limit = maxLength != null && maxLength > 0 ? maxLength : Number.POSITIVE_INFINITY
41
+ let totalLength = 0
42
+ let stdoutTruncated = false
43
+ let stderrTruncated = false
44
+
45
+ const createStream = (stream: 'stdout' | 'stderr'): GokeOutputStream => ({
46
+ write(data: string) {
47
+ if (totalLength >= limit) {
48
+ if (stream === 'stdout') {
49
+ stdoutTruncated = true
50
+ } else {
51
+ stderrTruncated = true
52
+ }
53
+ return
54
+ }
31
55
 
32
- function createTextCaptureStream(): GokeOutputStream & { readonly text: string } {
33
- const chunks: string[] = []
34
- return {
35
- get text() {
36
- return chunks.join('')
56
+ const remaining = limit - totalLength
57
+ const text = data.length <= remaining ? data : data.slice(0, remaining)
58
+ if (stream === 'stdout') {
59
+ stdoutChunks.push(text)
60
+ stdoutTruncated ||= text.length !== data.length
61
+ } else {
62
+ stderrChunks.push(text)
63
+ stderrTruncated ||= text.length !== data.length
64
+ }
65
+ totalLength += text.length
37
66
  },
38
- write(data: string) {
39
- chunks.push(data)
67
+ })
68
+
69
+ const trimEnd = (value: string, count: number) => value.slice(0, Math.max(0, value.length - count))
70
+
71
+ return {
72
+ stdout: createStream('stdout'),
73
+ stderr: createStream('stderr'),
74
+ getResult() {
75
+ let stdout = stdoutChunks.join('')
76
+ let stderr = stderrChunks.join('')
77
+
78
+ if (!stdoutTruncated && !stderrTruncated) {
79
+ return { stdout, stderr }
80
+ }
81
+
82
+ const target = stderrTruncated ? 'stderr' : 'stdout'
83
+ const message = limit === Number.POSITIVE_INFINITY
84
+ ? TRUNCATION_MESSAGE
85
+ : TRUNCATION_MESSAGE.slice(0, Math.min(TRUNCATION_MESSAGE.length, limit))
86
+
87
+ let overflow = stdout.length + stderr.length + message.length - limit
88
+ if (Number.isFinite(limit) && overflow > 0) {
89
+ if (target === 'stderr') {
90
+ const stderrTrim = Math.min(overflow, stderr.length)
91
+ stderr = trimEnd(stderr, stderrTrim)
92
+ overflow -= stderrTrim
93
+ if (overflow > 0) {
94
+ stdout = trimEnd(stdout, overflow)
95
+ }
96
+ } else {
97
+ const stdoutTrim = Math.min(overflow, stdout.length)
98
+ stdout = trimEnd(stdout, stdoutTrim)
99
+ overflow -= stdoutTrim
100
+ if (overflow > 0) {
101
+ stderr = trimEnd(stderr, overflow)
102
+ }
103
+ }
104
+ }
105
+
106
+ if (target === 'stderr') {
107
+ stderr += message
108
+ } else {
109
+ stdout += message
110
+ }
111
+
112
+ return { stdout, stderr }
40
113
  },
41
114
  }
42
115
  }
@@ -55,7 +128,7 @@ const getEncoding = (options?: GokeFsEncodingOption) => {
55
128
  return options.encoding
56
129
  }
57
130
 
58
- const toJustBashEncoding = (encoding?: BufferEncoding | null): JustBashEncoding | null | undefined => {
131
+ const toJustBashEncoding = (encoding?: BufferEncoding | null): 'utf8' | 'utf-8' | 'ascii' | 'binary' | 'base64' | 'hex' | 'latin1' | null | undefined => {
59
132
  if (encoding == null) {
60
133
  return encoding
61
134
  }
@@ -232,16 +305,15 @@ export function createJustBashCommand(
232
305
  name,
233
306
  trusted: true,
234
307
  async execute(args: string[], context?: JustBashExecutionContext) {
235
- const stdout = createTextCaptureStream()
236
- const stderr = createTextCaptureStream()
308
+ const output = createTextCaptureStreams(context?.limits?.maxOutputSize)
237
309
  const argv = ['node', name, ...args]
238
310
  const cloned = cli.clone({
239
311
  cwd: context?.cwd,
240
312
  env: context ? createJustBashEnvProxy(context.env) : cli.env,
241
313
  fs: context ? createJustBashFs(context.fs, context.cwd) : cli.fs,
242
314
  stdin: context?.stdin,
243
- stdout,
244
- stderr,
315
+ stdout: output.stdout,
316
+ stderr: output.stderr,
245
317
  argv,
246
318
  exit: (code) => {
247
319
  throw new GokeProcessExit(code)
@@ -253,16 +325,18 @@ export function createJustBashCommand(
253
325
  try {
254
326
  cloned.parse(argv, { run: false })
255
327
  await cloned.runMatchedCommand()
328
+ const result = output.getResult()
256
329
  return {
257
- stdout: stdout.text,
258
- stderr: stderr.text,
330
+ stdout: result.stdout,
331
+ stderr: result.stderr,
259
332
  exitCode: 0,
260
333
  }
261
334
  } catch (error) {
262
335
  if (error instanceof GokeProcessExit) {
336
+ const result = output.getResult()
263
337
  return {
264
- stdout: stdout.text,
265
- stderr: stderr.text,
338
+ stdout: result.stdout,
339
+ stderr: result.stderr,
266
340
  exitCode: error.code,
267
341
  }
268
342
  }