@gunshi/docs 0.27.0-beta.4
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/LICENSE +20 -0
- package/package.json +52 -0
- package/src/guide/advanced/advanced-lazy-loading.md +312 -0
- package/src/guide/advanced/command-hooks.md +469 -0
- package/src/guide/advanced/context-extensions.md +545 -0
- package/src/guide/advanced/custom-rendering.md +945 -0
- package/src/guide/advanced/docs-gen.md +594 -0
- package/src/guide/advanced/internationalization.md +677 -0
- package/src/guide/advanced/type-system.md +561 -0
- package/src/guide/essentials/auto-usage.md +281 -0
- package/src/guide/essentials/composable.md +332 -0
- package/src/guide/essentials/declarative.md +724 -0
- package/src/guide/essentials/getting-started.md +252 -0
- package/src/guide/essentials/lazy-async.md +408 -0
- package/src/guide/essentials/plugin-system.md +472 -0
- package/src/guide/essentials/type-safe.md +154 -0
- package/src/guide/introduction/setup.md +68 -0
- package/src/guide/introduction/what-is-gunshi.md +68 -0
- package/src/guide/plugin/decorators.md +545 -0
- package/src/guide/plugin/dependencies.md +519 -0
- package/src/guide/plugin/extensions.md +317 -0
- package/src/guide/plugin/getting-started.md +298 -0
- package/src/guide/plugin/guidelines.md +940 -0
- package/src/guide/plugin/introduction.md +294 -0
- package/src/guide/plugin/lifecycle.md +432 -0
- package/src/guide/plugin/list.md +37 -0
- package/src/guide/plugin/testing.md +843 -0
- package/src/guide/plugin/type-system.md +529 -0
- package/src/index.md +44 -0
- package/src/release/v0.27.md +722 -0
- package/src/showcase.md +11 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
# Plugin Development Guidelines
|
|
2
|
+
|
|
3
|
+
This guide provides practical guidelines for developing reliable, maintainable, and performant Gunshi plugins.
|
|
4
|
+
|
|
5
|
+
While other sections cover implementation details and APIs, this guide focuses on recommended approaches and techniques for production-ready plugins.
|
|
6
|
+
|
|
7
|
+
<!-- eslint-disable markdown/no-missing-label-refs -->
|
|
8
|
+
|
|
9
|
+
> [!TIP]
|
|
10
|
+
> We recommend developing Gunshi plugins with TypeScript for enhanced type safety, better IDE support, and compile-time error detection. All examples and code snippets in this guide are written in TypeScript. While JavaScript plugins are supported, TypeScript helps prevent runtime errors and provides a superior developer experience through auto-completion and type checking.
|
|
11
|
+
|
|
12
|
+
<!-- eslint-enable markdown/no-missing-label-refs -->
|
|
13
|
+
|
|
14
|
+
<!-- eslint-disable markdown/no-missing-label-refs -->
|
|
15
|
+
|
|
16
|
+
> [!NOTE]
|
|
17
|
+
> Some code examples in this guide include TypeScript file extensions (`.ts`) in `import`/`export` statements. If you use this pattern in your plugin, you'll need to enable `allowImportingTsExtensions` in your `tsconfig.json`.
|
|
18
|
+
|
|
19
|
+
<!-- eslint-enable markdown/no-missing-label-refs -->
|
|
20
|
+
|
|
21
|
+
## Design Principles
|
|
22
|
+
|
|
23
|
+
When developing Gunshi plugins, follow these core principles:
|
|
24
|
+
|
|
25
|
+
- **Single Responsibility**: Each plugin should have one clear purpose
|
|
26
|
+
- **Fail Fast**: Validate configuration early and provide clear error messages
|
|
27
|
+
- **Graceful Degradation**: Handle optional dependencies and features gracefully
|
|
28
|
+
- **Type Safety**: Export type definitions for all public interfaces
|
|
29
|
+
- **Performance Conscious**: Use lazy initialization and avoid blocking operations
|
|
30
|
+
|
|
31
|
+
These principles work together to create a robust plugin ecosystem. Single responsibility prevents conflicts and simplifies debugging.
|
|
32
|
+
|
|
33
|
+
Early validation saves time by catching errors at initialization. Graceful degradation ensures compatibility across environments.
|
|
34
|
+
|
|
35
|
+
Type safety prevents runtime errors and improves developer experience. Performance consciousness ensures responsive CLIs with instant feedback.
|
|
36
|
+
|
|
37
|
+
## Naming Conventions
|
|
38
|
+
|
|
39
|
+
### Plugin IDs
|
|
40
|
+
|
|
41
|
+
Use namespaced IDs to prevent conflicts and clearly identify plugin ownership.
|
|
42
|
+
|
|
43
|
+
Namespacing prevents ID collisions in large applications where multiple teams might develop plugins independently.
|
|
44
|
+
|
|
45
|
+
It enables plugin discovery and filtering by namespace, making it easy to identify official plugins versus third-party extensions.
|
|
46
|
+
|
|
47
|
+
Additionally, this convention clarifies ownership and responsibility, helping users understand a plugin's source, maintenance status, and trustworthiness at a glance.
|
|
48
|
+
|
|
49
|
+
The following examples demonstrate different namespacing conventions for plugin IDs:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// Organization namespace
|
|
53
|
+
export const pluginId = 'myorg:logger' as const
|
|
54
|
+
|
|
55
|
+
// Scoped package format
|
|
56
|
+
export const pluginId = '@company/auth' as const
|
|
57
|
+
|
|
58
|
+
// Official Gunshi plugins
|
|
59
|
+
export const pluginId = 'g:i18n' as const
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Package Names
|
|
63
|
+
|
|
64
|
+
Follow consistent naming for plugin packages:
|
|
65
|
+
|
|
66
|
+
- Standalone packages: `gunshi-plugin-{feature}`
|
|
67
|
+
- Scoped packages: `@{org}/gunshi-plugin-{feature}` or `@{org}/gunshi-plugin`
|
|
68
|
+
- Example: `gunshi-plugin-logger`, `@mycompany/gunshi-plugin-auth`, `@feature/gunshi/plugin`
|
|
69
|
+
|
|
70
|
+
<!-- eslint-disable markdown/no-missing-label-refs -->
|
|
71
|
+
|
|
72
|
+
> [!NOTE]
|
|
73
|
+
> Packages following the pattern `@gunshi/plugin-{feature}` (e.g., `@gunshi/plugin-i18n`) are official plugins maintained by the Gunshi team. These are not third-party plugins but are part of the official Gunshi ecosystem. Third-party developers should use their own organization scope or standalone naming as described above.
|
|
74
|
+
|
|
75
|
+
<!-- eslint-enable markdown/no-missing-label-refs -->
|
|
76
|
+
|
|
77
|
+
## Plugin Structure
|
|
78
|
+
|
|
79
|
+
### `gunshi/plugin` vs `@gunshi/plugin`
|
|
80
|
+
|
|
81
|
+
When developing Gunshi plugins, you need to import the `plugin` function and related types.
|
|
82
|
+
|
|
83
|
+
Plugin developers can import from either `gunshi/plugin` or `@gunshi/plugin`. Both provide identical APIs and type definitions.
|
|
84
|
+
|
|
85
|
+
Use `@gunshi/plugin` when you want to minimize your plugin's dependencies and reduce `node_modules` size, as it's a smaller package that only includes plugin-related functionality.
|
|
86
|
+
|
|
87
|
+
For plugin development, we recommend using `@gunshi/plugin` to keep your plugin package lightweight.
|
|
88
|
+
|
|
89
|
+
Here are the two equivalent import options available to plugin developers:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// Option 1: Import from main gunshi package
|
|
93
|
+
import { plugin } from 'gunshi/plugin'
|
|
94
|
+
|
|
95
|
+
// Option 2: Import from dedicated plugin package
|
|
96
|
+
import { plugin } from '@gunshi/plugin'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Factory Function Approach
|
|
100
|
+
|
|
101
|
+
Create plugins as factory functions to allow configuration at initialization.
|
|
102
|
+
|
|
103
|
+
This approach enables configuration flexibility by accepting options at plugin creation time, allowing each instance to be configured independently without relying on global state or environment variables.
|
|
104
|
+
|
|
105
|
+
The factory function should be named after the plugin's primary functionality to make its purpose immediately clear.
|
|
106
|
+
|
|
107
|
+
**Good naming examples:**
|
|
108
|
+
|
|
109
|
+
- `logger()` for a logging plugin
|
|
110
|
+
- `auth()` for an authentication plugin
|
|
111
|
+
- `database()` for a database connection plugin
|
|
112
|
+
|
|
113
|
+
**Avoid generic names:**
|
|
114
|
+
|
|
115
|
+
- `createPlugin()` - Too generic
|
|
116
|
+
- `setup()` - Unclear purpose
|
|
117
|
+
- `init()` - Non-descriptive
|
|
118
|
+
|
|
119
|
+
Here's a complete factory function implementation:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
export interface LoggerOptions {
|
|
123
|
+
level?: 'debug' | 'info' | 'warn' | 'error'
|
|
124
|
+
format?: 'json' | 'text'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default function logger(options: LoggerOptions = {}) {
|
|
128
|
+
const { level = 'info', format = 'text' } = options
|
|
129
|
+
|
|
130
|
+
return plugin({
|
|
131
|
+
id: 'logger',
|
|
132
|
+
extension: () => ({
|
|
133
|
+
log: (message: string) => {
|
|
134
|
+
// Use options to configure behavior
|
|
135
|
+
if (format === 'json') {
|
|
136
|
+
console.log(JSON.stringify({ level, message }))
|
|
137
|
+
} else {
|
|
138
|
+
console.log(`[${level}] ${message}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Export Types and Constants
|
|
147
|
+
|
|
148
|
+
Exporting types enables TypeScript consumers to properly type their code, preventing runtime type mismatches in production.
|
|
149
|
+
|
|
150
|
+
This practice improves IDE autocomplete and IntelliSense, allowing developers to discover your plugin's API more easily.
|
|
151
|
+
|
|
152
|
+
It also enables compile-time verification of correct plugin usage, catching integration errors during development rather than after deployment.
|
|
153
|
+
|
|
154
|
+
The following example shows how to properly export types and constants from your plugin:
|
|
155
|
+
|
|
156
|
+
```ts [types.ts]
|
|
157
|
+
export const pluginId = 'myorg:feature' as const
|
|
158
|
+
export type PluginId = typeof pluginId
|
|
159
|
+
|
|
160
|
+
export interface FeatureExtension {
|
|
161
|
+
process: (data: Data) => Promise<Result>
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```ts [index.ts]
|
|
166
|
+
export * from './types.ts'
|
|
167
|
+
export { default } from './plugin.ts'
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
For detailed type system usage, see [Plugin Type System](./type-system.md).
|
|
171
|
+
|
|
172
|
+
## Error Handling
|
|
173
|
+
|
|
174
|
+
### Validate Early, Fail Fast
|
|
175
|
+
|
|
176
|
+
Early validation helps avoid runtime issues that could occur deep in execution, potentially after performing partial operations or consuming computational resources.
|
|
177
|
+
|
|
178
|
+
By validating in the factory function, you can provide clearer error messages with full context about invalid configuration and specific steps to fix it.
|
|
179
|
+
|
|
180
|
+
This approach helps with debugging by catching errors at initialization rather than during command execution, when the source of the problem may be less clear.
|
|
181
|
+
|
|
182
|
+
The following example demonstrates early validation in the factory function:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
export default function api(endpoint: string) {
|
|
186
|
+
// Validate immediately
|
|
187
|
+
if (!endpoint || !endpoint.startsWith('http')) {
|
|
188
|
+
throw new Error('API plugin requires valid HTTP(S) endpoint URL')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return plugin({
|
|
192
|
+
id: 'api',
|
|
193
|
+
extension: () => ({
|
|
194
|
+
fetch: async (path: string) => {
|
|
195
|
+
// Endpoint is already validated
|
|
196
|
+
return await fetch(`${endpoint}${path}`)
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Provide Actionable Error Messages
|
|
204
|
+
|
|
205
|
+
Clear, actionable error messages reduce debugging time by pointing developers to the root cause and solution.
|
|
206
|
+
|
|
207
|
+
When errors provide specific context, possible causes, and concrete next steps, developers can more easily diagnose and fix issues independently.
|
|
208
|
+
|
|
209
|
+
This example shows how to provide helpful error messages with context and solutions:
|
|
210
|
+
|
|
211
|
+
<!-- eslint-skip -->
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
extension: ctx => ({
|
|
215
|
+
connect: async (url: string) => {
|
|
216
|
+
try {
|
|
217
|
+
return await establishConnection(url)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// Provide context and solution
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Failed to connect to ${url}.\n` +
|
|
222
|
+
`Possible causes:\n` +
|
|
223
|
+
` - Network connectivity issues\n` +
|
|
224
|
+
` - Invalid URL format\n` +
|
|
225
|
+
` - Server is not responding\n` +
|
|
226
|
+
`Try: Verify the URL and your network connection`
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Handle Optional Dependencies Gracefully
|
|
234
|
+
|
|
235
|
+
Check for optional dependencies before using them.
|
|
236
|
+
|
|
237
|
+
Graceful dependency handling ensures your plugin works across different environments and configurations, preventing cascading failures when optional plugins are not available.
|
|
238
|
+
|
|
239
|
+
Choose the appropriate approach based on your needs.
|
|
240
|
+
|
|
241
|
+
The following example demonstrates different patterns for handling optional dependencies:
|
|
242
|
+
|
|
243
|
+
<!-- eslint-skip -->
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
extension: ctx => {
|
|
247
|
+
// Optional dependencies from other plugins
|
|
248
|
+
const logger = ctx.extensions.logger
|
|
249
|
+
const cache = ctx.extensions.cache
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
processData: async (data: Data) => {
|
|
253
|
+
// Use optional chaining (?.) for single operations
|
|
254
|
+
logger?.info(`Processing data: ${data.id}`)
|
|
255
|
+
|
|
256
|
+
// Use if statements for complex logic or multiple operations
|
|
257
|
+
if (cache) {
|
|
258
|
+
const cached = await cache.get(data.id)
|
|
259
|
+
if (cached) {
|
|
260
|
+
logger?.info('Cache hit') // Mix patterns when appropriate
|
|
261
|
+
return cached
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Process the data
|
|
266
|
+
try {
|
|
267
|
+
const result = await doProcessing(data)
|
|
268
|
+
|
|
269
|
+
// Conditional block for related operations
|
|
270
|
+
if (cache && result) {
|
|
271
|
+
await cache.set(data.id, result)
|
|
272
|
+
await cache.setExpiry(data.id, 3600)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
logger?.info('Processing completed')
|
|
276
|
+
return result
|
|
277
|
+
} catch (error) {
|
|
278
|
+
logger?.error(`Failed: ${error.message}`)
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Resource Management
|
|
287
|
+
|
|
288
|
+
After establishing proper error handling, the next important aspect is managing resources effectively.
|
|
289
|
+
|
|
290
|
+
Proper resource management prevents memory leaks and ensures your plugin releases system resources correctly, especially important when errors occur during execution.
|
|
291
|
+
|
|
292
|
+
### Clean Up Resources
|
|
293
|
+
|
|
294
|
+
Proper resource cleanup helps prevent memory leaks in long-running CLI tools.
|
|
295
|
+
|
|
296
|
+
System resources have practical limits - file handles (typically 1024 per process) and database connections (often 100-200 per server) can be exhausted without proper cleanup.
|
|
297
|
+
|
|
298
|
+
The following example demonstrates how to implement cleanup mechanisms for managing multiple database connections.
|
|
299
|
+
|
|
300
|
+
The plugin tracks all created connections in an array and provides a cleanup method that closes them all when the process exits:
|
|
301
|
+
|
|
302
|
+
<!-- eslint-skip -->
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
extension: () => {
|
|
306
|
+
const connections: Connection[] = []
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
connect: async () => {
|
|
310
|
+
const conn = await createConnection()
|
|
311
|
+
connections.push(conn)
|
|
312
|
+
return conn
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
cleanup: async () => {
|
|
316
|
+
await Promise.all(connections.map(c => c.close()))
|
|
317
|
+
connections.length = 0
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Use in `onExtension` or command hooks
|
|
323
|
+
onExtension: ctx => {
|
|
324
|
+
process.once('exit', () => ctx.extensions.myPlugin.cleanup())
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Handle Process Signals
|
|
329
|
+
|
|
330
|
+
Your plugin should respond to system signals for graceful shutdown. The following code shows how to register cleanup handlers that disconnect from a database when the process receives termination signals (SIGINT from Ctrl+C or SIGTERM from system shutdown):
|
|
331
|
+
|
|
332
|
+
<!-- eslint-skip -->
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
onExtension: ctx => {
|
|
336
|
+
const cleanup = async () => {
|
|
337
|
+
await ctx.extensions.database.disconnect()
|
|
338
|
+
process.exit(0)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
process.once('SIGINT', cleanup)
|
|
342
|
+
process.once('SIGTERM', cleanup)
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Performance Considerations
|
|
347
|
+
|
|
348
|
+
With proper resource management in place, you can focus on optimizing when and how resources are created and accessed.
|
|
349
|
+
|
|
350
|
+
The following techniques improve CLI startup time and responsiveness while maintaining the resource cleanup patterns discussed earlier.
|
|
351
|
+
|
|
352
|
+
### Use Lazy Initialization
|
|
353
|
+
|
|
354
|
+
Lazy initialization improves CLI startup time by deferring expensive operations until actually needed.
|
|
355
|
+
|
|
356
|
+
This ensures quick response times for simple commands.
|
|
357
|
+
|
|
358
|
+
The following example demonstrates how to defer database connection initialization until the first query is executed:
|
|
359
|
+
|
|
360
|
+
<!-- eslint-skip -->
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
extension: () => {
|
|
364
|
+
let connection: Database | null = null
|
|
365
|
+
|
|
366
|
+
const getConnection = async () => {
|
|
367
|
+
// Only create the connection on first access
|
|
368
|
+
if (!connection) {
|
|
369
|
+
// Expensive operation deferred until actually needed
|
|
370
|
+
connection = await createConnection()
|
|
371
|
+
}
|
|
372
|
+
return connection
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
query: async (sql: string) => {
|
|
377
|
+
// Lazily initialize connection when query is first called
|
|
378
|
+
const conn = await getConnection()
|
|
379
|
+
return conn.execute(sql)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Avoid Blocking Operations
|
|
386
|
+
|
|
387
|
+
Blocking operations freeze the CLI interface and degrade user experience. Use asynchronous operations to maintain interactivity, especially during initialization and command execution.
|
|
388
|
+
|
|
389
|
+
The following examples contrast blocking and non-blocking approaches to file operations and data processing:
|
|
390
|
+
|
|
391
|
+
<!-- eslint-skip -->
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
extension: () => ({
|
|
395
|
+
// Bad: Blocks the entire CLI during initialization
|
|
396
|
+
loadConfig: () => {
|
|
397
|
+
const data = fs.readFileSync('./config.json', 'utf8') // Blocks!
|
|
398
|
+
return JSON.parse(data)
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
// Good: Non-blocking async operation
|
|
402
|
+
loadConfig: async () => {
|
|
403
|
+
const data = await fs.promises.readFile('./config.json', 'utf8')
|
|
404
|
+
return JSON.parse(data)
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
// Better: Non-blocking with progress feedback
|
|
408
|
+
processLargeDataset: async files => {
|
|
409
|
+
const results = []
|
|
410
|
+
for (const [index, file] of files.entries()) {
|
|
411
|
+
// Process file asynchronously
|
|
412
|
+
const data = await processFile(file)
|
|
413
|
+
results.push(data)
|
|
414
|
+
console.log(`Processing: ${index + 1}/${files.length}`)
|
|
415
|
+
}
|
|
416
|
+
console.log() // New line after progress
|
|
417
|
+
return results
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Optimize Module Loading
|
|
423
|
+
|
|
424
|
+
Loading heavy dependencies at startup increases CLI initialization time and memory usage. Use dynamic imports to load heavy dependencies only when needed, keeping the initial load minimal for fast command response.
|
|
425
|
+
|
|
426
|
+
These examples demonstrate how to use dynamic imports to defer loading heavy dependencies:
|
|
427
|
+
|
|
428
|
+
<!-- eslint-skip -->
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
// Bad: Module-level import loads dependency at startup
|
|
432
|
+
import ExcelJS from 'exceljs' // 4MB loaded at startup!
|
|
433
|
+
|
|
434
|
+
extension: () => ({
|
|
435
|
+
generateReport: async data => {
|
|
436
|
+
const workbook = new ExcelJS.Workbook()
|
|
437
|
+
// Generate report...
|
|
438
|
+
}
|
|
439
|
+
// Other methods that don't use ExcelJS...
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
// Good: Dynamic import loads dependency only when needed
|
|
443
|
+
extension: () => ({
|
|
444
|
+
generateReport: async data => {
|
|
445
|
+
// Load 4MB dependency only when report is actually generated
|
|
446
|
+
const { Workbook } = await import('exceljs')
|
|
447
|
+
const workbook = new Workbook()
|
|
448
|
+
// Generate report...
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
// Better: Combine with user feedback for perceived performance
|
|
452
|
+
exportData: async (format, data) => {
|
|
453
|
+
if (format === 'excel') {
|
|
454
|
+
console.log('Loading Excel export module...')
|
|
455
|
+
const { exportToExcel } = await import('./exporters/excel.js')
|
|
456
|
+
return exportToExcel(data)
|
|
457
|
+
} else if (format === 'pdf') {
|
|
458
|
+
console.log('Loading PDF export module...')
|
|
459
|
+
const { exportToPDF } = await import('./exporters/pdf.js')
|
|
460
|
+
return exportToPDF(data)
|
|
461
|
+
}
|
|
462
|
+
// Default lightweight JSON export
|
|
463
|
+
return JSON.stringify(data)
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
For more `extension` lifecycle details, see [Plugin Extensions](./extensions.md).
|
|
469
|
+
|
|
470
|
+
## Security Considerations
|
|
471
|
+
|
|
472
|
+
### Validate All Inputs
|
|
473
|
+
|
|
474
|
+
User input should be validated and sanitized before use.
|
|
475
|
+
|
|
476
|
+
The following example demonstrates how to validate file paths and extensions to prevent directory traversal attacks and restrict file types:
|
|
477
|
+
|
|
478
|
+
<!-- eslint-skip -->
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
extension: () => ({
|
|
482
|
+
readFile: async (path: string) => {
|
|
483
|
+
// Prevent path traversal
|
|
484
|
+
if (path.includes('..') || path.startsWith('/')) {
|
|
485
|
+
throw new Error('Invalid file path')
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Validate file extension
|
|
489
|
+
const allowed = ['.json', '.yaml', '.yml']
|
|
490
|
+
if (!allowed.some(ext => path.endsWith(ext))) {
|
|
491
|
+
throw new Error('Unsupported file type')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return await fs.readFile(path, 'utf8')
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Protect Sensitive Data
|
|
500
|
+
|
|
501
|
+
Avoid exposing sensitive information in logs or error messages.
|
|
502
|
+
|
|
503
|
+
This example shows how to handle API keys securely in a plugin, validating them without logging sensitive data:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
export default function auth(apiKey: string) {
|
|
507
|
+
// Validate but don't log the key
|
|
508
|
+
if (!apiKey || apiKey.length < 32) {
|
|
509
|
+
throw new Error('Invalid API key format')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return plugin({
|
|
513
|
+
id: 'auth',
|
|
514
|
+
extension: () => ({
|
|
515
|
+
request: async (url: string) => {
|
|
516
|
+
try {
|
|
517
|
+
return await fetch(url, {
|
|
518
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
519
|
+
})
|
|
520
|
+
} catch (error) {
|
|
521
|
+
// Don't include the API key in errors
|
|
522
|
+
throw new Error(`Request failed: ${error.message}`)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Prevent Prototype Pollution
|
|
531
|
+
|
|
532
|
+
Prototype pollution occurs when user-controlled data modifies `Object.prototype`, potentially injecting properties that affect all objects in your application.
|
|
533
|
+
|
|
534
|
+
This vulnerability is particularly dangerous in CLI tools that process configuration files or user-provided options, as attackers can manipulate command behavior through crafted inputs.
|
|
535
|
+
|
|
536
|
+
Use `Object.create(null)` to create objects without a prototype chain when handling user input:
|
|
537
|
+
|
|
538
|
+
<!-- eslint-skip -->
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
extension: () => {
|
|
542
|
+
// Vulnerable: Regular object inherits from Object.prototype
|
|
543
|
+
const userOptions = {} // Can be polluted via __proto__ or constructor
|
|
544
|
+
|
|
545
|
+
// Safe: Object without prototype chain
|
|
546
|
+
const safeOptions = Object.create(null)
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
parseConfig: config => {
|
|
550
|
+
// Safe storage for user-provided data
|
|
551
|
+
const settings = Object.create(null)
|
|
552
|
+
|
|
553
|
+
// Safely merge user config with defaults
|
|
554
|
+
for (const key in config) {
|
|
555
|
+
// Only copy own properties, not inherited ones
|
|
556
|
+
if (Object.prototype.hasOwnProperty.call(config, key)) {
|
|
557
|
+
// Prevent __proto__ and constructor pollution
|
|
558
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
559
|
+
continue
|
|
560
|
+
}
|
|
561
|
+
settings[key] = config[key]
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return settings
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
// Safe command registry without prototype chain
|
|
569
|
+
createCommandMap: commands => {
|
|
570
|
+
const commandMap = Object.create(null)
|
|
571
|
+
|
|
572
|
+
for (const cmd of commands) {
|
|
573
|
+
commandMap[cmd] = true
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Safe to check user input against this map
|
|
577
|
+
return commandMap
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Use `Object.create(null)` specifically when:
|
|
584
|
+
|
|
585
|
+
- Storing user-provided configuration or options
|
|
586
|
+
- Creating lookup maps from external input
|
|
587
|
+
- Building registries from dynamic data
|
|
588
|
+
- Merging multiple configuration sources
|
|
589
|
+
|
|
590
|
+
Regular object literals are safe for:
|
|
591
|
+
|
|
592
|
+
- Internal plugin state
|
|
593
|
+
- Hardcoded configurations
|
|
594
|
+
- Type-checked interfaces
|
|
595
|
+
|
|
596
|
+
## Testing Strategies
|
|
597
|
+
|
|
598
|
+
Focus testing on your plugin's extension factory and how it interacts with the command context.
|
|
599
|
+
|
|
600
|
+
This ensures your plugin properly integrates with Gunshi's lifecycle and handles command metadata correctly.
|
|
601
|
+
|
|
602
|
+
The following example demonstrates how to test your plugin's extension factory and its interaction with the command context:
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
import { describe, test, expect, vi } from 'vitest'
|
|
606
|
+
import myPlugin from './plugin.ts'
|
|
607
|
+
|
|
608
|
+
describe('Plugin Extension', () => {
|
|
609
|
+
test('extension factory creates correct methods', async () => {
|
|
610
|
+
const plugin = myPlugin({ debug: true })
|
|
611
|
+
|
|
612
|
+
// Mock a command context to simulate Gunshi's runtime environment
|
|
613
|
+
const mockContext = {
|
|
614
|
+
name: 'test-command',
|
|
615
|
+
values: { verbose: true },
|
|
616
|
+
log: vi.fn(),
|
|
617
|
+
extensions: {}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const mockCommand = { name: 'test', run: vi.fn() }
|
|
621
|
+
|
|
622
|
+
// Verify the extension factory returns all required plugin methods
|
|
623
|
+
const extension = await plugin.extension(mockContext, mockCommand)
|
|
624
|
+
expect(extension.process).toBeDefined()
|
|
625
|
+
expect(typeof extension.process).toBe('function')
|
|
626
|
+
|
|
627
|
+
// Verify the plugin correctly uses context.log when debug is enabled
|
|
628
|
+
extension.process('data')
|
|
629
|
+
expect(mockContext.log).toHaveBeenCalledWith('[DEBUG]', 'data')
|
|
630
|
+
})
|
|
631
|
+
})
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
For test helpers, lifecycle testing, and integration testing strategies, see [Plugin Testing](./testing.md).
|
|
635
|
+
|
|
636
|
+
## Documentation
|
|
637
|
+
|
|
638
|
+
Comprehensive documentation is crucial for plugin adoption and maintenance.
|
|
639
|
+
|
|
640
|
+
This section provides guidelines and real examples from official Gunshi plugins to help you create effective documentation.
|
|
641
|
+
|
|
642
|
+
### Module-Level Documentation
|
|
643
|
+
|
|
644
|
+
Start your main plugin file with module-level JSDoc that explains the plugin's purpose and provides a complete usage example.
|
|
645
|
+
|
|
646
|
+
The following example from `@gunshi/plugin-global` demonstrates this approach:
|
|
647
|
+
|
|
648
|
+
````ts
|
|
649
|
+
/**
|
|
650
|
+
* The entry point of global options plugin
|
|
651
|
+
*
|
|
652
|
+
* @example
|
|
653
|
+
* ```js
|
|
654
|
+
* import global from '@gunshi/plugin-global'
|
|
655
|
+
* import { cli } from 'gunshi'
|
|
656
|
+
*
|
|
657
|
+
* const entry = (ctx) => {
|
|
658
|
+
* // ...
|
|
659
|
+
* }
|
|
660
|
+
*
|
|
661
|
+
* await cli(process.argv.slice(2), entry, {
|
|
662
|
+
* // ...
|
|
663
|
+
*
|
|
664
|
+
* plugins: [
|
|
665
|
+
* global()
|
|
666
|
+
* ],
|
|
667
|
+
*
|
|
668
|
+
* // ...
|
|
669
|
+
* })
|
|
670
|
+
* ```
|
|
671
|
+
*
|
|
672
|
+
* @module
|
|
673
|
+
*/
|
|
674
|
+
````
|
|
675
|
+
|
|
676
|
+
### Factory Function Documentation
|
|
677
|
+
|
|
678
|
+
Document your factory function comprehensively, including all parameters and return types. Here's an example from `@gunshi/plugin-i18n`:
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
/**
|
|
682
|
+
* i18n plugin
|
|
683
|
+
*
|
|
684
|
+
* @param options - I18n plugin options
|
|
685
|
+
* @returns A defined plugin as i18n
|
|
686
|
+
*/
|
|
687
|
+
export default function i18n(
|
|
688
|
+
options: I18nPluginOptions = {}
|
|
689
|
+
): PluginWithExtension<I18nExtension<DefaultGunshiParams>> {
|
|
690
|
+
// Implementation
|
|
691
|
+
}
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
For plugins with required parameters, include validation guidance:
|
|
695
|
+
|
|
696
|
+
```ts
|
|
697
|
+
/**
|
|
698
|
+
* completion plugin
|
|
699
|
+
*
|
|
700
|
+
* @param options - Completion options
|
|
701
|
+
* @returns A defined plugin as completion
|
|
702
|
+
*/
|
|
703
|
+
export default function completion(options: CompletionOptions = {}): PluginWithoutExtension {
|
|
704
|
+
const config = options.config || {}
|
|
705
|
+
// Validate and use options
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### Extension Interface Documentation
|
|
710
|
+
|
|
711
|
+
Document all methods and properties exposed through your plugin's extension.
|
|
712
|
+
|
|
713
|
+
Here's a concise example:
|
|
714
|
+
|
|
715
|
+
<!-- eslint-skip -->
|
|
716
|
+
|
|
717
|
+
```ts
|
|
718
|
+
/**
|
|
719
|
+
* Extended command context utilities available via `CommandContext.extensions['g:i18n']`.
|
|
720
|
+
*/
|
|
721
|
+
export interface I18nExtension<G extends GunshiParams<any> = DefaultGunshiParams> {
|
|
722
|
+
/** Command locale */
|
|
723
|
+
locale: Intl.Locale
|
|
724
|
+
|
|
725
|
+
/** Translate a message with optional interpolation */
|
|
726
|
+
translate: <K>(key: K, values?: Record<string, unknown>) => string
|
|
727
|
+
|
|
728
|
+
/** Load command resources for the specified locale */
|
|
729
|
+
loadResource: (
|
|
730
|
+
locale: string | Intl.Locale,
|
|
731
|
+
ctx: CommandContext,
|
|
732
|
+
command: Command
|
|
733
|
+
) => Promise<boolean>
|
|
734
|
+
|
|
735
|
+
// Additional methods follow similar documentation patterns
|
|
736
|
+
}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
For complete examples, see the official plugins' source code.
|
|
740
|
+
|
|
741
|
+
### Configuration Options Documentation
|
|
742
|
+
|
|
743
|
+
Document all configuration options with their types, defaults, and purpose:
|
|
744
|
+
|
|
745
|
+
```ts
|
|
746
|
+
/**
|
|
747
|
+
* i18n plugin options
|
|
748
|
+
*/
|
|
749
|
+
export interface I18nPluginOptions {
|
|
750
|
+
/** Locale to use for translations */
|
|
751
|
+
locale?: string | Intl.Locale
|
|
752
|
+
|
|
753
|
+
/** Translation adapter factory */
|
|
754
|
+
translationAdapterFactory?: TranslationAdapterFactory
|
|
755
|
+
|
|
756
|
+
/** Built-in localizable resources */
|
|
757
|
+
builtinResources?: Record<string, Record<BuiltinResourceKeys, string>>
|
|
758
|
+
}
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
Nested interfaces follow the same documentation pattern with JSDoc comments for each property.
|
|
762
|
+
|
|
763
|
+
### Type Documentation Guidelines
|
|
764
|
+
|
|
765
|
+
Export all public types with clear documentation:
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
/** The unique identifier for the i18n plugin */
|
|
769
|
+
export const pluginId = namespacedId('i18n')
|
|
770
|
+
export type PluginId = typeof pluginId
|
|
771
|
+
|
|
772
|
+
/** Command resource type with dynamic argument keys */
|
|
773
|
+
export type CommandResource<G extends GunshiParamsConstraint = DefaultGunshiParams> = {
|
|
774
|
+
description: string
|
|
775
|
+
// Dynamic properties based on command arguments
|
|
776
|
+
} & { [key: string]: string }
|
|
777
|
+
|
|
778
|
+
/** Async function to fetch command resources */
|
|
779
|
+
export type CommandResourceFetcher<G extends GunshiParamsConstraint> = (
|
|
780
|
+
ctx: Readonly<CommandContext<G>>
|
|
781
|
+
) => Awaitable<CommandResource<G>>
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
For more complex generic types, see official plugin implementations.
|
|
785
|
+
|
|
786
|
+
### Plugin Dependencies Documentation
|
|
787
|
+
|
|
788
|
+
Document plugin dependencies clearly:
|
|
789
|
+
|
|
790
|
+
<!-- eslint-skip -->
|
|
791
|
+
|
|
792
|
+
```ts
|
|
793
|
+
return plugin<...>({
|
|
794
|
+
id: pluginId,
|
|
795
|
+
name: 'completion',
|
|
796
|
+
dependencies: [{ id: namespacedId('i18n'), optional: true }] as const
|
|
797
|
+
// ...
|
|
798
|
+
})
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
In your README, explain:
|
|
802
|
+
|
|
803
|
+
- Dependency ID and whether it's optional/required
|
|
804
|
+
- Purpose and effect when present
|
|
805
|
+
- Any automatic behaviors or integration points
|
|
806
|
+
|
|
807
|
+
### README Template Structure
|
|
808
|
+
|
|
809
|
+
<!-- eslint-disable markdown/no-missing-label-refs -->
|
|
810
|
+
|
|
811
|
+
> [!NOTE]
|
|
812
|
+
> Gunshi plans to provide official tooling for plugin authors to automatically generate README templates in future releases. This tooling will help scaffold standard README files with the correct structure, sections, and formatting. Until then, the following template demonstrates the recommended structure for plugin README files.
|
|
813
|
+
|
|
814
|
+
<!-- eslint-enable markdown/no-missing-label-refs -->
|
|
815
|
+
|
|
816
|
+
A comprehensive README should follow this structure:
|
|
817
|
+
|
|
818
|
+
```md [README.md]
|
|
819
|
+
# @yourorg/gunshi-plugin-{name}
|
|
820
|
+
|
|
821
|
+
> Brief description of what your plugin does.
|
|
822
|
+
|
|
823
|
+
## Installation
|
|
824
|
+
|
|
825
|
+
\`\`\`sh
|
|
826
|
+
|
|
827
|
+
### npm
|
|
828
|
+
|
|
829
|
+
npm install --save @yourorg/gunshi-plugin-{name}
|
|
830
|
+
\`\`\`
|
|
831
|
+
|
|
832
|
+
For other package managers, see [installation guide](./docs/install.md).
|
|
833
|
+
|
|
834
|
+
## Usage
|
|
835
|
+
|
|
836
|
+
<!-- eslint-disable markdown/no-missing-label-refs, markdown/no-space-in-emphasis -->
|
|
837
|
+
|
|
838
|
+
\`\`\`ts
|
|
839
|
+
import { cli } from 'gunshi'
|
|
840
|
+
import myPlugin from '@yourorg/gunshi-plugin-{name}'
|
|
841
|
+
|
|
842
|
+
const command = {
|
|
843
|
+
name: 'example',
|
|
844
|
+
run: ctx => {
|
|
845
|
+
ctx.extensions['yourorg:{name}'].someMethod()
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
await cli(process.argv.slice(2), command, {
|
|
850
|
+
plugins: [myPlugin({ /* options */ })]
|
|
851
|
+
})
|
|
852
|
+
\`\`\`
|
|
853
|
+
|
|
854
|
+
<!-- eslint-enable markdown/no-missing-label-refs, markdown/no-space-in-emphasis -->
|
|
855
|
+
|
|
856
|
+
## Plugin Options
|
|
857
|
+
|
|
858
|
+
See [API documentation](./docs/api.md) for complete options.
|
|
859
|
+
|
|
860
|
+
## Examples
|
|
861
|
+
|
|
862
|
+
See the [examples directory](./examples) for usage examples.
|
|
863
|
+
|
|
864
|
+
## License
|
|
865
|
+
|
|
866
|
+
[MIT](http://opensource.org/licenses/MIT)
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
### API Documentation Generation
|
|
870
|
+
|
|
871
|
+
Generate API documentation directly from your JSDoc comments to maintain a single source of truth.
|
|
872
|
+
|
|
873
|
+
This approach ensures your documentation stays synchronized with your code, as updates to JSDoc comments automatically reflect in generated documentation.
|
|
874
|
+
|
|
875
|
+
Various tools can generate documentation from JSDoc comments:
|
|
876
|
+
|
|
877
|
+
- **[TypeDoc](https://typedoc.org/)** - Recommended for TypeScript projects, generates documentation from TypeScript declarations and JSDoc
|
|
878
|
+
- **[API Extractor](https://api-extractor.com/)** - Microsoft's tool focusing on API review and documentation for TypeScript libraries
|
|
879
|
+
|
|
880
|
+
The following example demonstrates configuring `TypeDoc`, a popular choice for TypeScript plugin projects. Create a `typedoc.config.mjs` file:
|
|
881
|
+
|
|
882
|
+
```js [typedoc.config.mjs]
|
|
883
|
+
// @ts-check
|
|
884
|
+
|
|
885
|
+
export default {
|
|
886
|
+
/**
|
|
887
|
+
* typedoc options
|
|
888
|
+
* ref: https://typedoc.org/documents/Options.html
|
|
889
|
+
*/
|
|
890
|
+
entryPoints: ['./src/index.ts'],
|
|
891
|
+
out: 'docs',
|
|
892
|
+
plugin: ['typedoc-plugin-markdown'],
|
|
893
|
+
readme: 'none',
|
|
894
|
+
groupOrder: ['Variables', 'Functions', 'Classes', 'Interfaces', 'Type Aliases'],
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* typedoc-plugin-markdown options
|
|
898
|
+
* ref: https://typedoc-plugin-markdown.org/docs/options
|
|
899
|
+
*/
|
|
900
|
+
entryFileName: 'index',
|
|
901
|
+
hidePageTitle: false,
|
|
902
|
+
useCodeBlocks: true,
|
|
903
|
+
disableSources: true,
|
|
904
|
+
indexFormat: 'table',
|
|
905
|
+
parametersFormat: 'table',
|
|
906
|
+
interfacePropertiesFormat: 'table',
|
|
907
|
+
classPropertiesFormat: 'table',
|
|
908
|
+
propertyMembersFormat: 'table',
|
|
909
|
+
typeAliasPropertiesFormat: 'table',
|
|
910
|
+
enumMembersFormat: 'table'
|
|
911
|
+
}
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
Add documentation scripts to your `package.json`:
|
|
915
|
+
|
|
916
|
+
```json [package.json]
|
|
917
|
+
{
|
|
918
|
+
"scripts": {
|
|
919
|
+
"docs": "typedoc",
|
|
920
|
+
"docs:watch": "typedoc --watch",
|
|
921
|
+
"docs:clean": "rm -rf docs"
|
|
922
|
+
},
|
|
923
|
+
"devDependencies": {
|
|
924
|
+
"typedoc": "^0.26.0",
|
|
925
|
+
"typedoc-plugin-markdown": "^4.0.0"
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
This configuration extracts documentation from your JSDoc comments and TypeScript types, generating comprehensive API documentation without manual maintenance.
|
|
931
|
+
|
|
932
|
+
The generated documentation includes all exported functions, interfaces, types, and their associated JSDoc descriptions, ensuring consistency between code and documentation.
|
|
933
|
+
|
|
934
|
+
## Next Steps
|
|
935
|
+
|
|
936
|
+
Following these guidelines ensures your plugins are production-ready, maintainable, and provide excellent developer experience. You've learned naming conventions, error handling patterns, performance considerations, and documentation strategies.
|
|
937
|
+
|
|
938
|
+
Now that you understand how to build high-quality plugins, explore the existing ecosystem to see these principles in action and find plugins that can enhance your CLI.
|
|
939
|
+
|
|
940
|
+
The next chapter on [Plugin List](./list.md) showcases official plugins maintained by the Gunshi team and community contributions.
|