goke 6.1.3 → 6.2.1

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.
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from 'vitest'
2
2
  import goke, { createConsole } from '../index.js'
3
- import type { GokeOutputStream } from '../index.js'
3
+ import type { GokeOutputStream, GokeOptions } from '../index.js'
4
4
  import { coerceBySchema } from '../coerce.js'
5
5
  import { z } from 'zod'
6
6
 
@@ -21,6 +21,142 @@ function createTestOutputStream(): GokeOutputStream & { lines: string[]; readonl
21
21
  }
22
22
  }
23
23
 
24
+ /**
25
+ * Helper: creates a goke instance with exit overridden to a no-op.
26
+ * This prevents process.exit(1) from killing the test runner while
27
+ * still allowing the original error to propagate (the framework
28
+ * re-throws after calling exit when exit doesn't halt execution).
29
+ *
30
+ * Tests can still use .toThrow() to assert CLI errors normally.
31
+ */
32
+ function gokeTestable(name = '', options?: Partial<GokeOptions>) {
33
+ return goke(name, {
34
+ ...options,
35
+ exit: () => {},
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Strip stack trace lines for stable snapshots.
41
+ * Keeps the error message and help hint, removes all " at ..." lines
42
+ * and the blank line before them, since those contain machine-specific paths.
43
+ */
44
+ function stripStackTrace(text: string): string {
45
+ return text
46
+ .split('\n')
47
+ .filter(line => !line.match(/^\s+at /))
48
+ .join('\n')
49
+ .replace(/\n{2,}/g, '\n')
50
+ .trim()
51
+ }
52
+
53
+ describe('error formatting', () => {
54
+ test('unknown option prints formatted error to stderr', () => {
55
+ const stderr = createTestOutputStream()
56
+ const cli = goke('mycli', { stderr, exit: () => {} })
57
+
58
+ cli
59
+ .command('build', 'Build your app')
60
+ .option('--port <port>', 'Port')
61
+ .action(() => {})
62
+
63
+ try {
64
+ cli.parse('node bin build --unknown'.split(' '))
65
+ } catch {}
66
+
67
+ expect(stripStackTrace(stderr.text)).toMatchInlineSnapshot(`"error: Unknown option \`--unknown\`"`)
68
+ })
69
+
70
+ test('missing required option value prints formatted error to stderr', () => {
71
+ const stderr = createTestOutputStream()
72
+ const cli = goke('mycli', { stderr, exit: () => {} })
73
+
74
+ cli
75
+ .command('serve', 'Start server')
76
+ .option('--port <port>', 'Port')
77
+ .action(() => {})
78
+
79
+ try {
80
+ cli.parse('node bin serve --port'.split(' '))
81
+ } catch {}
82
+
83
+ expect(stripStackTrace(stderr.text)).toMatchInlineSnapshot(`"error: option \`--port <port>\` value is missing"`)
84
+ })
85
+
86
+ test('schema coercion error prints formatted error to stderr', () => {
87
+ const stderr = createTestOutputStream()
88
+ const cli = goke('mycli', { stderr, exit: () => {} })
89
+
90
+ cli.option('--port <port>', z.number().describe('Port'))
91
+
92
+ try {
93
+ cli.parse('node bin --port abc'.split(' '))
94
+ } catch {}
95
+
96
+ expect(stripStackTrace(stderr.text)).toMatchInlineSnapshot(`"error: Invalid value for --port: expected number, got "abc""`)
97
+ })
98
+
99
+ test('error includes help hint when help is enabled', () => {
100
+ const stderr = createTestOutputStream()
101
+ const cli = goke('mycli', { stderr, exit: () => {} })
102
+
103
+ cli.help()
104
+
105
+ cli
106
+ .command('serve', 'Start server')
107
+ .option('--port <port>', 'Port')
108
+ .action(() => {})
109
+
110
+ try {
111
+ cli.parse('node bin serve --port'.split(' '))
112
+ } catch {}
113
+
114
+ expect(stripStackTrace(stderr.text)).toMatchInlineSnapshot(`
115
+ "error: option \`--port <port>\` value is missing
116
+ Run "mycli serve --help" for usage information."
117
+ `)
118
+ })
119
+
120
+ test('async action error prints formatted error to stderr', async () => {
121
+ const stderr = createTestOutputStream()
122
+ let exitCode: number | undefined
123
+ const cli = goke('mycli', { stderr, exit: (code) => { exitCode = code } })
124
+
125
+ cli
126
+ .command('deploy', 'Deploy app')
127
+ .action(async () => {
128
+ throw new Error('connection refused')
129
+ })
130
+
131
+ cli.parse('node bin deploy'.split(' '))
132
+
133
+ // Wait for the async rejection to be handled
134
+ await new Promise(resolve => setTimeout(resolve, 10))
135
+
136
+ expect(exitCode).toBe(1)
137
+ expect(stripStackTrace(stderr.text)).toMatchInlineSnapshot(`"error: connection refused"`)
138
+ })
139
+
140
+ test('error output includes stack trace', () => {
141
+ const stderr = createTestOutputStream()
142
+ const cli = goke('mycli', { stderr, exit: () => {} })
143
+
144
+ cli
145
+ .command('build', 'Build app')
146
+ .action(() => {})
147
+
148
+ try {
149
+ cli.parse('node bin build --unknown'.split(' '))
150
+ } catch {}
151
+
152
+ // Verify that stderr contains "error:" prefix and a stack trace with "at" lines
153
+ const text = stderr.text
154
+ expect(text).toContain('error:')
155
+ expect(text).toContain('Unknown option `--unknown`')
156
+ expect(text).toMatch(/at /)
157
+ })
158
+ })
159
+
24
160
  test('double dashes', () => {
25
161
  const cli = goke()
26
162
 
@@ -101,7 +237,7 @@ describe('schema-based options', () => {
101
237
  })
102
238
 
103
239
  test('schema throws on invalid number', () => {
104
- const cli = goke()
240
+ const cli = gokeTestable()
105
241
 
106
242
  cli.option('--port <port>', z.number().describe('Port number'))
107
243
 
@@ -328,7 +464,7 @@ describe('typical CLI usage examples', () => {
328
464
 
329
465
  describe('regression: oracle-found issues', () => {
330
466
  test('required option with schema still throws when value missing', () => {
331
- const cli = goke()
467
+ const cli = gokeTestable()
332
468
  let actionCalled = false
333
469
 
334
470
  cli
@@ -344,7 +480,7 @@ describe('regression: oracle-found issues', () => {
344
480
  })
345
481
 
346
482
  test('repeated flags with non-array schema throws', () => {
347
- const cli = goke()
483
+ const cli = gokeTestable()
348
484
 
349
485
  cli.option('--tag <tag>', z.string().describe('Tags'))
350
486
 
@@ -353,7 +489,7 @@ describe('regression: oracle-found issues', () => {
353
489
  })
354
490
 
355
491
  test('repeated flags with number schema throws', () => {
356
- const cli = goke()
492
+ const cli = gokeTestable()
357
493
 
358
494
  cli.option('--id <id>', z.number().describe('ID'))
359
495
 
@@ -535,7 +671,7 @@ describe('edge cases: boolean flags + schema', () => {
535
671
  })
536
672
 
537
673
  test('invalid boolean string with boolean schema throws', () => {
538
- const cli = goke()
674
+ const cli = gokeTestable()
539
675
 
540
676
  cli.option('--flag <flag>', z.boolean().describe('A flag'))
541
677
 
@@ -587,7 +723,7 @@ describe('edge cases: empty string values', () => {
587
723
  })
588
724
 
589
725
  test('empty string with number schema throws', () => {
590
- const cli = goke()
726
+ const cli = gokeTestable()
591
727
 
592
728
  cli.option('--port <port>', z.number().describe('Port'))
593
729
 
@@ -653,7 +789,7 @@ describe('edge cases: short alias + schema', () => {
653
789
  })
654
790
 
655
791
  test('short alias repeated with non-array schema throws', () => {
656
- const cli = goke()
792
+ const cli = gokeTestable()
657
793
 
658
794
  cli.option('-p, --port <port>', z.number().describe('Port'))
659
795
 
@@ -663,7 +799,7 @@ describe('edge cases: short alias + schema', () => {
663
799
  })
664
800
 
665
801
  test('throw on unknown options', () => {
666
- const cli = goke()
802
+ const cli = gokeTestable()
667
803
 
668
804
  cli
669
805
  .command('build [entry]', 'Build your app')
@@ -834,23 +970,32 @@ describe('space-separated subcommands', () => {
834
970
  expect(stripAnsi(output)).toMatchInlineSnapshot(`
835
971
  "mycli
836
972
 
973
+
837
974
  Usage:
838
975
  $ mycli <command> [options]
839
976
 
977
+
840
978
  Commands:
841
979
  mcp login <url> Login to MCP server
842
980
 
981
+
843
982
  mcp logout Logout from MCP server
844
983
 
984
+
845
985
  mcp status Show connection status
846
986
 
987
+
847
988
  git remote add <name> <url> Add a git remote
848
989
 
990
+
849
991
  git remote remove <name> Remove a git remote
850
992
 
993
+
851
994
  build Build the project
995
+
852
996
  --watch Watch mode
853
997
 
998
+
854
999
  Options:
855
1000
  -h, --help Display this message
856
1001
  "
@@ -1100,6 +1245,113 @@ describe('many commands with root command (empty string)', () => {
1100
1245
  expect(stdout.text).toContain('Stream logs for a deployment')
1101
1246
  })
1102
1247
 
1248
+ test('root help with many commands renders examples section after options', () => {
1249
+ const stdout = createTestOutputStream()
1250
+ const cli = goke('deploy', { stdout })
1251
+
1252
+ cli
1253
+ .command('', 'Deploy the current project')
1254
+ .option('--env <env>', 'Target environment')
1255
+ .option('--dry-run', 'Preview without deploying')
1256
+ .example('# Deploy to staging first')
1257
+ .example('deploy --env staging --dry-run')
1258
+
1259
+ cli.command('init', 'Initialize a new project')
1260
+ cli.command('login', 'Authenticate with the server')
1261
+ cli.command('logout', 'Clear saved credentials')
1262
+ cli.command('status', 'Show deployment status')
1263
+ cli.command('logs <deploymentId>', 'Stream logs for a deployment')
1264
+
1265
+ cli.help()
1266
+ cli.parse(['node', 'bin', '--help'], { run: false })
1267
+
1268
+ expect(stdout.text).toMatchInlineSnapshot(`
1269
+ "deploy
1270
+
1271
+
1272
+ Usage:
1273
+ $ deploy [options]
1274
+
1275
+
1276
+ Commands:
1277
+ deploy Deploy the current project
1278
+
1279
+
1280
+ init Initialize a new project
1281
+
1282
+
1283
+ login Authenticate with the server
1284
+
1285
+
1286
+ logout Clear saved credentials
1287
+
1288
+
1289
+ status Show deployment status
1290
+
1291
+
1292
+ logs <deploymentId> Stream logs for a deployment
1293
+
1294
+
1295
+ Options:
1296
+ --env <env> Target environment
1297
+ --dry-run Preview without deploying
1298
+ -h, --help Display this message
1299
+
1300
+
1301
+ Examples:
1302
+ # Deploy to staging first
1303
+ deploy --env staging --dry-run
1304
+ "
1305
+ `)
1306
+ })
1307
+
1308
+ test('subcommand help renders command examples at the end', () => {
1309
+ const stdout = createTestOutputStream()
1310
+ const cli = goke('deploy', { stdout, columns: 80 })
1311
+
1312
+ cli.command('', 'Deploy the current project')
1313
+ cli.command('init', 'Initialize a new project')
1314
+ cli.command('login', 'Authenticate with the server')
1315
+
1316
+ cli
1317
+ .command('logs <deploymentId>', 'Stream logs for a deployment')
1318
+ .option('--follow', 'Follow log output')
1319
+ .option('--lines <n>', z.number().default(100).describe('Number of lines'))
1320
+ .example('# Stream last 200 lines for a deployment')
1321
+ .example('deploy logs dep_123 --lines 200')
1322
+ .example('# Keep following new log lines')
1323
+ .example('deploy logs dep_123 --follow')
1324
+
1325
+ cli.help()
1326
+ cli.parse(['node', 'bin', 'logs', '--help'], { run: false })
1327
+
1328
+ expect(stdout.text).toMatchInlineSnapshot(`
1329
+ "deploy
1330
+
1331
+
1332
+ Usage:
1333
+ $ deploy logs <deploymentId>
1334
+
1335
+
1336
+ Options:
1337
+ --follow Follow log output
1338
+ --lines <n> Number of lines (default: 100)
1339
+ -h, --help Display this message
1340
+
1341
+
1342
+ Description:
1343
+ Stream logs for a deployment
1344
+
1345
+
1346
+ Examples:
1347
+ # Stream last 200 lines for a deployment
1348
+ deploy logs dep_123 --lines 200
1349
+ # Keep following new log lines
1350
+ deploy logs dep_123 --follow
1351
+ "
1352
+ `)
1353
+ })
1354
+
1103
1355
  test('root help labels default command with cli name and does not duplicate global options', () => {
1104
1356
  const stdout = createTestOutputStream()
1105
1357
  const cli = goke('deploy', { stdout })
@@ -1118,14 +1370,18 @@ describe('many commands with root command (empty string)', () => {
1118
1370
  expect(stdout.text).toMatchInlineSnapshot(`
1119
1371
  "deploy
1120
1372
 
1373
+
1121
1374
  Usage:
1122
1375
  $ deploy [options]
1123
1376
 
1377
+
1124
1378
  Commands:
1125
1379
  deploy Deploy the current project
1126
1380
 
1381
+
1127
1382
  status Show deployment status
1128
1383
 
1384
+
1129
1385
  Options:
1130
1386
  --env <env> Target environment
1131
1387
  --dry-run Preview without deploying
@@ -1156,26 +1412,32 @@ describe('many commands with root command (empty string)', () => {
1156
1412
  expect(stdout.text).toMatchInlineSnapshot(`
1157
1413
  "mycli
1158
1414
 
1415
+
1159
1416
  Usage:
1160
1417
  $ mycli <command> [options]
1161
1418
 
1419
+
1162
1420
  Commands:
1163
1421
  notion-search Perform a semantic search over
1164
1422
  Notion workspace content and
1165
1423
  connected integrations with
1166
1424
  advanced filtering options, date
1167
1425
  filters, and creator filters.
1426
+
1168
1427
  --query <query> Natural language query text to
1169
1428
  search for
1170
1429
  --limit [limit] Maximum number of results to return
1171
1430
  (default: 10)
1172
1431
 
1432
+
1173
1433
  notion-fetch Retrieve a Notion page or database
1174
1434
  by URL or ID and render the result
1175
1435
  in enhanced markdown format for
1176
1436
  terminal output.
1437
+
1177
1438
  --id <id> Notion URL or UUID to fetch
1178
1439
 
1440
+
1179
1441
  Options:
1180
1442
  -h, --help Display this message
1181
1443
  "
@@ -1197,20 +1459,28 @@ describe('many commands with root command (empty string)', () => {
1197
1459
  expect(stdout.text).toMatchInlineSnapshot(`
1198
1460
  "gtui
1199
1461
 
1462
+
1200
1463
  Usage:
1201
1464
  $ gtui <command> [options]
1202
1465
 
1466
+
1203
1467
  Commands:
1204
1468
  auth login Authenticate with Google (opens browser)
1205
1469
 
1470
+
1206
1471
  auth logout Remove stored credentials
1472
+
1207
1473
  --force Skip confirmation
1208
1474
 
1475
+
1209
1476
  mail list List email threads
1477
+
1210
1478
  --folder [folder] Folder to list
1211
1479
 
1480
+
1212
1481
  attachment get <messageId> <attachmentId> Download an attachment
1213
1482
 
1483
+
1214
1484
  Options:
1215
1485
  -h, --help Display this message
1216
1486
  "
@@ -1257,14 +1527,18 @@ describe('many commands with root command (empty string)', () => {
1257
1527
  expect(stdout.text).toMatchInlineSnapshot(`
1258
1528
  "mycli
1259
1529
 
1530
+
1260
1531
  Usage:
1261
1532
  $ mycli <command> [options]
1262
1533
 
1534
+
1263
1535
  Commands:
1264
1536
  notion-search Perform a semantic search over Notion workspace content and connected integrations with advanced filtering options, date filters, and creator filters.
1537
+
1265
1538
  --query <query> Natural language query text to search for
1266
1539
  --limit [limit] Maximum number of results to return (default: 10)
1267
1540
 
1541
+
1268
1542
  Options:
1269
1543
  -h, --help Display this message
1270
1544
  "
@@ -1470,4 +1744,133 @@ describe('schema description and default extraction', () => {
1470
1744
 
1471
1745
  expect(stdout.text).toContain('(default: 3000)')
1472
1746
  })
1747
+
1748
+ test('deprecated options are hidden from help output', () => {
1749
+ const stdout = createTestOutputStream()
1750
+ const cli = goke('mycli', { stdout })
1751
+
1752
+ cli
1753
+ .command('serve', 'Start server')
1754
+ .option('--old <value>', z.string().meta({ deprecated: true, description: 'Old option' }))
1755
+ .option('--new <value>', z.string().describe('Normal option'))
1756
+
1757
+ cli.help()
1758
+ cli.parse(['node', 'bin', 'serve', '--help'], { run: false })
1759
+
1760
+ // Normal option should be visible
1761
+ expect(stdout.text).toContain('--new')
1762
+ expect(stdout.text).toContain('Normal option')
1763
+ // Deprecated option should be hidden
1764
+ expect(stdout.text).not.toContain('--old')
1765
+ expect(stdout.text).not.toContain('Old option')
1766
+ })
1767
+
1768
+ test('deprecated option still works for parsing (just hidden from help)', () => {
1769
+ const cli = gokeTestable('mycli')
1770
+
1771
+ let result: any = {}
1772
+ cli
1773
+ .command('serve', 'Start server')
1774
+ .option('--old <value>', z.string().meta({ deprecated: true, description: 'Old option' }))
1775
+ .action((options) => { result = options })
1776
+
1777
+ cli.parse(['node', 'bin', 'serve', '--old', 'legacy-value'])
1778
+
1779
+ // Deprecated option should still be parsed and usable
1780
+ expect(result.old).toBe('legacy-value')
1781
+ })
1782
+
1783
+ test('deprecated options hidden from global help', () => {
1784
+ const stdout = createTestOutputStream()
1785
+ const cli = goke('mycli', { stdout })
1786
+
1787
+ cli.option('--legacy [value]', z.string().meta({ deprecated: true, description: 'Deprecated global' }))
1788
+ cli.option('--current [value]', z.string().describe('Current option'))
1789
+
1790
+ cli.help()
1791
+ cli.parse(['node', 'bin', '--help'], { run: false })
1792
+
1793
+ expect(stdout.text).toContain('--current')
1794
+ expect(stdout.text).toContain('Current option')
1795
+ expect(stdout.text).not.toContain('--legacy')
1796
+ expect(stdout.text).not.toContain('Deprecated global')
1797
+ })
1798
+ })
1799
+
1800
+ describe('helpText()', () => {
1801
+ test('returns help string without printing', () => {
1802
+ const stdout = createTestOutputStream()
1803
+ const cli = goke('mycli', { stdout })
1804
+
1805
+ cli.command('serve', 'Start server')
1806
+ cli.option('--port <port>', 'Port number')
1807
+ cli.help()
1808
+ // parse a known command so help is not auto-triggered
1809
+ cli.parse(['node', 'bin', 'serve'], { run: false })
1810
+
1811
+ // reset stdout after parse
1812
+ stdout.lines.length = 0
1813
+
1814
+ const text = stripAnsi(cli.helpText())
1815
+
1816
+ expect(text).toContain('mycli')
1817
+ expect(text).toContain('serve')
1818
+ expect(text).toContain('Start server')
1819
+ expect(text).toContain('--port')
1820
+ // helpText() does not print to stdout
1821
+ expect(stdout.text).toBe('')
1822
+ })
1823
+
1824
+ test('returns same content as outputHelp', () => {
1825
+ const stdout = createTestOutputStream()
1826
+ const cli = goke('mycli', { stdout })
1827
+
1828
+ cli.command('build', 'Build project')
1829
+ cli.option('--watch [watch]', 'Watch mode')
1830
+ cli.help()
1831
+ // parse a known command so help is not auto-triggered
1832
+ cli.parse(['node', 'bin', 'build'], { run: false })
1833
+
1834
+ // reset stdout after parse
1835
+ stdout.lines.length = 0
1836
+
1837
+ const helpTextResult = stripAnsi(cli.helpText())
1838
+ cli.outputHelp()
1839
+ // outputHelp adds a trailing newline via console.log
1840
+ const outputHelpResult = stdout.text.replace(/\n$/, '')
1841
+
1842
+ expect(helpTextResult).toBe(outputHelpResult)
1843
+ })
1844
+
1845
+ test('returns subcommand help when command is matched', () => {
1846
+ const cli = goke('mycli')
1847
+
1848
+ cli.command('deploy <env>', 'Deploy to environment')
1849
+ .option('--force', 'Force deploy')
1850
+
1851
+ cli.help()
1852
+ cli.parse(['node', 'bin', 'deploy', '--help'], { run: false })
1853
+
1854
+ const text = stripAnsi(cli.helpText())
1855
+
1856
+ expect(text).toContain('deploy')
1857
+ expect(text).toContain('--force')
1858
+ expect(text).toContain('Force deploy')
1859
+ })
1860
+
1861
+ test('works without calling parse', () => {
1862
+ const cli = goke('mycli')
1863
+
1864
+ cli.command('test', 'Run tests')
1865
+ cli.option('--coverage', 'Enable coverage')
1866
+ cli.help()
1867
+
1868
+ // helpText() works even without parse
1869
+ const text = stripAnsi(cli.helpText())
1870
+
1871
+ expect(text).toContain('mycli')
1872
+ expect(text).toContain('test')
1873
+ expect(text).toContain('Run tests')
1874
+ expect(text).toContain('--coverage')
1875
+ })
1473
1876
  })