devvami 1.0.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/LICENSE +21 -0
- package/README.md +255 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/oclif.manifest.json +1238 -0
- package/package.json +161 -0
- package/src/commands/auth/login.js +89 -0
- package/src/commands/changelog.js +102 -0
- package/src/commands/costs/get.js +73 -0
- package/src/commands/create/repo.js +196 -0
- package/src/commands/docs/list.js +110 -0
- package/src/commands/docs/projects.js +92 -0
- package/src/commands/docs/read.js +172 -0
- package/src/commands/docs/search.js +103 -0
- package/src/commands/doctor.js +115 -0
- package/src/commands/init.js +222 -0
- package/src/commands/open.js +75 -0
- package/src/commands/pipeline/logs.js +41 -0
- package/src/commands/pipeline/rerun.js +66 -0
- package/src/commands/pipeline/status.js +62 -0
- package/src/commands/pr/create.js +114 -0
- package/src/commands/pr/detail.js +83 -0
- package/src/commands/pr/review.js +51 -0
- package/src/commands/pr/status.js +70 -0
- package/src/commands/repo/list.js +113 -0
- package/src/commands/search.js +62 -0
- package/src/commands/tasks/assigned.js +131 -0
- package/src/commands/tasks/list.js +133 -0
- package/src/commands/tasks/today.js +73 -0
- package/src/commands/upgrade.js +52 -0
- package/src/commands/whoami.js +85 -0
- package/src/formatters/cost.js +54 -0
- package/src/formatters/markdown.js +108 -0
- package/src/formatters/openapi.js +146 -0
- package/src/formatters/status.js +48 -0
- package/src/formatters/table.js +87 -0
- package/src/help.js +312 -0
- package/src/hooks/init.js +9 -0
- package/src/hooks/postrun.js +18 -0
- package/src/index.js +1 -0
- package/src/services/auth.js +83 -0
- package/src/services/aws-costs.js +80 -0
- package/src/services/clickup.js +288 -0
- package/src/services/config.js +59 -0
- package/src/services/docs.js +210 -0
- package/src/services/github.js +377 -0
- package/src/services/platform.js +48 -0
- package/src/services/shell.js +42 -0
- package/src/services/version-check.js +58 -0
- package/src/types.js +228 -0
- package/src/utils/banner.js +48 -0
- package/src/utils/errors.js +61 -0
- package/src/utils/gradient.js +130 -0
- package/src/utils/open-browser.js +29 -0
- package/src/utils/typewriter.js +48 -0
- package/src/validators/repo-name.js +42 -0
package/src/help.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { Help } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { isColorEnabled } from './utils/gradient.js'
|
|
4
|
+
import { printBanner } from './utils/banner.js'
|
|
5
|
+
|
|
6
|
+
// ─── Brand palette (flat — no gradient on help rows) ────────────────────────
|
|
7
|
+
const ORANGE = '#FF6B2B'
|
|
8
|
+
const LIGHT_ORANGE = '#FF9A5C'
|
|
9
|
+
const DIM_BLUE = '#4A9EFF'
|
|
10
|
+
const DIM_GRAY = '#888888'
|
|
11
|
+
|
|
12
|
+
// Strip ANSI escape codes
|
|
13
|
+
const ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} s
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
const strip = (s) => s.replace(ANSI_RE, '')
|
|
19
|
+
|
|
20
|
+
// ─── Category definitions ────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** @typedef {{ id: string, hint?: string }} CmdEntry */
|
|
23
|
+
/** @typedef {{ title: string, cmds: CmdEntry[] }} Category */
|
|
24
|
+
|
|
25
|
+
/** @type {Category[]} */
|
|
26
|
+
const CATEGORIES = [
|
|
27
|
+
{
|
|
28
|
+
title: 'GitHub & Documentazione',
|
|
29
|
+
cmds: [
|
|
30
|
+
{ id: 'repo:list', hint: '[--language] [--search]' },
|
|
31
|
+
{ id: 'docs:read', hint: '[FILE] [--repo] [--raw] [--render]' },
|
|
32
|
+
{ id: 'docs:list', hint: '[--repo] [--search]' },
|
|
33
|
+
{ id: 'docs:search', hint: '<TERM> [--repo]' },
|
|
34
|
+
{ id: 'docs:projects', hint: '[--search]' },
|
|
35
|
+
{ id: 'create:repo', hint: '[TEMPLATE] [--list] [--name]' },
|
|
36
|
+
{ id: 'search', hint: '<QUERY>' },
|
|
37
|
+
{ id: 'open', hint: '<TARGET>' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
title: 'Pull Request',
|
|
42
|
+
cmds: [
|
|
43
|
+
{ id: 'pr:create', hint: '' },
|
|
44
|
+
{ id: 'pr:status', hint: '' },
|
|
45
|
+
{ id: 'pr:detail', hint: '<PR_NUMBER> --repo <owner/repo>' },
|
|
46
|
+
{ id: 'pr:review', hint: '' },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
title: 'Pipeline & DevOps',
|
|
51
|
+
cmds: [
|
|
52
|
+
{ id: 'pipeline:status', hint: '[--repo] [--branch]' },
|
|
53
|
+
{ id: 'pipeline:rerun', hint: '<RUN_ID> --repo <repo>' },
|
|
54
|
+
{ id: 'pipeline:logs', hint: '<RUN_ID> --repo <repo>' },
|
|
55
|
+
{ id: 'changelog', hint: '' },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
title: 'Tasks (ClickUp)',
|
|
60
|
+
cmds: [
|
|
61
|
+
{ id: 'tasks:list', hint: '[--status] [--search]' },
|
|
62
|
+
{ id: 'tasks:today', hint: '' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: 'Cloud & Costi',
|
|
67
|
+
cmds: [
|
|
68
|
+
{ id: 'costs:get', hint: '[--period] [--profile]' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
title: 'Setup & Ambiente',
|
|
73
|
+
cmds: [
|
|
74
|
+
{ id: 'init', hint: '[--dry-run]' },
|
|
75
|
+
{ id: 'doctor', hint: '' },
|
|
76
|
+
{ id: 'auth:login', hint: '' },
|
|
77
|
+
{ id: 'whoami', hint: '' },
|
|
78
|
+
{ id: 'upgrade', hint: '' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
// ─── Example commands shown at bottom of root help ──────────────────────────
|
|
84
|
+
const EXAMPLES = [
|
|
85
|
+
{ cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
|
|
86
|
+
{ cmd: 'dvmi docs read openapi.yaml', note: 'Tabella endpoints OpenAPI nel terminale' },
|
|
87
|
+
{ cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
|
|
88
|
+
{ cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
|
|
89
|
+
{ cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
|
|
90
|
+
{ cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
|
|
91
|
+
{ cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
|
|
92
|
+
{ cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
// ─── Help class ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Custom help class.
|
|
99
|
+
* - showRootHelp: logo SNTG animato + layout comandi raggruppati per categoria
|
|
100
|
+
* - formatTopic / formatCommand: colorizza flag, descrizioni e esempi
|
|
101
|
+
* - Gradient solo sul logo; tutto il resto usa colori flat chalk
|
|
102
|
+
*/
|
|
103
|
+
export default class CustomHelp extends Help {
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Root help override: banner animato → layout categorizzato.
|
|
107
|
+
* Override di showRootHelp() (async) per evitare che formatRoot() (sync)
|
|
108
|
+
* debba attendere la Promise del banner.
|
|
109
|
+
* @returns {Promise<void>}
|
|
110
|
+
*/
|
|
111
|
+
async showRootHelp() {
|
|
112
|
+
// Animated logo — identical to `dvmi init` (no-ops in CI/non-TTY)
|
|
113
|
+
await printBanner()
|
|
114
|
+
this.log(this.#buildRootLayout())
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {import('@oclif/core').Interfaces.Topic[]} topics
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
formatTopics(topics) {
|
|
122
|
+
return this.#flatColorizeTopics(super.formatTopics(topics))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {import('@oclif/core').Interfaces.Topic} topic
|
|
127
|
+
* @param {import('@oclif/core').Command.Class[]} commands
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
formatTopic(topic, commands) {
|
|
131
|
+
return this.#colorizeRows(super.formatTopic(topic, commands))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {import('@oclif/core').Command.Class} command
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
formatCommand(command) {
|
|
139
|
+
return this.#colorizeRows(super.formatCommand(command))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the full categorized root help layout.
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
#buildRootLayout() {
|
|
149
|
+
/** @type {Map<string, import('@oclif/core').Command.Cached>} */
|
|
150
|
+
const cmdMap = new Map(this.config.commands.map((c) => [c.id, c]))
|
|
151
|
+
|
|
152
|
+
const lines = []
|
|
153
|
+
|
|
154
|
+
// ── Usage ──────────────────────────────────────────────────────────────
|
|
155
|
+
lines.push(this.#sectionHeader('USAGE'))
|
|
156
|
+
lines.push(
|
|
157
|
+
' ' + (isColorEnabled ? chalk.hex(ORANGE).bold('dvmi') : 'dvmi') +
|
|
158
|
+
chalk.dim(' <COMANDO> [FLAGS]\n'),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// ── Comandi per categoria ──────────────────────────────────────────────
|
|
162
|
+
lines.push(this.#sectionHeader('COMMANDS'))
|
|
163
|
+
|
|
164
|
+
for (const cat of CATEGORIES) {
|
|
165
|
+
lines.push(
|
|
166
|
+
' ' + (isColorEnabled ? chalk.hex(ORANGE).bold(cat.title) : cat.title),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
for (const entry of cat.cmds) {
|
|
170
|
+
const cmd = cmdMap.get(entry.id)
|
|
171
|
+
if (!cmd) continue
|
|
172
|
+
|
|
173
|
+
const displayId = entry.id.replaceAll(':', ' ')
|
|
174
|
+
const hint = entry.hint || ''
|
|
175
|
+
const desc = cmd.summary ?? (typeof cmd.description === 'string'
|
|
176
|
+
? cmd.description.split('\n')[0]
|
|
177
|
+
: '')
|
|
178
|
+
|
|
179
|
+
// Left column (name + flags hint), right-padded to align descriptions
|
|
180
|
+
const rawLeft = ' ' + displayId + (hint ? ' ' + hint : '')
|
|
181
|
+
const pad = ' '.repeat(Math.max(2, 50 - rawLeft.length))
|
|
182
|
+
|
|
183
|
+
const leftPart = isColorEnabled
|
|
184
|
+
? ' ' + chalk.hex(LIGHT_ORANGE).bold(displayId) +
|
|
185
|
+
(hint ? ' ' + chalk.dim(hint) : '')
|
|
186
|
+
: rawLeft
|
|
187
|
+
|
|
188
|
+
lines.push(leftPart + pad + chalk.dim(desc))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
lines.push('')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Flag globali ───────────────────────────────────────────────────────
|
|
195
|
+
lines.push(this.#sectionHeader('GLOBAL FLAGS'))
|
|
196
|
+
lines.push(this.#flagLine('-h, --help', 'Mostra aiuto per un comando'))
|
|
197
|
+
lines.push(this.#flagLine(' --json', 'Output in formato JSON strutturato'))
|
|
198
|
+
lines.push(this.#flagLine('-v, --version', 'Versione installata'))
|
|
199
|
+
lines.push('')
|
|
200
|
+
|
|
201
|
+
// ── Esempi ─────────────────────────────────────────────────────────────
|
|
202
|
+
lines.push(this.#sectionHeader('EXAMPLES'))
|
|
203
|
+
|
|
204
|
+
const maxCmdLen = Math.max(...EXAMPLES.map((e) => e.cmd.length))
|
|
205
|
+
for (const ex of EXAMPLES) {
|
|
206
|
+
const pad = ' '.repeat(maxCmdLen - ex.cmd.length + 4)
|
|
207
|
+
const sub = ex.cmd.replace(/^dvmi /, '')
|
|
208
|
+
const formatted = isColorEnabled
|
|
209
|
+
? chalk.dim('$') + ' ' + chalk.hex(ORANGE).bold('dvmi') + ' ' +
|
|
210
|
+
chalk.white(sub) + pad + chalk.hex(DIM_GRAY)(ex.note)
|
|
211
|
+
: '$ ' + ex.cmd + pad + ex.note
|
|
212
|
+
lines.push(' ' + formatted)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
lines.push('')
|
|
216
|
+
lines.push(
|
|
217
|
+
' ' + chalk.dim('Approfondisci:') + ' ' +
|
|
218
|
+
chalk.hex(DIM_BLUE)('dvmi <COMANDO> --help') +
|
|
219
|
+
chalk.dim(' · ') +
|
|
220
|
+
chalk.hex(DIM_BLUE)('dvmi <TOPIC> --help') + '\n',
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return lines.join('\n')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} title
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
#sectionHeader(title) {
|
|
231
|
+
return isColorEnabled ? chalk.hex(ORANGE).bold(title) : title
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {string} flagStr
|
|
236
|
+
* @param {string} desc
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
#flagLine(flagStr, desc) {
|
|
240
|
+
const pad = ' '.repeat(Math.max(2, 18 - flagStr.length))
|
|
241
|
+
return isColorEnabled
|
|
242
|
+
? ' ' + chalk.hex(DIM_BLUE).bold(flagStr) + pad + chalk.dim(desc)
|
|
243
|
+
: ' ' + flagStr + pad + desc
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Colorize topic list with flat orange names (no gradient).
|
|
248
|
+
* @param {string} text
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
#flatColorizeTopics(text) {
|
|
252
|
+
return text
|
|
253
|
+
.split('\n')
|
|
254
|
+
.map((line) => {
|
|
255
|
+
const plain = strip(line)
|
|
256
|
+
if (!plain.trim()) return line
|
|
257
|
+
|
|
258
|
+
const rowMatch = plain.match(/^( )([a-z][\w-]*)(\s{2,})(.+)$/)
|
|
259
|
+
if (rowMatch) {
|
|
260
|
+
const [, indent, name, spaces, desc] = rowMatch
|
|
261
|
+
return indent + chalk.hex(LIGHT_ORANGE).bold(name) + spaces + chalk.white(desc)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const subMatch = plain.match(/^( )([a-z][\w -]*)(\s{2,})(.*)$/)
|
|
265
|
+
if (subMatch) {
|
|
266
|
+
const [, indent, name, spaces, desc] = subMatch
|
|
267
|
+
return indent + chalk.hex(LIGHT_ORANGE)(name) + spaces + chalk.dim(desc)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return line
|
|
271
|
+
})
|
|
272
|
+
.join('\n')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Colorize flag rows and command rows in individual command help pages.
|
|
277
|
+
* @param {string} text
|
|
278
|
+
* @returns {string}
|
|
279
|
+
*/
|
|
280
|
+
#colorizeRows(text) {
|
|
281
|
+
return text
|
|
282
|
+
.split('\n')
|
|
283
|
+
.map((line) => {
|
|
284
|
+
const plain = strip(line)
|
|
285
|
+
if (!plain.trim()) return line
|
|
286
|
+
|
|
287
|
+
// Example lines: "$ dvmi …"
|
|
288
|
+
if (plain.includes('$ dvmi') || plain.trim().startsWith('$ dvmi')) {
|
|
289
|
+
return plain.replace(/\$ (dvmi\S*)/g, (_, cmd) =>
|
|
290
|
+
'$ ' + chalk.hex(ORANGE).bold(cmd),
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Flag rows: "--flag desc" or "-f, --flag desc"
|
|
295
|
+
const flagMatch = plain.match(/^(\s{2,})((?:-\w,\s*)?--[\w-]+)(\s+)(.*)$/)
|
|
296
|
+
if (flagMatch) {
|
|
297
|
+
const [, indent, flags, spaces, desc] = flagMatch
|
|
298
|
+
return indent + chalk.hex(DIM_BLUE).bold(flags) + spaces + chalk.dim(desc)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Command/topic rows: " name description"
|
|
302
|
+
const rowMatch = plain.match(/^( )([a-z][\w:-]*)(\s{2,})(.+)$/)
|
|
303
|
+
if (rowMatch) {
|
|
304
|
+
const [, indent, name, spaces, desc] = rowMatch
|
|
305
|
+
return indent + chalk.hex(LIGHT_ORANGE).bold(name) + spaces + chalk.white(desc)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return line
|
|
309
|
+
})
|
|
310
|
+
.join('\n')
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-command hook: trigger non-blocking version check.
|
|
3
|
+
*/
|
|
4
|
+
export const init = async () => {
|
|
5
|
+
// Fire-and-forget version check — result used by postrun hook
|
|
6
|
+
import('../services/version-check.js')
|
|
7
|
+
.then(({ checkForUpdate }) => checkForUpdate())
|
|
8
|
+
.catch(() => null) // never block command execution
|
|
9
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Post-command hook: display update notification if a newer version is available.
|
|
5
|
+
*/
|
|
6
|
+
export const postrun = async () => {
|
|
7
|
+
try {
|
|
8
|
+
const { checkForUpdate } = await import('../services/version-check.js')
|
|
9
|
+
const { hasUpdate, current, latest } = await checkForUpdate()
|
|
10
|
+
if (hasUpdate && latest) {
|
|
11
|
+
process.stderr.write(
|
|
12
|
+
chalk.dim(`\nUpdate available: ${current} → ${chalk.green(latest)}. Run \`dvmi upgrade\`\n`),
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
} catch {
|
|
16
|
+
// Never interrupt user flow
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core'
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { exec } from './shell.js'
|
|
2
|
+
import { loadConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} AuthStatus
|
|
6
|
+
* @property {boolean} authenticated
|
|
7
|
+
* @property {string} [username]
|
|
8
|
+
* @property {string} [account]
|
|
9
|
+
* @property {string} [role]
|
|
10
|
+
* @property {string} [error]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check GitHub authentication status.
|
|
15
|
+
* @returns {Promise<AuthStatus>}
|
|
16
|
+
*/
|
|
17
|
+
export async function checkGitHubAuth() {
|
|
18
|
+
const result = await exec('gh', ['auth', 'status'])
|
|
19
|
+
if (result.exitCode !== 0) {
|
|
20
|
+
return { authenticated: false, error: result.stderr }
|
|
21
|
+
}
|
|
22
|
+
// Extract username from output like "Logged in to github.com as username"
|
|
23
|
+
const match = result.stderr.match(/Logged in to .+ as (\S+)/)
|
|
24
|
+
return { authenticated: true, username: match?.[1] ?? 'unknown' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Log in to GitHub via SSO (opens browser).
|
|
29
|
+
* @returns {Promise<AuthStatus>}
|
|
30
|
+
*/
|
|
31
|
+
export async function loginGitHub() {
|
|
32
|
+
const result = await exec('gh', ['auth', 'login', '--web'])
|
|
33
|
+
if (result.exitCode !== 0) {
|
|
34
|
+
return { authenticated: false, error: result.stderr }
|
|
35
|
+
}
|
|
36
|
+
return checkGitHubAuth()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check AWS authentication via aws-vault.
|
|
41
|
+
* @returns {Promise<AuthStatus>}
|
|
42
|
+
*/
|
|
43
|
+
export async function checkAWSAuth() {
|
|
44
|
+
const config = await loadConfig()
|
|
45
|
+
if (!config.awsProfile) return { authenticated: false, error: 'No AWS profile configured' }
|
|
46
|
+
|
|
47
|
+
const result = await exec('aws-vault', [
|
|
48
|
+
'exec',
|
|
49
|
+
config.awsProfile,
|
|
50
|
+
'--',
|
|
51
|
+
'aws',
|
|
52
|
+
'sts',
|
|
53
|
+
'get-caller-identity',
|
|
54
|
+
'--output',
|
|
55
|
+
'json',
|
|
56
|
+
])
|
|
57
|
+
if (result.exitCode !== 0) {
|
|
58
|
+
return { authenticated: false, error: result.stderr || 'Session expired' }
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const identity = JSON.parse(result.stdout)
|
|
62
|
+
return {
|
|
63
|
+
authenticated: true,
|
|
64
|
+
account: identity.Account,
|
|
65
|
+
role: identity.Arn?.split('/').at(-1),
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
return { authenticated: false, error: 'Could not parse AWS identity' }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Log in to AWS via aws-vault.
|
|
74
|
+
* @param {string} profile - aws-vault profile name
|
|
75
|
+
* @returns {Promise<AuthStatus>}
|
|
76
|
+
*/
|
|
77
|
+
export async function loginAWS(profile) {
|
|
78
|
+
const result = await exec('aws-vault', ['login', profile])
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
return { authenticated: false, error: result.stderr }
|
|
81
|
+
}
|
|
82
|
+
return checkAWSAuth()
|
|
83
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { CostExplorerClient, GetCostAndUsageCommand } from '@aws-sdk/client-cost-explorer'
|
|
2
|
+
|
|
3
|
+
/** @import { AWSCostEntry } from '../types.js' */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the date range for a cost period.
|
|
7
|
+
* @param {'last-month'|'last-week'|'mtd'} period
|
|
8
|
+
* @returns {{ start: string, end: string }}
|
|
9
|
+
*/
|
|
10
|
+
function getPeriodDates(period) {
|
|
11
|
+
const now = new Date()
|
|
12
|
+
const fmt = (d) => d.toISOString().split('T')[0]
|
|
13
|
+
|
|
14
|
+
if (period === 'last-month') {
|
|
15
|
+
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
|
16
|
+
const end = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
17
|
+
return { start: fmt(start), end: fmt(end) }
|
|
18
|
+
}
|
|
19
|
+
if (period === 'last-week') {
|
|
20
|
+
const end = new Date(now)
|
|
21
|
+
end.setDate(now.getDate() - now.getDay())
|
|
22
|
+
const start = new Date(end)
|
|
23
|
+
start.setDate(end.getDate() - 7)
|
|
24
|
+
return { start: fmt(start), end: fmt(end) }
|
|
25
|
+
}
|
|
26
|
+
// mtd
|
|
27
|
+
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
28
|
+
return { start: fmt(start), end: fmt(now) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Query AWS Cost Explorer for costs filtered by project tags.
|
|
33
|
+
* @param {string} serviceName - Tag value for filtering
|
|
34
|
+
* @param {Record<string, string>} tags - Project tags (key-value pairs)
|
|
35
|
+
* @param {'last-month'|'last-week'|'mtd'} [period]
|
|
36
|
+
* @returns {Promise<{ entries: AWSCostEntry[], period: { start: string, end: string } }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function getServiceCosts(serviceName, tags, period = 'last-month') {
|
|
39
|
+
// Cost Explorer always uses us-east-1
|
|
40
|
+
const client = new CostExplorerClient({ region: 'us-east-1' })
|
|
41
|
+
const { start, end } = getPeriodDates(period)
|
|
42
|
+
|
|
43
|
+
// Build tag filter from project tags
|
|
44
|
+
const tagEntries = Object.entries(tags)
|
|
45
|
+
const filter =
|
|
46
|
+
tagEntries.length === 1
|
|
47
|
+
? { Tags: { Key: tagEntries[0][0], Values: [tagEntries[0][1]] } }
|
|
48
|
+
: {
|
|
49
|
+
And: tagEntries.map(([k, v]) => ({
|
|
50
|
+
Tags: { Key: k, Values: [v] },
|
|
51
|
+
})),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const command = new GetCostAndUsageCommand({
|
|
55
|
+
TimePeriod: { Start: start, End: end },
|
|
56
|
+
Granularity: 'MONTHLY',
|
|
57
|
+
Metrics: ['UnblendedCost'],
|
|
58
|
+
Filter: filter,
|
|
59
|
+
GroupBy: [{ Type: 'DIMENSION', Key: 'SERVICE' }],
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const result = await client.send(command)
|
|
63
|
+
const entries = []
|
|
64
|
+
|
|
65
|
+
for (const timeResult of result.ResultsByTime ?? []) {
|
|
66
|
+
for (const group of timeResult.Groups ?? []) {
|
|
67
|
+
const amount = Number(group.Metrics?.UnblendedCost?.Amount ?? 0)
|
|
68
|
+
if (amount > 0) {
|
|
69
|
+
entries.push({
|
|
70
|
+
serviceName: group.Keys?.[0] ?? 'Unknown',
|
|
71
|
+
amount,
|
|
72
|
+
unit: group.Metrics?.UnblendedCost?.Unit ?? 'USD',
|
|
73
|
+
period: { start, end },
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { entries, period: { start, end } }
|
|
80
|
+
}
|