goke 6.4.0 → 6.5.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/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  - **Yet so powerful**. Enable features like default command, git-like subcommands, validation for required arguments and options, variadic arguments, dot-nested options, automated help message generation and so on.
16
16
  - **Space-separated subcommands**: Support multi-word commands like `mcp login`, `git remote add`.
17
17
  - **Schema-based type coercion**: Use Zod, Valibot, ArkType, or plain JSON Schema for automatic type coercion and TypeScript type inference. Description and default values are extracted from the schema automatically.
18
- - **Injected execution context**: Prefer `{ console, process }` in actions and middleware for portable output, simpler tests, and alternate runtimes like JustBash.
18
+ - **Injected execution context**: Prefer `{ fs, console, process }` in actions and middleware for portable storage, output, and runtime metadata across Node.js, tests, and JustBash.
19
19
  - **Type-safe middleware**: Register `.use()` callbacks that run before commands with full type inference from global options.
20
20
  - **Developer friendly**. Written in TypeScript.
21
21
 
@@ -44,8 +44,8 @@ cli.option(
44
44
  )
45
45
  cli.option('--name <name>', 'Provide your name')
46
46
 
47
- cli.command('lint [...files]', 'Lint files').action((files, options, { console }) => {
48
- console.log(files, options)
47
+ cli.command('lint [...files]', 'Lint files').action((files, options, { console, process }) => {
48
+ console.log(files, options, process.cwd)
49
49
  })
50
50
 
51
51
  cli
@@ -53,8 +53,8 @@ cli
53
53
  .option('--minify', 'Minify output')
54
54
  .example('build src/index.ts')
55
55
  .example('build src/index.ts --minify')
56
- .action(async (entry, options, { console }) => { // options is type safe! no need to type it
57
- console.log(entry, options)
56
+ .action(async (entry, options, { console, process }) => { // options is type safe! no need to type it
57
+ console.log(entry, options, process.env.NODE_ENV)
58
58
  })
59
59
 
60
60
  cli.example((bin) => `${bin} lint src/**/*.ts`)
@@ -133,8 +133,8 @@ cli
133
133
  .option('--channel <name>', 'Target channel: stable, beta, alpha')
134
134
  .option('--notes-file <path>', 'Markdown file used as release notes')
135
135
  .option('--dry-run', 'Preview every step without publishing')
136
- .action((version, options, { console }) => {
137
- console.log('release', version, options)
136
+ .action((version, options, { console, process }) => {
137
+ console.log('release', version, options, process.cwd)
138
138
  })
139
139
 
140
140
  cli
@@ -159,8 +159,8 @@ cli
159
159
  .option('--target <migration>', 'Apply up to a specific migration id')
160
160
  .option('--dry-run', 'Print plan only, do not execute SQL')
161
161
  .option('--verbose', 'Show each executed statement')
162
- .action((options, { console }) => {
163
- console.log('migrate', options)
162
+ .action((options, { console, process }) => {
163
+ console.log('migrate', options, process.stdin)
164
164
  })
165
165
 
166
166
  cli.help()
@@ -299,39 +299,39 @@ cli
299
299
  z.string().default('production').describe('Target environment'),
300
300
  )
301
301
  .option('--dry-run', 'Preview without deploying')
302
- .action((options, { console }) => {
303
- console.log(`Deploying to ${options.env}...`)
302
+ .action((options, { console, process }) => {
303
+ console.log(`Deploying to ${options.env} from ${process.cwd}...`)
304
304
  })
305
305
 
306
306
  // Subcommands
307
307
  cli
308
308
  .command('init', 'Initialize a new project')
309
309
  .option('--template <template>', 'Project template')
310
- .action((options, { console }) => {
311
- console.log('Initializing project...')
310
+ .action((options, { console, process }) => {
311
+ console.log('Initializing project in', process.cwd)
312
312
  })
313
313
 
314
- cli.command('login', 'Authenticate with the server').action((options, { console }) => {
315
- console.log('Opening browser for login...')
314
+ cli.command('login', 'Authenticate with the server').action((options, { console, process }) => {
315
+ console.log('Opening browser for login from', process.cwd)
316
316
  })
317
317
 
318
- cli.command('logout', 'Clear saved credentials').action((options, { console }) => {
319
- console.log('Logged out')
318
+ cli.command('logout', 'Clear saved credentials').action((options, { console, process }) => {
319
+ console.log('Logged out', process.env.USER)
320
320
  })
321
321
 
322
322
  cli
323
323
  .command('status', 'Show deployment status')
324
324
  .option('--json', 'Output as JSON')
325
- .action((options, { console }) => {
326
- console.log('Fetching status...')
325
+ .action((options, { console, process }) => {
326
+ console.log('Fetching status from', process.cwd)
327
327
  })
328
328
 
329
329
  cli
330
330
  .command('logs <deploymentId>', 'Stream logs for a deployment')
331
331
  .option('--follow', 'Follow log output')
332
332
  .option('--lines <n>', z.number().default(100).describe('Number of lines'))
333
- .action((deploymentId, options, { console }) => {
334
- console.log(`Streaming logs for ${deploymentId}...`)
333
+ .action((deploymentId, options, { console, process }) => {
334
+ console.log(`Streaming logs for ${deploymentId} from ${process.cwd}...`)
335
335
  })
336
336
 
337
337
  cli.help()
@@ -352,10 +352,72 @@ deploy --help # shows all commands
352
352
 
353
353
  Global options are defined on the CLI instance and apply to all commands. Use `.use()` to register middleware that runs before any command action — useful for reacting to global options like setting up logging, initializing state, or configuring services.
354
354
 
355
- Prefer the injected `{ console, process }` argument over global `console` and `process.exit`. It keeps commands easier to test and lets the same command code run inside alternate runtimes like JustBash.
355
+ Prefer the injected `{ fs, console, process }` argument over global `console`, `process.exit`, or direct `node:fs/promises` imports. It keeps commands easier to test and lets the same command code run inside alternate runtimes like JustBash.
356
+
357
+ `process.cwd`, `process.stdin`, and `process.env` come from the active runtime:
358
+
359
+ - In normal Node.js runs, `process.cwd` and `process.env` reflect the host process, while `process.stdin` defaults to an empty string unless you inject it yourself.
360
+ - In JustBash runs, those same fields are populated from the sandbox execution context.
356
361
 
357
362
  Middleware runs in registration order, after option parsing and validation, but before the matched command's `.action()` callback.
358
363
 
364
+ ### Filesystem Access
365
+
366
+ The injected `fs` object is the recommended way to read or write CLI state.
367
+
368
+ - In normal Node.js runs, `fs` defaults to `node:fs/promises`
369
+ - In JustBash runs, `goke` swaps in a compatible adapter over the JustBash virtual filesystem
370
+
371
+ This makes storage-style commands work in both environments without branching on runtime details.
372
+
373
+ ```ts
374
+ cli
375
+ .command('login', 'Save auth token')
376
+ .option('--token <token>', z.string().describe('Auth token'))
377
+ .action(async (options, { fs, console, process }) => {
378
+ await fs.mkdir('.mycli', { recursive: true })
379
+ await fs.writeFile('.mycli/auth.json', JSON.stringify({ token: options.token }), 'utf8')
380
+ console.log('saved credentials in', process.cwd)
381
+ })
382
+
383
+ cli
384
+ .command('whoami', 'Read saved auth token')
385
+ .action(async (options, { fs, console, process }) => {
386
+ const auth = await fs.readFile('.mycli/auth.json', 'utf8')
387
+ console.log(auth, process.env.USER)
388
+ })
389
+ ```
390
+
391
+ Prefer injected `fs` for CLI storage instead of importing `node:fs/promises` directly inside actions. That keeps the command portable to JustBash and easier to test.
392
+
393
+ `goke` also exports the runtime types, so helper functions can use dependency injection without reaching for globals:
394
+
395
+ ```ts
396
+ import { goke } from 'goke'
397
+ import type { GokeFs, GokeProcess } from 'goke'
398
+
399
+ async function saveAuthToken(args: {
400
+ fs: GokeFs
401
+ process: GokeProcess
402
+ token: string
403
+ }) {
404
+ await args.fs.mkdir('.mycli', { recursive: true })
405
+ await args.fs.writeFile('.mycli/auth.json', JSON.stringify({
406
+ token,
407
+ cwd: args.process.cwd,
408
+ }), 'utf8')
409
+ }
410
+
411
+ const cli = goke('mycli')
412
+
413
+ cli
414
+ .command('login <token>', 'Save auth token')
415
+ .action(async (token, options, { fs, process, console }) => {
416
+ await saveAuthToken({ fs, process, token })
417
+ console.log('saved credentials')
418
+ })
419
+ ```
420
+
359
421
  ```ts
360
422
  import { goke } from 'goke'
361
423
  import { z } from 'zod'
@@ -365,25 +427,25 @@ const cli = goke('mycli')
365
427
  cli
366
428
  .option('--verbose', z.boolean().default(false).describe('Enable verbose logging'))
367
429
  .option('--api-url [url]', z.string().default('https://api.example.com').describe('API base URL'))
368
- .use((options, { console }) => {
430
+ .use((options, { console, process }) => {
369
431
  // options.verbose and options.apiUrl are fully typed here
370
432
  if (options.verbose) {
371
- console.log('verbose mode enabled')
433
+ console.log('verbose mode enabled in', process.cwd)
372
434
  }
373
435
  })
374
436
 
375
437
  cli
376
438
  .command('deploy <env>', 'Deploy to an environment')
377
439
  .option('--dry-run', 'Preview without deploying')
378
- .action((env, options, { console }) => {
440
+ .action((env, options, { console, process }) => {
379
441
  // options includes both command options (dryRun) and global options (verbose, apiUrl)
380
- console.log(`Deploying to ${env} via ${options.apiUrl}`)
442
+ console.log(`Deploying to ${env} via ${options.apiUrl} from ${process.cwd}`)
381
443
  })
382
444
 
383
445
  cli
384
446
  .command('status', 'Show deployment status')
385
- .action((options, { console }) => {
386
- console.log('Checking status...')
447
+ .action((options, { console, process }) => {
448
+ console.log('Checking status...', process.stdin)
387
449
  })
388
450
 
389
451
  cli.help()
@@ -398,13 +460,16 @@ cli
398
460
  .use((options, { process }) => {
399
461
  options.verbose // boolean — typed
400
462
  process.argv // string[] — typed
463
+ process.cwd // string — typed
464
+ process.env // Record<string, string> — typed
465
+ process.stdin // string — typed
401
466
  options.port // TypeScript error — not declared yet
402
467
  })
403
468
  .option('--port <port>', z.number().describe('Port'))
404
- .use((options, { console }) => {
469
+ .use((options, { console, process }) => {
405
470
  options.verbose // boolean — still visible
406
471
  options.port // number — now visible
407
- console.error('ready')
472
+ console.error('ready', process.cwd)
408
473
  })
409
474
  ```
410
475
 
@@ -413,10 +478,10 @@ Middleware supports async functions. If any middleware is async, the remaining m
413
478
  ```ts
414
479
  cli
415
480
  .option('--token <token>', z.string().describe('API token'))
416
- .use(async (options, { console }) => {
481
+ .use(async (options, { console, process }) => {
417
482
  const client = await connectToApi(options.token)
418
483
  globalState.client = client
419
- console.log('connected')
484
+ console.log('connected', process.env.NODE_ENV)
420
485
  })
421
486
  ```
422
487
 
@@ -432,8 +497,8 @@ const cli = goke()
432
497
  cli
433
498
  .command('rm <dir>', 'Remove a dir')
434
499
  .option('-r, --recursive', 'Remove recursively')
435
- .action((dir, options, { console }) => {
436
- console.log('remove ' + dir + (options.recursive ? ' recursively' : ''))
500
+ .action((dir, options, { console, process }) => {
501
+ console.log('remove ' + dir + (options.recursive ? ' recursively' : ''), process.cwd)
437
502
  })
438
503
 
439
504
  cli.help()
@@ -450,18 +515,18 @@ import { goke } from 'goke'
450
515
 
451
516
  const cli = goke('mycli')
452
517
 
453
- cli.command('mcp login <url>', 'Login to MCP server').action((url, options, { console }) => {
454
- console.log('Logging in to', url)
518
+ cli.command('mcp login <url>', 'Login to MCP server').action((url, options, { console, process }) => {
519
+ console.log('Logging in to', url, 'from', process.cwd)
455
520
  })
456
521
 
457
- cli.command('mcp logout', 'Logout from MCP server').action((options, { console }) => {
458
- console.log('Logged out')
522
+ cli.command('mcp logout', 'Logout from MCP server').action((options, { console, process }) => {
523
+ console.log('Logged out', process.env.USER)
459
524
  })
460
525
 
461
526
  cli
462
527
  .command('git remote add <name> <url>', 'Add a git remote')
463
- .action((name, url, options, { console }) => {
464
- console.log('Adding remote', name, url)
528
+ .action((name, url, options, { console, process }) => {
529
+ console.log('Adding remote', name, url, 'from', process.cwd)
465
530
  })
466
531
 
467
532
  cli.help()
@@ -485,9 +550,9 @@ cli
485
550
  .option('--workers <workers>', z.int().describe('Worker count'))
486
551
  .option('--tags <tag>', z.array(z.string()).describe('Tags (repeatable)'))
487
552
  .option('--verbose', 'Verbose output')
488
- .action((options, { console }) => {
553
+ .action((options, { console, process }) => {
489
554
  // options.port is number, options.host is string, etc.
490
- console.log(options)
555
+ console.log(options, process.env.NODE_ENV)
491
556
  })
492
557
 
493
558
  cli.parse()
@@ -516,9 +581,9 @@ cli
516
581
  .option('--old-port <port>', z.number().meta({ deprecated: true, description: 'Use --port instead' }))
517
582
  // Current option: visible in help
518
583
  .option('--port <port>', z.number().describe('Port number'))
519
- .action((options, { console }) => {
584
+ .action((options, { console, process }) => {
520
585
  const port = options.port ?? options.oldPort
521
- console.log('Starting on port', port)
586
+ console.log('Starting on port', port, 'from', process.cwd)
522
587
  })
523
588
 
524
589
  cli.help()
@@ -554,10 +619,10 @@ The last argument of a command can be variadic. To make an argument variadic you
554
619
  cli
555
620
  .command('build <entry> [...otherFiles]', 'Build your app')
556
621
  .option('--foo', 'Foo option')
557
- .action((entry, otherFiles, options, { console }) => {
622
+ .action((entry, otherFiles, options, { console, process }) => {
558
623
  console.log(entry)
559
624
  console.log(otherFiles)
560
- console.log(options)
625
+ console.log(options, process.stdin)
561
626
  })
562
627
  ```
563
628
 
@@ -612,8 +677,8 @@ cli
612
677
  .command('build', 'desc')
613
678
  .option('--env <env>', 'Set envs')
614
679
  .example('--env.API_SECRET xxx')
615
- .action((options, { console }) => {
616
- console.log(options)
680
+ .action((options, { console, process }) => {
681
+ console.log(options, process.env.API_SECRET)
617
682
  })
618
683
  ```
619
684
 
@@ -625,9 +690,9 @@ Register a command that will be used when no other command is matched.
625
690
  cli
626
691
  .command('[...files]', 'Build files')
627
692
  .option('--minimize', 'Minimize output')
628
- .action((files, options, { console }) => {
693
+ .action((files, options, { console, process }) => {
629
694
  console.log(files)
630
- console.log(options.minimize)
695
+ console.log(options.minimize, process.cwd)
631
696
  })
632
697
  ```
633
698
 
@@ -648,7 +713,7 @@ try {
648
713
 
649
714
  ### Testing with mocked console and exit
650
715
 
651
- Because goke derives its injected `{ console, process }` from the CLI's configured output streams and `exit` function, tests can override them directly and assert on the calls.
716
+ Because goke derives its injected `{ fs, console, process }` from the CLI's configured runtime dependencies, tests can override them directly and assert on the calls.
652
717
 
653
718
  ```ts
654
719
  import { describe, expect, test, vi } from 'vitest'
@@ -700,11 +765,11 @@ cli
700
765
  .command('serve <entry>', 'Start the app')
701
766
  .option('--port <port>', z.number().default(3000).describe('Port number'))
702
767
  .option('--watch', 'Watch files')
703
- .action((entry, options, { console }) => {
768
+ .action((entry, options, { console, process }) => {
704
769
  // entry: string
705
770
  // options.port: number
706
771
  // options.watch: boolean
707
- console.log(entry, options.port, options.watch)
772
+ console.log(entry, options.port, options.watch, process.cwd)
708
773
  })
709
774
  ```
710
775
 
@@ -732,8 +797,8 @@ const cli = goke('parent')
732
797
  cli
733
798
  .command('child commandwithspaces', 'Run nested command')
734
799
  .option('--name <name>', z.string().describe('Name'))
735
- .action((options, { console }) => {
736
- console.log(`hello ${options.name}`)
800
+ .action((options, { console, process }) => {
801
+ console.log(`hello ${options.name} from ${process.cwd}`)
737
802
  })
738
803
 
739
804
  const bash = new Bash({
@@ -743,7 +808,7 @@ const bash = new Bash({
743
808
  await bash.exec('parent child commandwithspaces --name Tommy')
744
809
  ```
745
810
 
746
- Prefer the injected `{ console, process }` helpers in command implementations so the same command code works cleanly both in the regular CLI runtime and through the JustBash bridge.
811
+ Prefer the injected `{ fs, console, process }` helpers in command implementations so the same command code works cleanly both in the regular CLI runtime and through the JustBash bridge. The injected `fs` defaults to Node `fs/promises`, and `process.cwd` / `process.env` / `process.stdin` reflect host values in Node but sandbox values inside `createJustBashCommand()`.
747
812
 
748
813
  ## References
749
814
 
@@ -780,9 +845,9 @@ Add a global option. The second argument is either:
780
845
 
781
846
  #### cli.use(callback)
782
847
 
783
- - Type: `(callback: (options: Opts, { console, process }) => void | Promise<void>) => CLI`
848
+ - Type: `(callback: (options: Opts, { fs, console, process }) => void | Promise<void>) => CLI`
784
849
 
785
- Register a middleware function that runs before the matched command action. Middleware runs in registration order, after option parsing and validation. The callback receives the parsed global options, typed according to all `.option()` calls that precede the `.use()` in the chain, plus an injected `{ console, process }` helper object.
850
+ Register a middleware function that runs before the matched command action. Middleware runs in registration order, after option parsing and validation. The callback receives the parsed global options, typed according to all `.option()` calls that precede the `.use()` in the chain, plus an injected `{ fs, console, process }` helper object.
786
851
 
787
852
  #### cli.parse(argv?)
788
853
 
@@ -836,7 +901,7 @@ Basically the same as `cli.option` but this adds the option to specific command.
836
901
 
837
902
  - Type: `(callback: ActionCallback) => Command`
838
903
 
839
- Command callbacks receive positional args first, then parsed options, then an injected `{ console, process }` object. Prefer those injected helpers over global `console` and `process.exit` so commands stay easier to test and can run inside alternate runtimes like JustBash.
904
+ Command callbacks receive positional args first, then parsed options, then an injected `{ fs, console, process }` object. Prefer those injected helpers over global `console`, `process.exit`, and direct `node:fs/promises` imports so commands stay easier to test and can run inside alternate runtimes like JustBash.
840
905
 
841
906
  #### command.alias(name)
842
907
 
@@ -2,6 +2,9 @@ import { describe, test, expect } from 'vitest';
2
2
  import goke, { createConsole } from '../index.js';
3
3
  import { coerceBySchema } from '../coerce.js';
4
4
  import { z } from 'zod';
5
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
5
8
  const ANSI_RE = /\x1B\[[0-9;]*m/g;
6
9
  const stripAnsi = (text) => text.replace(ANSI_RE, '');
7
10
  /**
@@ -129,6 +132,83 @@ describe('error formatting', () => {
129
132
  expect(text).toMatch(/at /);
130
133
  });
131
134
  });
135
+ describe('injected fs', () => {
136
+ test('command actions can use the default node fs for cli storage', async () => {
137
+ const stdout = createTestOutputStream();
138
+ const cli = gokeTestable('mycli', { stdout });
139
+ const originalCwd = process.cwd();
140
+ const tempDir = await mkdtemp(join(tmpdir(), 'goke-fs-'));
141
+ try {
142
+ process.chdir(tempDir);
143
+ cli
144
+ .command('login', 'Persist login state')
145
+ .option('--token <token>', z.string().describe('Token'))
146
+ .action(async (options, { fs, console }) => {
147
+ await fs.mkdir('.mycli', { recursive: true });
148
+ await fs.writeFile('.mycli/auth.json', JSON.stringify({ token: options.token }), 'utf8');
149
+ console.log('saved credentials');
150
+ });
151
+ cli.parse(['node', 'bin', 'login', '--token', 'abc123'], { run: false });
152
+ await cli.runMatchedCommand();
153
+ expect(stdout.text).toBe('saved credentials\n');
154
+ expect(await readFile(join(tempDir, '.mycli/auth.json'), 'utf8')).toBe('{"token":"abc123"}');
155
+ }
156
+ finally {
157
+ process.chdir(originalCwd);
158
+ await rm(tempDir, { recursive: true, force: true });
159
+ }
160
+ });
161
+ });
162
+ describe('injected process context', () => {
163
+ test('command actions receive host cwd, env, and stdin defaults', async () => {
164
+ const stdout = createTestOutputStream();
165
+ const cli = gokeTestable('mycli', { stdout });
166
+ const originalCwd = process.cwd();
167
+ const originalEnv = process.env.GOKE_TEST_TOKEN;
168
+ const tempDir = await mkdtemp(join(tmpdir(), 'goke-process-'));
169
+ try {
170
+ process.chdir(tempDir);
171
+ process.env.GOKE_TEST_TOKEN = 'abc123';
172
+ cli
173
+ .command('context', 'Inspect process context')
174
+ .action((options, { console, process }) => {
175
+ console.log(JSON.stringify({
176
+ cwd: process.cwd,
177
+ stdin: process.stdin,
178
+ token: process.env.GOKE_TEST_TOKEN,
179
+ }));
180
+ });
181
+ cli.parse(['node', 'bin', 'context'], { run: false });
182
+ await cli.runMatchedCommand();
183
+ expect(stdout.text).toBe(`${JSON.stringify({ cwd: process.cwd(), stdin: '', token: 'abc123' })}\n`);
184
+ }
185
+ finally {
186
+ process.chdir(originalCwd);
187
+ if (originalEnv === undefined) {
188
+ delete process.env.GOKE_TEST_TOKEN;
189
+ }
190
+ else {
191
+ process.env.GOKE_TEST_TOKEN = originalEnv;
192
+ }
193
+ await rm(tempDir, { recursive: true, force: true });
194
+ }
195
+ });
196
+ test('custom injected env stays mutable inside command actions', async () => {
197
+ const stdout = createTestOutputStream();
198
+ const env = { TOKEN: 'before' };
199
+ const cli = gokeTestable('mycli', { env, stdout });
200
+ cli
201
+ .command('context', 'Mutate process env')
202
+ .action((options, { console, process }) => {
203
+ process.env.TOKEN = 'after';
204
+ console.log(process.env.TOKEN);
205
+ });
206
+ cli.parse(['node', 'bin', 'context'], { run: false });
207
+ await cli.runMatchedCommand();
208
+ expect(stdout.text).toBe('after\n');
209
+ expect(env.TOKEN).toBe('after');
210
+ });
211
+ });
132
212
  test('double dashes', () => {
133
213
  const cli = goke();
134
214
  const { args, options } = cli.parse([
@@ -79,6 +79,134 @@ describe('createJustBashCommand', () => {
79
79
  exitCode: 0,
80
80
  });
81
81
  });
82
+ test('works through real just-bash exec with a goke custom command', async () => {
83
+ const { Bash } = await import('just-bash');
84
+ const cli = gokeTestable('parent');
85
+ cli
86
+ .command('child commandwithspaces', 'Run nested command')
87
+ .option('--name <name>', z.string().describe('Name'))
88
+ .action((options, { console }) => {
89
+ console.log(`hello ${options.name}`);
90
+ });
91
+ const bash = new Bash({
92
+ customCommands: [await cli.createJustBashCommand()],
93
+ });
94
+ const result = await bash.exec('parent child commandwithspaces --name Tommy');
95
+ expect(result.stdout).toBe('hello Tommy\n');
96
+ expect(result.stderr).toBe('');
97
+ expect(result.exitCode).toBe(0);
98
+ });
99
+ test('maps injected fs to the just-bash virtual filesystem', async () => {
100
+ const { Bash } = await import('just-bash');
101
+ const cli = gokeTestable('parent');
102
+ cli
103
+ .command('login', 'Persist login state')
104
+ .option('--token <token>', z.string().describe('Token'))
105
+ .action(async (options, { fs, console }) => {
106
+ await fs.mkdir('.mycli', { recursive: true });
107
+ await fs.writeFile('.mycli/auth.json', JSON.stringify({ token: options.token }), 'utf8');
108
+ console.log('saved credentials');
109
+ });
110
+ const bash = new Bash({
111
+ customCommands: [await cli.createJustBashCommand()],
112
+ });
113
+ const loginResult = await bash.exec('mkdir project && cd project && parent login --token Tommy');
114
+ const catResult = await bash.exec('cd project && cat .mycli/auth.json');
115
+ expect(loginResult.stdout).toBe('saved credentials\n');
116
+ expect(loginResult.stderr).toBe('');
117
+ expect(loginResult.exitCode).toBe(0);
118
+ expect(catResult.stdout).toBe('{"token":"Tommy"}');
119
+ expect(catResult.stderr).toBe('');
120
+ expect(catResult.exitCode).toBe(0);
121
+ });
122
+ test('real just-bash exec passes the configured in-memory fs to the goke command', async () => {
123
+ const { Bash, InMemoryFs } = await import('just-bash');
124
+ const cli = gokeTestable('parent');
125
+ cli
126
+ .command('login', 'Persist login state')
127
+ .option('--token <token>', z.string().describe('Token'))
128
+ .action(async (options, { fs, console }) => {
129
+ await fs.mkdir('.mycli', { recursive: true });
130
+ await fs.writeFile('.mycli/auth.json', JSON.stringify({ token: options.token }), 'utf8');
131
+ console.log('saved credentials');
132
+ });
133
+ const virtualFs = new InMemoryFs();
134
+ await virtualFs.mkdir('/project', { recursive: true });
135
+ const bash = new Bash({
136
+ fs: virtualFs,
137
+ cwd: '/project',
138
+ customCommands: [await cli.createJustBashCommand()],
139
+ });
140
+ const result = await bash.exec('parent login --token Tommy');
141
+ expect(result.stdout).toBe('saved credentials\n');
142
+ expect(result.stderr).toBe('');
143
+ expect(result.exitCode).toBe(0);
144
+ expect(await virtualFs.readFile('/project/.mycli/auth.json', 'utf8')).toBe('{"token":"Tommy"}');
145
+ });
146
+ test('real just-bash exec passes sandbox cwd, stdin, and env through process context', async () => {
147
+ const { Bash, InMemoryFs } = await import('just-bash');
148
+ const cli = gokeTestable('parent');
149
+ cli
150
+ .command('context', 'Inspect process context')
151
+ .action((options, { console, process }) => {
152
+ console.log(JSON.stringify({
153
+ cwd: process.cwd,
154
+ stdin: process.stdin,
155
+ token: process.env.TOKEN,
156
+ }));
157
+ });
158
+ const virtualFs = new InMemoryFs();
159
+ await virtualFs.mkdir('/project', { recursive: true });
160
+ const bash = new Bash({
161
+ fs: virtualFs,
162
+ cwd: '/project',
163
+ env: { TOKEN: 'Tommy' },
164
+ customCommands: [await cli.createJustBashCommand()],
165
+ });
166
+ const result = await bash.exec('parent context', { stdin: 'hello from stdin' });
167
+ expect(result.stdout).toBe(`${JSON.stringify({ cwd: '/project', stdin: 'hello from stdin', token: 'Tommy' })}\n`);
168
+ expect(result.stderr).toBe('');
169
+ expect(result.exitCode).toBe(0);
170
+ });
171
+ test('explicit just-bash context exposes a mutable env object backed by the sandbox env', async () => {
172
+ const { InMemoryFs } = await import('just-bash');
173
+ const cli = gokeTestable('parent');
174
+ cli
175
+ .command('mutate-env', 'Mutate sandbox env')
176
+ .action((options, { console, process }) => {
177
+ process.env.TOKEN = 'updated';
178
+ console.log(process.env.TOKEN);
179
+ });
180
+ const virtualFs = new InMemoryFs();
181
+ await virtualFs.mkdir('/project', { recursive: true });
182
+ const env = new Map([['TOKEN', 'before']]);
183
+ const customCommand = await cli.createJustBashCommand();
184
+ const result = await customCommand.execute(['mutate-env'], { fs: virtualFs, cwd: '/project', env, stdin: '' });
185
+ expect(result.stdout).toBe('updated\n');
186
+ expect(result.stderr).toBe('');
187
+ expect(result.exitCode).toBe(0);
188
+ expect(env.get('TOKEN')).toBe('updated');
189
+ });
190
+ test('accepts an explicit just-bash fs context when executing the custom command', async () => {
191
+ const { InMemoryFs } = await import('just-bash');
192
+ const cli = gokeTestable('parent');
193
+ cli
194
+ .command('login', 'Persist login state')
195
+ .option('--token <token>', z.string().describe('Token'))
196
+ .action(async (options, { fs, console }) => {
197
+ await fs.mkdir('.mycli', { recursive: true });
198
+ await fs.writeFile('.mycli/auth.json', JSON.stringify({ token: options.token }), 'utf8');
199
+ console.log('saved credentials');
200
+ });
201
+ const customCommand = await cli.createJustBashCommand();
202
+ const virtualFs = new InMemoryFs();
203
+ await virtualFs.mkdir('/project', { recursive: true });
204
+ const result = await customCommand.execute(['login', '--token', 'Tommy'], { fs: virtualFs, cwd: '/project', env: new Map(), stdin: '' });
205
+ expect(result.stdout).toBe('saved credentials\n');
206
+ expect(result.stderr).toBe('');
207
+ expect(result.exitCode).toBe(0);
208
+ expect(await virtualFs.readFile('/project/.mycli/auth.json', 'utf8')).toBe('{"token":"Tommy"}');
209
+ });
82
210
  test('maps injected process.exit to a command exit code', async () => {
83
211
  const cli = gokeTestable('parent');
84
212
  cli
@@ -73,10 +73,14 @@ describe('type-level: middleware use() callback inference', () => {
73
73
  goke('test')
74
74
  .option('--port <port>', schema1)
75
75
  .option('--host <host>', schema2)
76
- .use((options, { console, process }) => {
76
+ .use((options, { console, fs, process }) => {
77
77
  expectTypeOf(options.port).toEqualTypeOf();
78
78
  expectTypeOf(options.host).toEqualTypeOf();
79
+ expectTypeOf(fs.mkdir).toBeFunction();
79
80
  expectTypeOf(process.argv).toEqualTypeOf();
81
+ expectTypeOf(process.cwd).toEqualTypeOf();
82
+ expectTypeOf(process.env).toEqualTypeOf();
83
+ expectTypeOf(process.stdin).toEqualTypeOf();
80
84
  expectTypeOf(process.stdout.write).toEqualTypeOf();
81
85
  expectTypeOf(console.log).toBeFunction();
82
86
  });
@@ -86,8 +90,9 @@ describe('type-level: middleware use() callback inference', () => {
86
90
  const schema2 = {};
87
91
  goke('test')
88
92
  .option('--verbose', schema1)
89
- .use((options, { process }) => {
93
+ .use((options, { fs, process }) => {
90
94
  expectTypeOf(options.verbose).toEqualTypeOf();
95
+ expectTypeOf(fs.writeFile).toBeFunction();
91
96
  expectTypeOf(process.exit).toEqualTypeOf();
92
97
  // @ts-expect-error port is not declared yet
93
98
  options.port;
@@ -104,7 +109,8 @@ describe('type-level: middleware use() callback inference', () => {
104
109
  const schema = {};
105
110
  goke('test')
106
111
  .option('--port <port>', schema)
107
- .use((options, { process }) => {
112
+ .use((options, { fs, process }) => {
113
+ expectTypeOf(fs.readFile).toBeFunction();
108
114
  expectTypeOf(process.stderr.write).toEqualTypeOf();
109
115
  // @ts-expect-error nonExistent was never defined
110
116
  options.nonExistent;