@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.
- package/CHANGELOG.md +41 -0
- package/README.md +26 -2
- package/dist/dx/terminal/index.d.ts +2 -1
- package/dist/dx/terminal/index.js +3 -1
- package/dist/dx/terminal/intro-outro.d.ts +4 -0
- package/dist/dx/terminal/intro-outro.js +44 -0
- package/dist/dx/terminal/output.d.ts +13 -1
- package/dist/dx/terminal/output.js +43 -2
- package/dist/dx/tui/index.d.ts +12 -0
- package/dist/dx/tui/index.js +12 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/tui/component-adapters/autocomplete.d.ts +15 -0
- package/dist/tui/component-adapters/autocomplete.js +34 -0
- package/dist/tui/component-adapters/note.d.ts +7 -0
- package/dist/tui/component-adapters/note.js +23 -0
- package/dist/tui/component-adapters/password.d.ts +7 -0
- package/dist/tui/component-adapters/password.js +24 -0
- package/dist/tui/component-adapters/progress.d.ts +7 -0
- package/dist/tui/component-adapters/progress.js +44 -0
- package/dist/tui/component-adapters/spinner.d.ts +10 -0
- package/dist/tui/component-adapters/spinner.js +48 -0
- package/dist/tui/component-adapters/tasks.d.ts +9 -0
- package/dist/tui/component-adapters/tasks.js +31 -0
- package/docs/component-reference.md +474 -0
- package/docs/getting-started.md +242 -0
- package/docs/help-contract-spec.md +29 -0
- package/docs/integration-examples.md +677 -0
- package/docs/module-authoring-guide.md +156 -0
- package/docs/snap-args.md +323 -0
- package/docs/snap-help.md +372 -0
- package/docs/snap-runtime.md +394 -0
- package/docs/snap-terminal.md +410 -0
- package/docs/snap-tui.md +529 -0
- 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
|
+
```
|