@levu/snap 0.1.1 → 0.2.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +26 -2
  3. package/dist/dx/terminal/index.d.ts +2 -1
  4. package/dist/dx/terminal/index.js +3 -1
  5. package/dist/dx/terminal/intro-outro.d.ts +4 -0
  6. package/dist/dx/terminal/intro-outro.js +44 -0
  7. package/dist/dx/terminal/output.d.ts +13 -1
  8. package/dist/dx/terminal/output.js +43 -2
  9. package/dist/dx/tui/index.d.ts +12 -0
  10. package/dist/dx/tui/index.js +12 -0
  11. package/dist/index.d.ts +4 -0
  12. package/dist/index.js +2 -0
  13. package/dist/tui/component-adapters/autocomplete.d.ts +15 -0
  14. package/dist/tui/component-adapters/autocomplete.js +34 -0
  15. package/dist/tui/component-adapters/note.d.ts +7 -0
  16. package/dist/tui/component-adapters/note.js +23 -0
  17. package/dist/tui/component-adapters/password.d.ts +7 -0
  18. package/dist/tui/component-adapters/password.js +24 -0
  19. package/dist/tui/component-adapters/progress.d.ts +7 -0
  20. package/dist/tui/component-adapters/progress.js +44 -0
  21. package/dist/tui/component-adapters/spinner.d.ts +10 -0
  22. package/dist/tui/component-adapters/spinner.js +48 -0
  23. package/dist/tui/component-adapters/tasks.d.ts +9 -0
  24. package/dist/tui/component-adapters/tasks.js +31 -0
  25. package/docs/component-reference.md +474 -0
  26. package/docs/getting-started.md +242 -0
  27. package/docs/help-contract-spec.md +29 -0
  28. package/docs/integration-examples.md +677 -0
  29. package/docs/module-authoring-guide.md +156 -0
  30. package/docs/snap-args.md +323 -0
  31. package/docs/snap-help.md +372 -0
  32. package/docs/snap-runtime.md +394 -0
  33. package/docs/snap-terminal.md +410 -0
  34. package/docs/snap-tui.md +529 -0
  35. package/package.json +4 -2
