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/__test__/index.test.js +124 -0
- package/dist/__test__/just-bash.test.js +109 -0
- package/dist/__test__/types.test-d.js +37 -0
- package/dist/goke.d.ts +99 -2
- package/dist/goke.d.ts.map +1 -1
- package/dist/goke.js +62 -34
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/just-bash.d.ts +1 -1
- package/dist/just-bash.d.ts.map +1 -1
- package/dist/just-bash.js +80 -15
- package/package.json +1 -1
- package/src/__test__/index.test.ts +166 -0
- package/src/__test__/just-bash.test.ts +125 -1
- package/src/__test__/types.test-d.ts +41 -0
- package/src/goke.ts +130 -13
- package/src/index.ts +1 -1
- package/src/just-bash.ts +92 -18
- package/README.md +0 -1254
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
211
|
-
stderr: stderr
|
|
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
|
|
219
|
-
stderr: stderr
|
|
283
|
+
stdout: result.stdout,
|
|
284
|
+
stderr: result.stderr,
|
|
220
285
|
exitCode: error.code,
|
|
221
286
|
};
|
|
222
287
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
1206
|
-
stderr
|
|
1207
|
-
exit: (code
|
|
1208
|
-
|
|
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
|
-
|
|
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"
|