devvami 1.1.2 → 1.3.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/README.md CHANGED
@@ -114,8 +114,19 @@ dvmi tasks assigned # Show tasks assigned to you
114
114
 
115
115
  ```bash
116
116
  dvmi costs get # Analyze AWS costs
117
+ dvmi costs trend # Show 2-month daily AWS cost trend
117
118
  ```
118
119
 
120
+ If `awsProfile` is configured (`dvmi init`), AWS cost commands automatically re-run via
121
+ `aws-vault exec <profile> -- ...` when credentials are missing, so developers can run:
122
+
123
+ ```bash
124
+ dvmi costs get
125
+ dvmi costs trend
126
+ ```
127
+
128
+ without manually prefixing `aws-vault exec`.
129
+
119
130
  ### Documentation
120
131
 
121
132
  ```bash
@@ -164,12 +175,36 @@ Devvami uses your system's **secure credential storage**:
164
175
 
165
176
  - **macOS**: Keychain
166
177
  - **Linux**: Secret Service / pass
167
- - **Windows**: Credential Manager
178
+ - **WSL2**: Windows bridge for browser/GCM + Linux tooling for security setup
179
+ - **Windows (native / non-WSL)**: limited support (see Platform Support)
168
180
 
169
181
  Tokens are **never stored in plain text**. They're stored securely via `@keytar/keytar`.
170
182
 
171
183
  ---
172
184
 
185
+ ## 🖥️ Platform Support
186
+
187
+ ### Fully supported
188
+
189
+ - **macOS**
190
+ - **Linux (Debian/Ubuntu family)**
191
+ - **Windows via WSL2**
192
+
193
+ ### Linux/WSL notes
194
+
195
+ - `dvmi security setup` currently uses `apt-get` for package install (Debian/Ubuntu oriented).
196
+ - `dvmi security setup` requires authenticated `sudo` (`sudo -n true` must pass).
197
+ - On WSL2, browser opening tries `wslview` first, then falls back to `xdg-open`.
198
+
199
+ ### Windows native (non-WSL)
200
+
201
+ - Not fully supported today.
202
+ - Platform detection does not handle `win32` explicitly yet.
203
+ - Some shell assumptions are Unix-centric (for example `which` usage and security setup steps).
204
+ - Recommended path on Windows is to use **WSL2**.
205
+
206
+ ---
207
+
173
208
  ## 📚 Documentation
174
209
 
175
210
  - **Setup**: See [Quick Start](#-quick-start) above
@@ -273,6 +273,29 @@
273
273
  "upgrade.js"
274
274
  ]
275
275
  },
276
+ "welcome": {
277
+ "aliases": [],
278
+ "args": {},
279
+ "description": "Show the dvmi mission dashboard with animated intro",
280
+ "examples": [
281
+ "<%= config.bin %> welcome"
282
+ ],
283
+ "flags": {},
284
+ "hasDynamicHelp": false,
285
+ "hiddenAliases": [],
286
+ "id": "welcome",
287
+ "pluginAlias": "devvami",
288
+ "pluginName": "devvami",
289
+ "pluginType": "core",
290
+ "strict": true,
291
+ "enableJsonFlag": false,
292
+ "isESM": true,
293
+ "relativePath": [
294
+ "src",
295
+ "commands",
296
+ "welcome.js"
297
+ ]
298
+ },
276
299
  "whoami": {
277
300
  "aliases": [],
278
301
  "args": {},
@@ -361,16 +384,20 @@
361
384
  "aliases": [],
362
385
  "args": {
363
386
  "service": {
364
- "description": "Nome del servizio",
387
+ "description": "Service name (used to derive tag filter from config)",
365
388
  "name": "service",
366
- "required": true
389
+ "required": false
367
390
  }
368
391
  },
369
- "description": "Stima costi AWS per un servizio (via Cost Explorer API)",
392
+ "description": "Get AWS costs for a service, grouped by service, tag, or both",
370
393
  "examples": [
394
+ "<%= config.bin %> costs get",
371
395
  "<%= config.bin %> costs get my-service",
372
- "<%= config.bin %> costs get my-api --period mtd",
373
- "<%= config.bin %> costs get my-service --json"
396
+ "<%= config.bin %> costs get --period mtd",
397
+ "<%= config.bin %> costs get --period last-week",
398
+ "<%= config.bin %> costs get --group-by tag --tag-key env",
399
+ "<%= config.bin %> costs get my-service --group-by both --tag-key env",
400
+ "<%= config.bin %> costs get --group-by tag --tag-key env --json"
374
401
  ],
375
402
  "flags": {
376
403
  "json": {
@@ -381,7 +408,7 @@
381
408
  "type": "boolean"
382
409
  },
383
410
  "period": {
384
- "description": "Periodo: last-month, last-week, mtd",
411
+ "description": "Time period: last-month, last-week, mtd",
385
412
  "name": "period",
386
413
  "default": "last-month",
387
414
  "hasDynamicHelp": false,
@@ -392,6 +419,26 @@
392
419
  "mtd"
393
420
  ],
394
421
  "type": "option"
422
+ },
423
+ "group-by": {
424
+ "description": "Grouping dimension: service, tag, or both",
425
+ "name": "group-by",
426
+ "default": "service",
427
+ "hasDynamicHelp": false,
428
+ "multiple": false,
429
+ "options": [
430
+ "service",
431
+ "tag",
432
+ "both"
433
+ ],
434
+ "type": "option"
435
+ },
436
+ "tag-key": {
437
+ "description": "Tag key for grouping when --group-by tag or both",
438
+ "name": "tag-key",
439
+ "hasDynamicHelp": false,
440
+ "multiple": false,
441
+ "type": "option"
395
442
  }
396
443
  },
397
444
  "hasDynamicHelp": false,
@@ -410,6 +457,69 @@
410
457
  "get.js"
411
458
  ]
412
459
  },
460
+ "costs:trend": {
461
+ "aliases": [],
462
+ "args": {},
463
+ "description": "Show a rolling 2-month daily cost trend chart",
464
+ "examples": [
465
+ "<%= config.bin %> costs trend",
466
+ "<%= config.bin %> costs trend --line",
467
+ "<%= config.bin %> costs trend --group-by tag --tag-key env",
468
+ "<%= config.bin %> costs trend --group-by both --tag-key env",
469
+ "<%= config.bin %> costs trend --group-by tag --tag-key env --line",
470
+ "<%= config.bin %> costs trend --json"
471
+ ],
472
+ "flags": {
473
+ "json": {
474
+ "description": "Format output as json.",
475
+ "helpGroup": "GLOBAL",
476
+ "name": "json",
477
+ "allowNo": false,
478
+ "type": "boolean"
479
+ },
480
+ "group-by": {
481
+ "description": "Grouping dimension: service, tag, or both",
482
+ "name": "group-by",
483
+ "default": "service",
484
+ "hasDynamicHelp": false,
485
+ "multiple": false,
486
+ "options": [
487
+ "service",
488
+ "tag",
489
+ "both"
490
+ ],
491
+ "type": "option"
492
+ },
493
+ "tag-key": {
494
+ "description": "Tag key for grouping when --group-by tag or both",
495
+ "name": "tag-key",
496
+ "hasDynamicHelp": false,
497
+ "multiple": false,
498
+ "type": "option"
499
+ },
500
+ "line": {
501
+ "description": "Render as line chart instead of default bar chart",
502
+ "name": "line",
503
+ "allowNo": false,
504
+ "type": "boolean"
505
+ }
506
+ },
507
+ "hasDynamicHelp": false,
508
+ "hiddenAliases": [],
509
+ "id": "costs:trend",
510
+ "pluginAlias": "devvami",
511
+ "pluginName": "devvami",
512
+ "pluginType": "core",
513
+ "strict": true,
514
+ "enableJsonFlag": true,
515
+ "isESM": true,
516
+ "relativePath": [
517
+ "src",
518
+ "commands",
519
+ "costs",
520
+ "trend.js"
521
+ ]
522
+ },
413
523
  "create:repo": {
414
524
  "aliases": [],
415
525
  "args": {
@@ -703,6 +813,82 @@
703
813
  "search.js"
704
814
  ]
705
815
  },
816
+ "logs": {
817
+ "aliases": [],
818
+ "args": {},
819
+ "description": "Browse and query CloudWatch log groups interactively",
820
+ "examples": [
821
+ "<%= config.bin %> logs",
822
+ "<%= config.bin %> logs --group /aws/lambda/my-fn",
823
+ "<%= config.bin %> logs --group /aws/lambda/my-fn --filter \"ERROR\" --since 24h",
824
+ "<%= config.bin %> logs --group /aws/lambda/my-fn --limit 50 --json"
825
+ ],
826
+ "flags": {
827
+ "json": {
828
+ "description": "Format output as json.",
829
+ "helpGroup": "GLOBAL",
830
+ "name": "json",
831
+ "allowNo": false,
832
+ "type": "boolean"
833
+ },
834
+ "group": {
835
+ "char": "g",
836
+ "description": "Log group name — bypasses interactive picker",
837
+ "name": "group",
838
+ "hasDynamicHelp": false,
839
+ "multiple": false,
840
+ "type": "option"
841
+ },
842
+ "filter": {
843
+ "char": "f",
844
+ "description": "CloudWatch filter pattern (empty = all events)",
845
+ "name": "filter",
846
+ "default": "",
847
+ "hasDynamicHelp": false,
848
+ "multiple": false,
849
+ "type": "option"
850
+ },
851
+ "since": {
852
+ "description": "Time window: 1h, 24h, 7d",
853
+ "name": "since",
854
+ "default": "1h",
855
+ "hasDynamicHelp": false,
856
+ "multiple": false,
857
+ "type": "option"
858
+ },
859
+ "limit": {
860
+ "description": "Max log events to return (1–10000)",
861
+ "name": "limit",
862
+ "default": 100,
863
+ "hasDynamicHelp": false,
864
+ "multiple": false,
865
+ "type": "option"
866
+ },
867
+ "region": {
868
+ "char": "r",
869
+ "description": "AWS region (defaults to project config awsRegion)",
870
+ "name": "region",
871
+ "hasDynamicHelp": false,
872
+ "multiple": false,
873
+ "type": "option"
874
+ }
875
+ },
876
+ "hasDynamicHelp": false,
877
+ "hiddenAliases": [],
878
+ "id": "logs",
879
+ "pluginAlias": "devvami",
880
+ "pluginName": "devvami",
881
+ "pluginType": "core",
882
+ "strict": true,
883
+ "enableJsonFlag": true,
884
+ "isESM": true,
885
+ "relativePath": [
886
+ "src",
887
+ "commands",
888
+ "logs",
889
+ "index.js"
890
+ ]
891
+ },
706
892
  "pipeline:logs": {
707
893
  "aliases": [],
708
894
  "args": {
@@ -1346,6 +1532,46 @@
1346
1532
  "list.js"
1347
1533
  ]
1348
1534
  },
1535
+ "security:setup": {
1536
+ "aliases": [],
1537
+ "args": {},
1538
+ "description": "Interactive wizard to install and configure credential protection tools (aws-vault, pass, GPG, Git Credential Manager, macOS Keychain)",
1539
+ "examples": [
1540
+ "<%= config.bin %> security setup",
1541
+ "<%= config.bin %> security setup --json"
1542
+ ],
1543
+ "flags": {
1544
+ "json": {
1545
+ "description": "Format output as json.",
1546
+ "helpGroup": "GLOBAL",
1547
+ "name": "json",
1548
+ "allowNo": false,
1549
+ "type": "boolean"
1550
+ },
1551
+ "help": {
1552
+ "char": "h",
1553
+ "description": "Show CLI help.",
1554
+ "name": "help",
1555
+ "allowNo": false,
1556
+ "type": "boolean"
1557
+ }
1558
+ },
1559
+ "hasDynamicHelp": false,
1560
+ "hiddenAliases": [],
1561
+ "id": "security:setup",
1562
+ "pluginAlias": "devvami",
1563
+ "pluginName": "devvami",
1564
+ "pluginType": "core",
1565
+ "strict": true,
1566
+ "enableJsonFlag": true,
1567
+ "isESM": true,
1568
+ "relativePath": [
1569
+ "src",
1570
+ "commands",
1571
+ "security",
1572
+ "setup.js"
1573
+ ]
1574
+ },
1349
1575
  "tasks:assigned": {
1350
1576
  "aliases": [],
1351
1577
  "args": {},
@@ -1498,5 +1724,5 @@
1498
1724
  ]
1499
1725
  }
1500
1726
  },
1501
- "version": "1.1.2"
1727
+ "version": "1.3.0"
1502
1728
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "devvami",
3
3
  "description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
4
- "version": "1.1.2",
4
+ "version": "1.3.0",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -83,6 +83,7 @@
83
83
  }
84
84
  },
85
85
  "dependencies": {
86
+ "@aws-sdk/client-cloudwatch-logs": "^3.1018.0",
86
87
  "@aws-sdk/client-cost-explorer": "^3",
87
88
  "@inquirer/prompts": "^7",
88
89
  "@oclif/core": "^4",
@@ -1,49 +1,122 @@
1
1
  import { Command, Args, Flags } from '@oclif/core'
2
+ import { input } from '@inquirer/prompts'
2
3
  import ora from 'ora'
3
4
  import { getServiceCosts } from '../../services/aws-costs.js'
4
5
  import { loadConfig } from '../../services/config.js'
5
6
  import { formatCostTable, calculateTotal } from '../../formatters/cost.js'
7
+ import { DvmiError } from '../../utils/errors.js'
8
+ import {
9
+ awsVaultPrefix,
10
+ isAwsVaultSession,
11
+ reexecCurrentCommandWithAwsVault,
12
+ reexecCurrentCommandWithAwsVaultProfile,
13
+ } from '../../utils/aws-vault.js'
6
14
 
7
15
  export default class CostsGet extends Command {
8
- static description = 'Stima costi AWS per un servizio (via Cost Explorer API)'
16
+ static description = 'Get AWS costs for a service, grouped by service, tag, or both'
9
17
 
10
18
  static examples = [
19
+ '<%= config.bin %> costs get',
11
20
  '<%= config.bin %> costs get my-service',
12
- '<%= config.bin %> costs get my-api --period mtd',
13
- '<%= config.bin %> costs get my-service --json',
21
+ '<%= config.bin %> costs get --period mtd',
22
+ '<%= config.bin %> costs get --period last-week',
23
+ '<%= config.bin %> costs get --group-by tag --tag-key env',
24
+ '<%= config.bin %> costs get my-service --group-by both --tag-key env',
25
+ '<%= config.bin %> costs get --group-by tag --tag-key env --json',
14
26
  ]
15
27
 
16
28
  static enableJsonFlag = true
17
29
 
18
30
  static args = {
19
- service: Args.string({ description: 'Nome del servizio', required: true }),
31
+ service: Args.string({ description: 'Service name (used to derive tag filter from config)', required: false }),
20
32
  }
21
33
 
22
34
  static flags = {
23
35
  period: Flags.string({
24
- description: 'Periodo: last-month, last-week, mtd',
36
+ description: 'Time period: last-month, last-week, mtd',
25
37
  default: 'last-month',
26
38
  options: ['last-month', 'last-week', 'mtd'],
27
39
  }),
40
+ 'group-by': Flags.string({
41
+ description: 'Grouping dimension: service, tag, or both',
42
+ default: 'service',
43
+ options: ['service', 'tag', 'both'],
44
+ }),
45
+ 'tag-key': Flags.string({
46
+ description: 'Tag key for grouping when --group-by tag or both',
47
+ }),
28
48
  }
29
49
 
30
50
  async run() {
31
51
  const { args, flags } = await this.parse(CostsGet)
32
52
  const isJson = flags.json
53
+ const isInteractive = !isJson && process.stdout.isTTY && process.env.CI !== 'true'
54
+ const groupBy = /** @type {'service'|'tag'|'both'} */ (flags['group-by'])
33
55
 
34
- const spinner = isJson ? null : ora(`Fetching costs for ${args.service}...`).start()
35
-
36
- // Get project tags from config
37
56
  const config = await loadConfig()
38
- const tags = config.projectTags ?? { project: args.service }
57
+
58
+ if (
59
+ isInteractive &&
60
+ !isAwsVaultSession() &&
61
+ process.env.DVMI_AWS_VAULT_REEXEC !== '1'
62
+ ) {
63
+ const profile = await input({
64
+ message: 'AWS profile (aws-vault):',
65
+ default: config.awsProfile || process.env.AWS_VAULT || 'default',
66
+ })
67
+
68
+ const selected = profile.trim()
69
+ if (!selected) {
70
+ this.error('AWS profile is required to run this command.')
71
+ }
72
+
73
+ const promptedReexecExitCode = await reexecCurrentCommandWithAwsVaultProfile(selected)
74
+ if (promptedReexecExitCode !== null) {
75
+ this.exit(promptedReexecExitCode)
76
+ return
77
+ }
78
+ }
79
+
80
+ // Transparent aws-vault usage: if a profile is configured and no AWS creds are present,
81
+ // re-run this exact command via `aws-vault exec <profile> -- ...`.
82
+ const reexecExitCode = await reexecCurrentCommandWithAwsVault(config)
83
+ if (reexecExitCode !== null) {
84
+ this.exit(reexecExitCode)
85
+ return
86
+ }
87
+
88
+ // Resolve tag key: explicit flag → first key in config projectTags
89
+ const configTagKey = config.projectTags ? Object.keys(config.projectTags)[0] : undefined
90
+ const tagKey = flags['tag-key'] ?? configTagKey
91
+
92
+ // Validate: tag key required when grouping by tag or both
93
+ if ((groupBy === 'tag' || groupBy === 'both') && !tagKey) {
94
+ throw new DvmiError(
95
+ 'No tag key available.',
96
+ 'Pass --tag-key or configure projectTags in dvmi config.',
97
+ )
98
+ }
99
+
100
+ const serviceArg = args.service ?? 'all'
101
+ const tags = config.projectTags ?? (args.service ? { project: args.service } : {})
102
+
103
+ const spinner = isJson ? null : ora(`Fetching costs...`).start()
39
104
 
40
105
  try {
41
- const { entries, period } = await getServiceCosts(args.service, tags, /** @type {any} */ (flags.period))
106
+ const { entries, period } = await getServiceCosts(
107
+ serviceArg,
108
+ tags,
109
+ /** @type {any} */ (flags.period),
110
+ groupBy,
111
+ tagKey,
112
+ )
42
113
  spinner?.stop()
43
114
 
44
115
  const total = calculateTotal(entries)
45
116
  const result = {
46
- service: args.service,
117
+ service: args.service ?? null,
118
+ groupBy,
119
+ tagKey: tagKey ?? null,
47
120
  period,
48
121
  items: entries,
49
122
  total: { amount: total, unit: 'USD' },
@@ -52,21 +125,42 @@ export default class CostsGet extends Command {
52
125
  if (isJson) return result
53
126
 
54
127
  if (entries.length === 0) {
55
- this.log(`No costs found for service "${args.service}".`)
56
- this.log('Check service name and tagging convention.')
128
+ this.log(`No costs found.`)
57
129
  return result
58
130
  }
59
131
 
60
- this.log(formatCostTable(entries, args.service))
132
+ const label = tagKey && groupBy !== 'service' ? `${serviceArg} (by ${tagKey})` : serviceArg
133
+ this.log(formatCostTable(entries, label, groupBy))
61
134
  return result
62
135
  } catch (err) {
63
136
  spinner?.stop()
64
137
  if (String(err).includes('AccessDenied') || String(err).includes('UnauthorizedAccess')) {
65
- this.error('Missing IAM permission: ce:GetCostAndUsage\nContact your AWS admin to grant Cost Explorer access.')
138
+ this.error('Missing IAM permission: ce:GetCostAndUsage. Contact your AWS admin.')
139
+ }
140
+ if (String(err).includes('CredentialsProviderError') || String(err).includes('No credentials')) {
141
+ if (isInteractive) {
142
+ const suggestedProfile = config.awsProfile || process.env.AWS_VAULT || 'default'
143
+ const profile = await input({
144
+ message: 'No AWS credentials. Enter aws-vault profile to retry (empty to cancel):',
145
+ default: suggestedProfile,
146
+ })
147
+
148
+ const selected = profile.trim()
149
+ if (selected) {
150
+ const retryExitCode = await reexecCurrentCommandWithAwsVaultProfile(selected)
151
+ if (retryExitCode !== null) {
152
+ this.exit(retryExitCode)
153
+ return
154
+ }
155
+ }
156
+ }
157
+
158
+ const prefix = awsVaultPrefix(config)
159
+ this.error(
160
+ `No AWS credentials. Use: ${prefix}dvmi costs get` +
161
+ (args.service ? ` ${args.service}` : ''),
162
+ )
66
163
  }
67
- if (String(err).includes('CredentialsProviderError') || String(err).includes('No credentials')) {
68
- this.error('No AWS credentials. Use: aws-vault exec <profile> -- dvmi costs get ' + args.service)
69
- }
70
164
  throw err
71
165
  }
72
166
  }