goke 6.7.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.
package/dist/just-bash.js CHANGED
@@ -8,14 +8,78 @@
8
8
  import { Buffer } from 'node:buffer';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { GokeProcessExit } from './goke.js';
11
- function createTextCaptureStream() {
12
- const chunks = [];
13
- return {
14
- get text() {
15
- return chunks.join('');
16
- },
11
+ const TRUNCATION_MESSAGE = '\n[output truncated]\n';
12
+ function createTextCaptureStreams(maxLength) {
13
+ const stdoutChunks = [];
14
+ const stderrChunks = [];
15
+ const limit = maxLength != null && maxLength > 0 ? maxLength : Number.POSITIVE_INFINITY;
16
+ let totalLength = 0;
17
+ let stdoutTruncated = false;
18
+ let stderrTruncated = false;
19
+ const createStream = (stream) => ({
17
20
  write(data) {
18
- chunks.push(data);
21
+ if (totalLength >= limit) {
22
+ if (stream === 'stdout') {
23
+ stdoutTruncated = true;
24
+ }
25
+ else {
26
+ stderrTruncated = true;
27
+ }
28
+ return;
29
+ }
30
+ const remaining = limit - totalLength;
31
+ const text = data.length <= remaining ? data : data.slice(0, remaining);
32
+ if (stream === 'stdout') {
33
+ stdoutChunks.push(text);
34
+ stdoutTruncated ||= text.length !== data.length;
35
+ }
36
+ else {
37
+ stderrChunks.push(text);
38
+ stderrTruncated ||= text.length !== data.length;
39
+ }
40
+ totalLength += text.length;
41
+ },
42
+ });
43
+ const trimEnd = (value, count) => value.slice(0, Math.max(0, value.length - count));
44
+ return {
45
+ stdout: createStream('stdout'),
46
+ stderr: createStream('stderr'),
47
+ getResult() {
48
+ let stdout = stdoutChunks.join('');
49
+ let stderr = stderrChunks.join('');
50
+ if (!stdoutTruncated && !stderrTruncated) {
51
+ return { stdout, stderr };
52
+ }
53
+ const target = stderrTruncated ? 'stderr' : 'stdout';
54
+ const message = limit === Number.POSITIVE_INFINITY
55
+ ? TRUNCATION_MESSAGE
56
+ : TRUNCATION_MESSAGE.slice(0, Math.min(TRUNCATION_MESSAGE.length, limit));
57
+ let overflow = stdout.length + stderr.length + message.length - limit;
58
+ if (Number.isFinite(limit) && overflow > 0) {
59
+ if (target === 'stderr') {
60
+ const stderrTrim = Math.min(overflow, stderr.length);
61
+ stderr = trimEnd(stderr, stderrTrim);
62
+ overflow -= stderrTrim;
63
+ if (overflow > 0) {
64
+ stdout = trimEnd(stdout, overflow);
65
+ }
66
+ }
67
+ else {
68
+ const stdoutTrim = Math.min(overflow, stdout.length);
69
+ stdout = trimEnd(stdout, stdoutTrim);
70
+ overflow -= stdoutTrim;
71
+ if (overflow > 0) {
72
+ stderr = trimEnd(stderr, overflow);
73
+ }
74
+ }
75
+ }
76
+ if (target === 'stderr') {
77
+ stderr += message;
78
+ }
79
+ else {
80
+ stdout += message;
81
+ }
82
+ return { stdout, stderr };
19
83
  },
20
84
  };
21
85
  }
@@ -187,16 +251,15 @@ export function createJustBashCommand(cli, options) {
187
251
  name,
188
252
  trusted: true,
189
253
  async execute(args, context) {
190
- const stdout = createTextCaptureStream();
191
- const stderr = createTextCaptureStream();
254
+ const output = createTextCaptureStreams(context?.limits?.maxOutputSize);
192
255
  const argv = ['node', name, ...args];
193
256
  const cloned = cli.clone({
194
257
  cwd: context?.cwd,
195
258
  env: context ? createJustBashEnvProxy(context.env) : cli.env,
196
259
  fs: context ? createJustBashFs(context.fs, context.cwd) : cli.fs,
197
260
  stdin: context?.stdin,
198
- stdout,
199
- stderr,
261
+ stdout: output.stdout,
262
+ stderr: output.stderr,
200
263
  argv,
201
264
  exit: (code) => {
202
265
  throw new GokeProcessExit(code);
@@ -206,17 +269,19 @@ export function createJustBashCommand(cli, options) {
206
269
  try {
207
270
  cloned.parse(argv, { run: false });
208
271
  await cloned.runMatchedCommand();
272
+ const result = output.getResult();
209
273
  return {
210
- stdout: stdout.text,
211
- stderr: stderr.text,
274
+ stdout: result.stdout,
275
+ stderr: result.stderr,
212
276
  exitCode: 0,
213
277
  };
214
278
  }
215
279
  catch (error) {
216
280
  if (error instanceof GokeProcessExit) {
281
+ const result = output.getResult();
217
282
  return {
218
- stdout: stdout.text,
219
- stderr: stderr.text,
283
+ stdout: result.stdout,
284
+ stderr: result.stderr,
220
285
  exitCode: error.code,
221
286
  };
222
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goke",
3
- "version": "6.7.0",
3
+ "version": "6.9.0",
4
4
  "type": "module",
5
5
  "description": "Simple yet powerful framework for building command-line apps. Inspired by cac.",
6
6
  "repository": {
@@ -2336,3 +2336,169 @@ describe('middleware', () => {
2336
2336
  expect(order).toEqual(['sync1', 'async', 'sync2', 'action'])
2337
2337
  })
2338
2338
  })
2339
+
2340
+ describe('use() with sub-CLI composition', () => {
2341
+ test('basic composition: sub-CLI command runs via parent', () => {
2342
+ const parent = goke('mycli')
2343
+ const sub = goke()
2344
+ let matched = ''
2345
+
2346
+ sub
2347
+ .command('deploy', 'Deploy the app')
2348
+ .action(() => { matched = 'deploy' })
2349
+
2350
+ parent.use(sub)
2351
+ parent.parse(['node', 'bin', 'deploy'], { run: true })
2352
+ expect(matched).toBe('deploy')
2353
+ })
2354
+
2355
+ test('multiple sub-CLIs composed together', () => {
2356
+ const parent = goke('mycli')
2357
+ const subA = goke()
2358
+ const subB = goke()
2359
+ let matched = ''
2360
+
2361
+ subA.command('login', 'Login').action(() => { matched = 'login' })
2362
+ subB.command('deploy', 'Deploy').action(() => { matched = 'deploy' })
2363
+
2364
+ parent.use(subA).use(subB)
2365
+
2366
+ parent.parse(['node', 'bin', 'login'], { run: true })
2367
+ expect(matched).toBe('login')
2368
+
2369
+ matched = ''
2370
+ parent.parse(['node', 'bin', 'deploy'], { run: true })
2371
+ expect(matched).toBe('deploy')
2372
+ })
2373
+
2374
+ test('sub-CLI command with options and schema coercion', () => {
2375
+ const parent = goke('mycli')
2376
+ const sub = goke()
2377
+ let result: any = {}
2378
+
2379
+ sub
2380
+ .command('serve', 'Start server')
2381
+ .option('--port <port>', z.number().describe('Port'))
2382
+ .option('--host <host>', z.string().describe('Host'))
2383
+ .action((options) => { result = options })
2384
+
2385
+ parent.use(sub)
2386
+ parent.parse('node bin serve --port 3000 --host localhost'.split(' '), { run: true })
2387
+
2388
+ expect(result.port).toBe(3000)
2389
+ expect(typeof result.port).toBe('number')
2390
+ expect(result.host).toBe('localhost')
2391
+ })
2392
+
2393
+ test('sub-CLI command with positional args', () => {
2394
+ const parent = goke('mycli')
2395
+ const sub = goke()
2396
+ let receivedId = ''
2397
+
2398
+ sub
2399
+ .command('get <id>', 'Get a resource')
2400
+ .action((id) => { receivedId = id })
2401
+
2402
+ parent.use(sub)
2403
+ parent.parse(['node', 'bin', 'get', 'abc123'], { run: true })
2404
+
2405
+ expect(receivedId).toBe('abc123')
2406
+ })
2407
+
2408
+ test('sub-CLI with multi-word commands', () => {
2409
+ const parent = goke('mycli')
2410
+ const sub = goke()
2411
+ let matched = ''
2412
+
2413
+ sub.command('mcp login', 'Login to MCP').action(() => { matched = 'mcp login' })
2414
+ sub.command('mcp logout', 'Logout from MCP').action(() => { matched = 'mcp logout' })
2415
+
2416
+ parent.use(sub)
2417
+
2418
+ parent.parse(['node', 'bin', 'mcp', 'login'], { run: true })
2419
+ expect(matched).toBe('mcp login')
2420
+
2421
+ matched = ''
2422
+ parent.parse(['node', 'bin', 'mcp', 'logout'], { run: true })
2423
+ expect(matched).toBe('mcp logout')
2424
+ })
2425
+
2426
+ test('help output includes composed commands', () => {
2427
+ const stdout = createTestOutputStream()
2428
+ const parent = goke('mycli', { stdout })
2429
+ const sub = goke()
2430
+
2431
+ sub.command('selfhost', 'Set up on your own workspace')
2432
+ .option('-t, --token [token]', 'Admin token')
2433
+
2434
+ parent.command('init', 'Initialize project')
2435
+ parent.use(sub)
2436
+ parent.help()
2437
+ parent.parse(['node', 'bin', '--help'], { run: false })
2438
+
2439
+ expect(stdout.text).toContain('init')
2440
+ expect(stdout.text).toContain('selfhost')
2441
+ expect(stdout.text).toContain('Set up on your own workspace')
2442
+ })
2443
+
2444
+ test('sub-CLI middlewares are NOT copied to parent', () => {
2445
+ const parent = goke('mycli')
2446
+ const sub = goke()
2447
+ let subMiddlewareCalled = false
2448
+ const order: string[] = []
2449
+
2450
+ sub.use(() => { subMiddlewareCalled = true })
2451
+ sub.command('deploy', 'Deploy').action(() => { order.push('deploy') })
2452
+
2453
+ parent.use(() => { order.push('parent-mw') })
2454
+ parent.use(sub)
2455
+
2456
+ parent.parse(['node', 'bin', 'deploy'], { run: true })
2457
+
2458
+ expect(subMiddlewareCalled).toBe(false)
2459
+ expect(order).toEqual(['parent-mw', 'deploy'])
2460
+ })
2461
+
2462
+ test('parent global options are available to composed commands', () => {
2463
+ const parent = goke('mycli')
2464
+ const sub = goke()
2465
+ let result: any = {}
2466
+
2467
+ parent.option('--verbose', 'Verbose output')
2468
+
2469
+ sub
2470
+ .command('build', 'Build')
2471
+ .option('--target <target>', 'Build target')
2472
+ .action((options) => { result = options })
2473
+
2474
+ parent.use(sub)
2475
+ parent.parse('node bin build --verbose --target production'.split(' '), { run: true })
2476
+
2477
+ expect(result.verbose).toBe(true)
2478
+ expect(result.target).toBe('production')
2479
+ })
2480
+
2481
+ test('composed commands coexist with inline commands', () => {
2482
+ const parent = goke('mycli')
2483
+ const sub = goke()
2484
+ let matched = ''
2485
+
2486
+ parent.command('init', 'Initialize').action(() => { matched = 'init' })
2487
+
2488
+ sub.command('deploy', 'Deploy').action(() => { matched = 'deploy' })
2489
+ sub.command('rollback', 'Rollback').action(() => { matched = 'rollback' })
2490
+
2491
+ parent.use(sub)
2492
+
2493
+ parent.parse(['node', 'bin', 'init'], { run: true })
2494
+ expect(matched).toBe('init')
2495
+
2496
+ matched = ''
2497
+ parent.parse(['node', 'bin', 'deploy'], { run: true })
2498
+ expect(matched).toBe('deploy')
2499
+
2500
+ matched = ''
2501
+ parent.parse(['node', 'bin', 'rollback'], { run: true })
2502
+ expect(matched).toBe('rollback')
2503
+ })
2504
+ })
@@ -5,7 +5,33 @@
5
5
  import { describe, expect, test } from 'vitest'
6
6
  import { z } from 'zod'
7
7
  import goke from '../index.js'
8
- import type { GokeOutputStream, GokeOptions } from '../index.js'
8
+ import type { GokeOutputStream, GokeOptions, GokeFs } from '../index.js'
9
+
10
+ /**
11
+ * Build a minimal `GokeFs` stub where every method throws unless the
12
+ * caller overrides it. Used by `createExecutionContext` tests that
13
+ * only care about a single method (e.g. `readFile`) but still need
14
+ * an object that satisfies the full `GokeFs` interface.
15
+ */
16
+ function stubGokeFs(overrides: Partial<GokeFs>): GokeFs {
17
+ const notImplemented = () => { throw new Error('not implemented in stub') }
18
+ return {
19
+ appendFile: notImplemented,
20
+ chmod: notImplemented,
21
+ copyFile: notImplemented,
22
+ link: notImplemented,
23
+ mkdir: notImplemented,
24
+ readFile: notImplemented,
25
+ readlink: notImplemented,
26
+ realpath: notImplemented,
27
+ rename: notImplemented,
28
+ rm: notImplemented,
29
+ symlink: notImplemented,
30
+ utimes: notImplemented,
31
+ writeFile: notImplemented,
32
+ ...overrides,
33
+ }
34
+ }
9
35
 
10
36
  const ANSI_RE = /\x1B\[[0-9;]*m/g
11
37
 
@@ -82,6 +108,80 @@ describe('clone', () => {
82
108
  })
83
109
  })
84
110
 
111
+ describe('createExecutionContext', () => {
112
+ test('returns a context that mirrors the cli defaults when called with no override', () => {
113
+ const stdout = createTestOutputStream()
114
+ const stderr = createTestOutputStream()
115
+ const cli = gokeTestable('mycli', {
116
+ cwd: '/workspace',
117
+ env: { TOKEN: 'abc' },
118
+ stdin: 'stdin-text',
119
+ stdout,
120
+ stderr,
121
+ })
122
+
123
+ const ctx = cli.createExecutionContext()
124
+
125
+ expect(ctx.process.cwd).toBe('/workspace')
126
+ expect(ctx.process.env.TOKEN).toBe('abc')
127
+ expect(ctx.process.stdin).toBe('stdin-text')
128
+ expect(ctx.process.stdout).toBe(stdout)
129
+ expect(ctx.process.stderr).toBe(stderr)
130
+
131
+ ctx.console.log('hi')
132
+ expect(stdout.text).toBe('hi\n')
133
+ })
134
+
135
+ test('honors per-call overrides for stdout, cwd, env, stdin, fs, and exit', async () => {
136
+ const defaultStdout = createTestOutputStream()
137
+ const defaultFs = stubGokeFs({ readFile: async () => 'default' })
138
+ const cli = gokeTestable('mycli', {
139
+ cwd: '/default',
140
+ env: { DEFAULT: '1' },
141
+ stdin: 'default-stdin',
142
+ stdout: defaultStdout,
143
+ fs: defaultFs,
144
+ })
145
+
146
+ const overrideStdout = createTestOutputStream()
147
+ const overrideStderr = createTestOutputStream()
148
+ const overrideFs = stubGokeFs({ readFile: async () => 'override' })
149
+ let receivedExitCode: number | undefined
150
+ const ctx = cli.createExecutionContext({
151
+ cwd: '/tenant',
152
+ env: { TOKEN: 'xyz' },
153
+ stdin: 'tenant-stdin',
154
+ stdout: overrideStdout,
155
+ stderr: overrideStderr,
156
+ fs: overrideFs,
157
+ exit: (code) => {
158
+ receivedExitCode = code
159
+ },
160
+ })
161
+
162
+ expect(ctx.process.cwd).toBe('/tenant')
163
+ expect(ctx.process.env.TOKEN).toBe('xyz')
164
+ expect(ctx.process.env.DEFAULT).toBeUndefined()
165
+ expect(ctx.process.stdin).toBe('tenant-stdin')
166
+ expect(ctx.process.stdout).toBe(overrideStdout)
167
+ expect(ctx.process.stderr).toBe(overrideStderr)
168
+ expect(ctx.fs).toBe(overrideFs)
169
+ expect(await ctx.fs.readFile('unused')).toBe('override')
170
+
171
+ // The cli's own stdout must NOT receive writes made through the
172
+ // override. This is what makes per-request capturing safe.
173
+ ctx.console.log('per-tenant')
174
+ expect(defaultStdout.text).toBe('')
175
+ expect(overrideStdout.text).toBe('per-tenant\n')
176
+
177
+ // Custom exit callback runs, then the wrapper throws GokeProcessExit
178
+ // so the action's code path stops at the exit site.
179
+ const { GokeProcessExit } = await import('../goke.js')
180
+ expect(() => ctx.process.exit(7)).toThrow(GokeProcessExit)
181
+ expect(receivedExitCode).toBe(7)
182
+ })
183
+ })
184
+
85
185
  describe('createJustBashCommand', () => {
86
186
  test('runs multi-word goke subcommands through one just-bash command', async () => {
87
187
  const cli = gokeTestable('parent')
@@ -289,4 +389,28 @@ describe('createJustBashCommand', () => {
289
389
  exitCode: 7,
290
390
  })
291
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
+ })
292
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
@@ -1000,6 +1000,45 @@ interface GokeExecutionContext {
1000
1000
  process: GokeProcess
1001
1001
  }
1002
1002
 
1003
+ /**
1004
+ * Per-request overrides accepted by `Goke#createExecutionContext()`.
1005
+ *
1006
+ * Any field left `undefined` falls back to the `Goke` instance's
1007
+ * defaults (set via `GokeOptions`), which themselves fall back to the
1008
+ * real Node.js `process.*`. Use this to construct an execution context
1009
+ * with tenant-specific values — e.g. a per-user `cwd`/`env`/`fs` pair
1010
+ * for a remote MCP server, or capture streams for stdout/stderr.
1011
+ *
1012
+ * Passing a custom `exit` replaces the default behavior of calling
1013
+ * `this.exit(code)`. The returned `process.exit` still throws
1014
+ * `GokeProcessExit` after the user-provided `exit` returns, so callers
1015
+ * can catch the exit without the outer code needing to know about it.
1016
+ */
1017
+ interface GokeExecutionContextOverride {
1018
+ /** Override the argv array exposed as `process.argv`. Defaults to the cli's raw parsed argv. */
1019
+ argv?: string[]
1020
+ /** Override the working directory exposed as `process.cwd`. */
1021
+ cwd?: string
1022
+ /** Override the environment exposed as `process.env`. */
1023
+ env?: Record<string, string | undefined>
1024
+ /** Override the filesystem exposed as `ctx.fs`. */
1025
+ fs?: GokeFs
1026
+ /** Override the stdin content exposed as `process.stdin`. */
1027
+ stdin?: string
1028
+ /** Override the stdout stream used by `ctx.console.log` and exposed as `process.stdout`. */
1029
+ stdout?: GokeOutputStream
1030
+ /** Override the stderr stream used by `ctx.console.error` and exposed as `process.stderr`. */
1031
+ stderr?: GokeOutputStream
1032
+ /**
1033
+ * Override the exit function called by `process.exit(code)`.
1034
+ *
1035
+ * The returned context still throws `GokeProcessExit` after this
1036
+ * callback returns, so callers that want to capture the exit
1037
+ * without killing the host process can pass `() => {}`.
1038
+ */
1039
+ exit?: (code: number) => void
1040
+ }
1041
+
1003
1042
  class GokeProcessExit extends Error {
1004
1043
  code: number
1005
1044
 
@@ -1193,19 +1232,63 @@ class Goke<Opts = {}> extends EventEmitter {
1193
1232
  return cloned
1194
1233
  }
1195
1234
 
1196
- private createExecutionContext(argv = this.rawArgs): GokeExecutionContext {
1235
+ /**
1236
+ * Build a `GokeExecutionContext` using this cli's defaults, optionally
1237
+ * overridden per-request.
1238
+ *
1239
+ * `runMatchedCommand()` calls this internally with no arguments to
1240
+ * construct the context passed to command actions and middlewares.
1241
+ *
1242
+ * The method is also public so adapters (MCP, remote RPC, batch
1243
+ * runners, etc.) can build a context for a single invocation with
1244
+ * tenant-specific values — e.g. capture streams for stdout/stderr,
1245
+ * a per-user `cwd`/`env`/`fs`, or an `exit` that throws instead of
1246
+ * killing the host process. See {@link GokeExecutionContextOverride}.
1247
+ *
1248
+ * @example
1249
+ * ```ts
1250
+ * // Build an execution context that captures output into strings and
1251
+ * // treats `ctx.process.exit(code)` as a `GokeProcessExit` throw
1252
+ * // instead of terminating the host process.
1253
+ * const stdout = createTextCaptureStream()
1254
+ * const stderr = createTextCaptureStream()
1255
+ * const ctx = cli.createExecutionContext({
1256
+ * stdout,
1257
+ * stderr,
1258
+ * exit: () => {},
1259
+ * })
1260
+ * try {
1261
+ * await action(...args, options, ctx)
1262
+ * } catch (err) {
1263
+ * if (err instanceof GokeProcessExit) {
1264
+ * // handle exit code
1265
+ * } else {
1266
+ * throw err
1267
+ * }
1268
+ * }
1269
+ * ```
1270
+ */
1271
+ createExecutionContext(override?: GokeExecutionContextOverride): GokeExecutionContext {
1272
+ const stdout = override?.stdout ?? this.stdout
1273
+ const stderr = override?.stderr ?? this.stderr
1274
+ // Reuse the cached console when streams aren't overridden; otherwise
1275
+ // build a new one so ctx.console.log writes to the overridden streams.
1276
+ const contextConsole = (override?.stdout !== undefined || override?.stderr !== undefined)
1277
+ ? createConsole(stdout, stderr)
1278
+ : this.console
1279
+ const exitFn = override?.exit ?? this.exit
1197
1280
  return {
1198
- console: this.console,
1199
- fs: this.fs,
1281
+ console: contextConsole,
1282
+ fs: override?.fs ?? this.fs,
1200
1283
  process: {
1201
- argv,
1202
- cwd: this.cwd ?? process.cwd(),
1203
- env: this.env ?? process.env,
1204
- stdin: this.stdin ?? '',
1205
- stdout: this.stdout,
1206
- stderr: this.stderr,
1207
- exit: (code: number) => {
1208
- this.exit(code)
1284
+ argv: override?.argv ?? this.rawArgs,
1285
+ cwd: override?.cwd ?? this.cwd ?? process.cwd(),
1286
+ env: override?.env ?? this.env ?? process.env,
1287
+ stdin: override?.stdin ?? this.stdin ?? '',
1288
+ stdout,
1289
+ stderr,
1290
+ exit: (code) => {
1291
+ exitFn(code)
1209
1292
  throw new GokeProcessExit(code)
1210
1293
  },
1211
1294
  },
@@ -1298,14 +1381,48 @@ class Goke<Opts = {}> extends EventEmitter {
1298
1381
  * }
1299
1382
  * })
1300
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
+ * ```
1301
1406
  */
1407
+ use(subCli: Goke<any>): this
1302
1408
  use(
1303
1409
  callback: (
1304
1410
  options: Opts & DoubleDashOptions,
1305
1411
  context: GokeExecutionContext,
1306
1412
  ) => void | Promise<void>,
1413
+ ): this
1414
+ use(
1415
+ callbackOrCli:
1416
+ | Goke<any>
1417
+ | ((options: Opts & DoubleDashOptions, context: GokeExecutionContext) => void | Promise<void>),
1307
1418
  ): this {
1308
- 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 })
1309
1426
  return this
1310
1427
  }
1311
1428
 
@@ -1828,6 +1945,6 @@ class Goke<Opts = {}> extends EventEmitter {
1828
1945
 
1829
1946
  // ─── Exports ───
1830
1947
 
1831
- export type { GokeOutputStream, GokeConsole, GokeOptions, GokeProcess, GokeExecutionContext, GokeFs }
1948
+ export type { GokeOutputStream, GokeConsole, GokeOptions, GokeProcess, GokeExecutionContext, GokeExecutionContextOverride, GokeFs }
1832
1949
  export { createConsole, Command, GokeProcessExit, openInBrowser }
1833
1950
  export default Goke
package/src/index.ts CHANGED
@@ -11,6 +11,6 @@ const goke = (name = '', options?: GokeOptions) => new Goke(name, options)
11
11
  export default goke
12
12
  export { goke, Goke, Command }
13
13
  export { createConsole, GokeProcessExit, openInBrowser } from "./goke.js"
14
- export type { GokeOutputStream, GokeConsole, GokeExecutionContext, GokeFs, GokeOptions, GokeProcess } from "./goke.js"
14
+ export type { GokeOutputStream, GokeConsole, GokeExecutionContext, GokeExecutionContextOverride, GokeFs, GokeOptions, GokeProcess } from "./goke.js"
15
15
  export type { StandardTypedV1, StandardJSONSchemaV1, JsonSchema } from "./coerce.js"
16
16
  export { GokeError, coerceBySchema, extractJsonSchema, wrapJsonSchema, isStandardSchema, extractSchemaMetadata } from "./coerce.js"