@@ -0,0 +1,29 @@
1
+ # Help Contract Spec
2
+
3
+ ## Output Contract
4
+
5
+ Help renderer must produce deterministic text sections in this order:
6
+
7
+ 1. `# HELP`
8
+ 2. `MODULE: <module-id>`
9
+ 3. `ACTION: <action-id|*>`
10
+ 4. Section blocks:
11
+ - `## MODULE`
12
+ - `## ACTIONS`
13
+ - `## SUMMARY`
14
+ - `## ARGS`
15
+ - `## EXAMPLES`
16
+ - `## USE-CASES`
17
+ - `## KEYBINDINGS`
18
+
19
+ Each line item in section body uses `- <content>`.
20
+
21
+ ## CLI Levels
22
+
23
+ - `hub -h` => module overview list.
24
+ - `hub -h <module>` => module scoped list.
25
+ - `hub -h <module> <action>` => action detail.
26
+
27
+ ## Validation
28
+
29
+ Missing target returns non-zero exit code with deterministic error message.
@@ -0,0 +1,677 @@
1
+ # Integration Examples
2
+
3
+ This guide provides practical examples for common integration patterns with the Snap framework.
4
+
5
+ ## Multi-Module CLI
6
+
7
+ ### Standard CLI Structure
8
+
9
+ ```
10
+ mytool/
11
+ ├── src/
12
+ │ ├── cli.ts # CLI entry point
13
+ │ ├── modules/
14
+ │ │ ├── deploy/
15
+ │ │ │ └── module.ts
16
+ │ │ ├── database/
17
+ │ │ │ └── module.ts
18
+ │ │ └── index.ts # Module registry
19
+ │ └── index.ts # Barrel export
20
+ ├── package.json
21
+ └── tsconfig.json
22
+ ```
23
+
24
+ ### CLI Entry Point (`cli.ts`)
25
+
26
+ ```typescript
27
+ #!/usr/bin/env node
28
+ import { createRegistry, runMultiModuleCli } from 'snap-framework';
29
+ import { modules } from './modules/index.js';
30
+
31
+ const registry = createRegistry(modules);
32
+
33
+ await runMultiModuleCli({
34
+ registry,
35
+ cliName: 'mytool'
36
+ });
37
+ ```
38
+
39
+ ### Module Registry (`modules/index.ts`)
40
+
41
+ ```typescript
42
+ import type { ModuleContract } from 'snap-framework';
43
+ import deployModule from './deploy/module.js';
44
+ import databaseModule from './database/module.js';
45
+
46
+ export const modules: ModuleContract[] = [
47
+ deployModule,
48
+ databaseModule
49
+ ];
50
+ ```
51
+
52
+ ### Usage
53
+
54
+ ```bash
55
+ mytool -h # List all modules
56
+ mytool deploy -h # Module help
57
+ mytool deploy start --env=prod
58
+ mytool database migrate
59
+ ```
60
+
61
+ ## Single-Module CLI
62
+
63
+ For dedicated tools that focus on one domain:
64
+
65
+ ### CLI Entry Point
66
+
67
+ ```typescript
68
+ #!/usr/bin/env node
69
+ import { createRegistry, runSingleModuleCli } from 'snap-framework';
70
+ import myModule from './module.js';
71
+
72
+ const registry = createRegistry([myModule]);
73
+
74
+ await runSingleModuleCli({
75
+ registry,
76
+ moduleSelector: (args) => {
77
+ // Could conditionally return different modules
78
+ return myModule;
79
+ },
80
+ defaultActionId: 'start' // Makes `mytool` equivalent to `mytool start`
81
+ });
82
+ ```
83
+
84
+ ### Usage
85
+
86
+ ```bash
87
+ mytool # Runs default action (start)
88
+ mytool start # Explicit action
89
+ mytool start --option=value
90
+ ```
91
+
92
+ ## Submodule CLI
93
+
94
+ For tools organized by feature submodules (like the alias example):
95
+
96
+ ### App Structure
97
+
98
+ ```typescript
99
+ // src/app.ts
100
+ import type { AppContract } from 'snap-framework';
101
+ import { featureModules, submoduleRoutes } from './modules/index.js';
102
+
103
+ export const app = {
104
+ modules: featureModules,
105
+ submodules: submoduleRoutes,
106
+ defaultSubmoduleId: 'default' // Optional default submodule
107
+ } as const;
108
+ ```
109
+
110
+ ### Submodule Routes
111
+
112
+ ```typescript
113
+ // src/modules/index.ts
114
+ import type { ModuleContract, SubmoduleRoute } from 'snap-framework';
115
+ import defaultModule from './default/module.js';
116
+ import featureA from './feature-a/module.js';
117
+ import featureB from './feature-b/module.js';
118
+
119
+ export const featureModules: ModuleContract[] = [
120
+ defaultModule,
121
+ featureA,
122
+ featureB
123
+ ];
124
+
125
+ const toDefaultAction = (moduleContract: ModuleContract): string =>
126
+ moduleContract.actions[0]?.actionId ?? moduleContract.moduleId;
127
+
128
+ export const submoduleRoutes: SubmoduleRoute[] = featureModules.map((moduleContract) => ({
129
+ moduleId: moduleContract.moduleId,
130
+ defaultActionId: toDefaultAction(moduleContract),
131
+ helpDefaultTarget: 'action'
132
+ }));
133
+ ```
134
+
135
+ ### CLI Entry Point
136
+
137
+ ```typescript
138
+ #!/usr/bin/env node
139
+ import { runSubmoduleCli } from 'snap-framework';
140
+ import { app } from './app.js';
141
+
142
+ await runSubmoduleCli({
143
+ app,
144
+ cliName: 'mytool'
145
+ });
146
+ ```
147
+
148
+ ### Usage
149
+
150
+ ```bash
151
+ mytool -h # List all submodules
152
+ mytool default -h # Default submodule help
153
+ mytool feature-a action-name # Specific action
154
+ mytool feature-a # Runs default action for feature-a
155
+ ```
156
+
157
+ ## Environment Variable Integration
158
+
159
+ ### Collecting Environment Variables
160
+
161
+ ```typescript
162
+ import * as SnapArgs from 'snap-framework';
163
+
164
+ run: async (context) => {
165
+ // Collect all MYAPP_* prefixed env vars
166
+ const envArgs = SnapArgs.collectUpperSnakeCaseEnvArgs(context.args, 'MYAPP_');
167
+
168
+ const apiKey = envArgs.MYAPP_API_KEY;
169
+ const region = envArgs.MYAPP_REGION;
170
+ const debug = envArgs.MYAPP_DEBUG;
171
+
172
+ return {
173
+ ok: true,
174
+ mode: context.mode,
175
+ exitCode: ExitCode.SUCCESS,
176
+ data: { apiKey, region, debug }
177
+ };
178
+ }
179
+ ```
180
+
181
+ ### Fallback Pattern: CLI → ENV → Default
182
+
183
+ ```typescript
184
+ run: async (context) => {
185
+ // Try CLI arg first, then environment, then default
186
+ const environment =
187
+ SnapArgs.readStringArg(context.args, 'environment', 'env') ??
188
+ SnapArgs.readStringArg(process.env as any, 'MYAPP_ENVIRONMENT') ??
189
+ 'development';
190
+
191
+ return {
192
+ ok: true,
193
+ mode: context.mode,
194
+ exitCode: ExitCode.SUCCESS,
195
+ data: { environment }
196
+ };
197
+ }
198
+ ```
199
+
200
+ ## Configuration File Integration
201
+
202
+ ### Loading Configuration
203
+
204
+ ```typescript
205
+ import { readFileSync } from 'node:fs';
206
+ import { resolve } from 'node:path';
207
+
208
+ interface AppConfig {
209
+ apiUrl: string;
210
+ timeout: number;
211
+ retries: number;
212
+ }
213
+
214
+ const loadConfig = (path: string): AppConfig => {
215
+ const resolved = resolve(path);
216
+ const content = readFileSync(resolved, 'utf-8');
217
+ return JSON.parse(content);
218
+ };
219
+
220
+ const configModule: ModuleContract = {
221
+ moduleId: 'config',
222
+ description: 'Configuration management',
223
+ actions: [
224
+ {
225
+ actionId: 'validate',
226
+ description: 'Validate configuration file',
227
+ tui: { steps: ['collect-path', 'show-results'] },
228
+ commandline: { requiredArgs: ['config'] },
229
+ help: {
230
+ summary: 'Validate configuration file.',
231
+ args: [{ name: 'config', required: true, description: 'Config file path' }]
232
+ },
233
+ run: async (context) => {
234
+ const configPath = String(context.args.config ?? '');
235
+ const config = loadConfig(configPath);
236
+
237
+ // Validation logic
238
+ const errors: string[] = [];
239
+
240
+ if (!config.apiUrl) errors.push('apiUrl is required');
241
+ if (config.timeout < 0) errors.push('timeout must be positive');
242
+ if (config.retries < 0) errors.push('retries must be non-negative');
243
+
244
+ if (errors.length > 0) {
245
+ context.terminal.error('Configuration errors:');
246
+ context.terminal.lines(errors.map(e => ` ✗ ${e}`));
247
+
248
+ return {
249
+ ok: false,
250
+ mode: context.mode,
251
+ exitCode: ExitCode.VALIDATION_ERROR,
252
+ errorMessage: errors.join('; ')
253
+ };
254
+ }
255
+
256
+ context.terminal.line('✓ Configuration is valid');
257
+ return {
258
+ ok: true,
259
+ mode: context.mode,
260
+ exitCode: ExitCode.SUCCESS,
261
+ data: config
262
+ };
263
+ }
264
+ }
265
+ ]
266
+ };
267
+ ```
268
+
269
+ ## API Integration
270
+
271
+ ### Fetch-Based Action
272
+
273
+ ```typescript
274
+ import * as SnapRuntime from 'snap-framework';
275
+
276
+ const apiModule: ModuleContract = {
277
+ moduleId: 'api',
278
+ description: 'API operations',
279
+ actions: [
280
+ {
281
+ actionId: 'fetch',
282
+ description: 'Fetch from API',
283
+ tui: { steps: ['collect-url', 'show-response'] },
284
+ commandline: { requiredArgs: ['url'] },
285
+ help: {
286
+ summary: 'Fetch data from API endpoint.',
287
+ args: [{ name: 'url', required: true, description: 'API URL' }]
288
+ },
289
+ run: async (context) => {
290
+ return SnapRuntime.runActionSafely({
291
+ context,
292
+ fallbackErrorMessage: 'API request failed',
293
+ execute: async () => {
294
+ const url = String(context.args.url ?? '');
295
+ const response = await fetch(url);
296
+
297
+ if (!response.ok) {
298
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
299
+ }
300
+
301
+ const data = await response.json();
302
+
303
+ context.terminal.line(`Fetched ${JSON.stringify(data).length} bytes`);
304
+
305
+ return data;
306
+ }
307
+ });
308
+ }
309
+ }
310
+ ]
311
+ };
312
+ ```
313
+
314
+ ### With Authentication
315
+
316
+ ```typescript
317
+ run: async (context) => {
318
+ return SnapRuntime.runActionSafely({
319
+ context,
320
+ fallbackErrorMessage: 'API request failed',
321
+ execute: async () => {
322
+ const url = String(context.args.url ?? '');
323
+ const token = SnapArgs.readRequiredStringArg(context.args, 'token');
324
+
325
+ const response = await fetch(url, {
326
+ headers: {
327
+ 'Authorization': `Bearer ${token}`,
328
+ 'Content-Type': 'application/json'
329
+ }
330
+ });
331
+
332
+ if (!response.ok) {
333
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
334
+ }
335
+
336
+ return await response.json();
337
+ }
338
+ });
339
+ }
340
+ ```
341
+
342
+ ## File System Operations
343
+
344
+ ### Safe File Operations
345
+
346
+ ```typescript
347
+ import { readFile, writeFile } from 'node:fs/promises';
348
+ import { resolve } from 'node:path';
349
+
350
+ const fileModule: ModuleContract = {
351
+ moduleId: 'file',
352
+ description: 'File operations',
353
+ actions: [
354
+ {
355
+ actionId: 'process',
356
+ description: 'Process a file',
357
+ tui: { steps: ['collect-path', 'show-results'] },
358
+ commandline: { requiredArgs: ['path'] },
359
+ help: {
360
+ summary: 'Process and transform a file.',
361
+ args: [{ name: 'path', required: true, description: 'File path' }]
362
+ },
363
+ run: async (context) => {
364
+ return SnapRuntime.runActionSafely({
365
+ context,
366
+ fallbackErrorMessage: 'File operation failed',
367
+ execute: async () => {
368
+ const filePath = resolve(String(context.args.path ?? ''));
369
+
370
+ context.terminal.line(`Reading ${filePath}...`);
371
+
372
+ const content = await readFile(filePath, 'utf-8');
373
+
374
+ // Process content
375
+ const processed = content
376
+ .toUpperCase()
377
+ .split('\n')
378
+ .map(line => line.trim())
379
+ .filter(line => line.length > 0)
380
+ .join('\n');
381
+
382
+ context.terminal.line(`Processed ${processed.split('\n').length} lines`);
383
+
384
+ return {
385
+ original: content,
386
+ processed,
387
+ lineCount: processed.split('\n').length
388
+ };
389
+ }
390
+ });
391
+ }
392
+ }
393
+ ]
394
+ };
395
+ ```
396
+
397
+ ## Database Integration
398
+
399
+ ### Query Execution
400
+
401
+ ```typescript
402
+ // Using a hypothetical database client
403
+ import { createClient } from 'my-database-client';
404
+
405
+ const dbModule: ModuleContract = {
406
+ moduleId: 'db',
407
+ description: 'Database operations',
408
+ actions: [
409
+ {
410
+ actionId: 'query',
411
+ description: 'Execute database query',
412
+ tui: { steps: ['collect-query', 'show-results'] },
413
+ commandline: { requiredArgs: ['query'] },
414
+ help: {
415
+ summary: 'Execute a SQL query.',
416
+ args: [{ name: 'query', required: true, description: 'SQL query' }]
417
+ },
418
+ run: async (context) => {
419
+ return SnapRuntime.runActionSafely({
420
+ context,
421
+ fallbackErrorMessage: 'Database query failed',
422
+ execute: async () => {
423
+ const query = String(context.args.query ?? '');
424
+ const connectionString = process.env.DATABASE_URL;
425
+
426
+ if (!connectionString) {
427
+ throw new Error('DATABASE_URL environment variable is required');
428
+ }
429
+
430
+ const client = createClient(connectionString);
431
+
432
+ context.terminal.line('Executing query...');
433
+
434
+ const results = await client.query(query);
435
+
436
+ context.terminal.line(`Returned ${results.rows.length} row(s)`);
437
+
438
+ await client.close();
439
+
440
+ return results.rows;
441
+ }
442
+ });
443
+ }
444
+ }
445
+ ]
446
+ };
447
+ ```
448
+
449
+ ## Workflow Patterns
450
+
451
+ ### Multi-Step Deployment
452
+
453
+ ```typescript
454
+ const deployModule: ModuleContract = {
455
+ moduleId: 'deploy',
456
+ description: 'Deployment automation',
457
+ actions: [
458
+ {
459
+ actionId: 'full',
460
+ description: 'Full deployment pipeline',
461
+ tui: {
462
+ flow: SnapTui.defineTuiFlow({
463
+ entryStepId: 'environment',
464
+ steps: [
465
+ {
466
+ stepId: 'environment',
467
+ title: 'Select Environment',
468
+ components: [
469
+ SnapTui.defineTuiComponent({
470
+ componentId: 'env',
471
+ type: 'select',
472
+ label: 'Environment',
473
+ arg: 'environment',
474
+ required: true,
475
+ options: SnapTui.defineTuiOptions([
476
+ { value: 'staging', label: 'Staging' },
477
+ { value: 'production', label: 'Production' }
478
+ ])
479
+ })
480
+ ]
481
+ },
482
+ {
483
+ stepId: 'options',
484
+ title: 'Deployment Options',
485
+ components: [
486
+ SnapTui.defineTuiComponent({
487
+ componentId: 'skip-tests',
488
+ type: 'confirm',
489
+ label: 'Skip tests?',
490
+ arg: 'skipTests'
491
+ }),
492
+ SnapTui.defineTuiComponent({
493
+ componentId: 'force',
494
+ type: 'confirm',
495
+ label: 'Force deployment?',
496
+ arg: 'force'
497
+ })
498
+ ]
499
+ },
500
+ {
501
+ stepId: 'confirm',
502
+ title: 'Confirm Deployment'
503
+ }
504
+ ]
505
+ })
506
+ },
507
+ commandline: {
508
+ requiredArgs: ['environment'],
509
+ optionalArgs: ['skipTests', 'force']
510
+ },
511
+ help: {
512
+ summary: 'Run full deployment pipeline.',
513
+ args: [
514
+ { name: 'environment', required: true, description: 'Target environment' },
515
+ { name: 'skipTests', required: false, description: 'Skip test suite' },
516
+ { name: 'force', required: false, description: 'Force deployment' }
517
+ ]
518
+ },
519
+ run: async (context) => {
520
+ return SnapRuntime.runActionSafely({
521
+ context,
522
+ fallbackErrorMessage: 'Deployment failed',
523
+ execute: async () => {
524
+ const environment = String(context.args.environment ?? '');
525
+ const skipTests = Boolean(context.args.skipTests);
526
+ const force = Boolean(context.args.force);
527
+
528
+ context.terminal.line(`Starting deployment to ${environment}...`);
529
+
530
+ // Step 1: Run tests (unless skipped)
531
+ if (!skipTests) {
532
+ context.terminal.line('Running tests...');
533
+ await runTests();
534
+ context.terminal.line('✓ Tests passed');
535
+ } else {
536
+ context.terminal.line('⚠ Tests skipped');
537
+ }
538
+
539
+ // Step 2: Build
540
+ context.terminal.line('Building application...');
541
+ await buildApp();
542
+ context.terminal.line('✓ Build complete');
543
+
544
+ // Step 3: Deploy
545
+ context.terminal.line('Deploying...');
546
+ const deploymentResult = await deploy(environment, force);
547
+ context.terminal.line(`✓ Deployed to ${deploymentResult.url}`);
548
+
549
+ return {
550
+ environment,
551
+ url: deploymentResult.url,
552
+ version: deploymentResult.version
553
+ };
554
+ }
555
+ });
556
+ }
557
+ }
558
+ ]
559
+ };
560
+ ```
561
+
562
+ ## Testing Integration
563
+
564
+ ### Testable Module Design
565
+
566
+ ```typescript
567
+ // module.ts
568
+ export const createMyModule = (dependencies: {
569
+ apiClient: ApiClient;
570
+ logger: Logger;
571
+ }): ModuleContract => ({
572
+ moduleId: 'my',
573
+ description: 'My module',
574
+ actions: [
575
+ {
576
+ actionId: 'action',
577
+ description: 'My action',
578
+ tui: { steps: [] },
579
+ commandline: { requiredArgs: [] },
580
+ help: { summary: 'My action' },
581
+ run: async (context) => {
582
+ const result = await dependencies.apiClient.fetch();
583
+ dependencies.logger.log('Result:', result);
584
+
585
+ return {
586
+ ok: true,
587
+ mode: context.mode,
588
+ exitCode: ExitCode.SUCCESS,
589
+ data: result
590
+ };
591
+ }
592
+ }
593
+ ]
594
+ });
595
+ ```
596
+
597
+ ### Usage Tests
598
+
599
+ ```typescript
600
+ import { createRegistry } from 'snap-framework';
601
+ import { createMyModule } from './module.js';
602
+
603
+ describe('my-tool', () => {
604
+ it('should run action successfully', async () => {
605
+ const mockApiClient = {
606
+ fetch: vi.fn().mockResolvedValue({ data: 'test' })
607
+ };
608
+
609
+ const mockLogger = {
610
+ log: vi.fn()
611
+ };
612
+
613
+ const module = createMyModule({ apiClient: mockApiClient, logger: mockLogger });
614
+ const registry = createRegistry([module]);
615
+
616
+ // Test dispatch
617
+ const result = await registry.dispatch({
618
+ moduleId: 'my',
619
+ actionId: 'action',
620
+ args: {}
621
+ });
622
+
623
+ expect(result.ok).toBe(true);
624
+ expect(mockApiClient.fetch).toHaveBeenCalled();
625
+ });
626
+ });
627
+ ```
628
+
629
+ ## Package.json Scripts
630
+
631
+ ```json
632
+ {
633
+ "name": "mytool",
634
+ "version": "1.0.0",
635
+ "type": "module",
636
+ "bin": {
637
+ "mytool": "./dist/cli.js"
638
+ },
639
+ "scripts": {
640
+ "build": "tsc",
641
+ "dev": "tsc && node dist/cli.js",
642
+ "typecheck": "tsc --noEmit",
643
+ "test": "vitest",
644
+ "lint": "eslint src",
645
+ "prepublishOnly": "npm run build"
646
+ },
647
+ "files": [
648
+ "dist",
649
+ "README.md"
650
+ ],
651
+ "exports": {
652
+ ".": "./dist/index.js",
653
+ "./cli": "./dist/cli.js"
654
+ }
655
+ }
656
+ ```
657
+
658
+ ## TypeScript Config
659
+
660
+ ```json
661
+ {
662
+ "compilerOptions": {
663
+ "target": "ES2022",
664
+ "module": "ESNext",
665
+ "moduleResolution": "bundler",
666
+ "esModuleInterop": true,
667
+ "strict": true,
668
+ "skipLibCheck": true,
669
+ "outDir": "./dist",
670
+ "rootDir": "./src",
671
+ "declaration": true,
672
+ "declarationMap": true
673
+ },
674
+ "include": ["src/**/*"],
675
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
676
+ }
677
+ ```