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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Command } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import { createOctokit } from '../services/github.js'
|
|
5
|
+
import { checkAWSAuth } from '../services/auth.js'
|
|
6
|
+
import { getCurrentVersion } from '../services/version-check.js'
|
|
7
|
+
import { CONFIG_PATH, loadConfig } from '../services/config.js'
|
|
8
|
+
import { getUser, isAuthenticated } from '../services/clickup.js'
|
|
9
|
+
|
|
10
|
+
export default class Whoami extends Command {
|
|
11
|
+
static description = 'Mostra la tua identita su GitHub, AWS e ClickUp'
|
|
12
|
+
|
|
13
|
+
static examples = [
|
|
14
|
+
'<%= config.bin %> whoami',
|
|
15
|
+
'<%= config.bin %> whoami --json',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
static enableJsonFlag = true
|
|
19
|
+
|
|
20
|
+
async run() {
|
|
21
|
+
const { flags } = await this.parse(Whoami)
|
|
22
|
+
const isJson = flags.json
|
|
23
|
+
|
|
24
|
+
const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Fetching identity...') }).start()
|
|
25
|
+
|
|
26
|
+
const [ghResult, awsResult, version, cuResult] = await Promise.allSettled([
|
|
27
|
+
(async () => {
|
|
28
|
+
const octokit = await createOctokit()
|
|
29
|
+
const { data: user } = await octokit.rest.users.getAuthenticated()
|
|
30
|
+
return { username: user.login, name: user.name ?? '', org: '', teams: [] }
|
|
31
|
+
})(),
|
|
32
|
+
checkAWSAuth(),
|
|
33
|
+
getCurrentVersion(),
|
|
34
|
+
(async () => {
|
|
35
|
+
if (!(await isAuthenticated())) return null
|
|
36
|
+
const [user, config] = await Promise.all([getUser(), loadConfig()])
|
|
37
|
+
return { username: user.username, teamName: config.clickup?.teamName ?? null }
|
|
38
|
+
})(),
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
spinner?.stop()
|
|
42
|
+
|
|
43
|
+
const github =
|
|
44
|
+
ghResult.status === 'fulfilled'
|
|
45
|
+
? ghResult.value
|
|
46
|
+
: { username: null, error: '[NOT AUTHENTICATED]' }
|
|
47
|
+
|
|
48
|
+
const aws =
|
|
49
|
+
awsResult.status === 'fulfilled' && awsResult.value.authenticated
|
|
50
|
+
? { accountId: awsResult.value.account, role: awsResult.value.role }
|
|
51
|
+
: { accountId: null, error: '[NOT AUTHENTICATED]' }
|
|
52
|
+
|
|
53
|
+
const clickup =
|
|
54
|
+
cuResult.status === 'fulfilled' && cuResult.value
|
|
55
|
+
? cuResult.value
|
|
56
|
+
: { username: null, teamName: null, error: '[NOT AUTHENTICATED]' }
|
|
57
|
+
|
|
58
|
+
const cli = {
|
|
59
|
+
version: version.status === 'fulfilled' ? version.value : '?',
|
|
60
|
+
configPath: CONFIG_PATH,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = { github, aws, clickup, cli }
|
|
64
|
+
|
|
65
|
+
if (isJson) return result
|
|
66
|
+
|
|
67
|
+
this.log(chalk.bold('\nGitHub'))
|
|
68
|
+
this.log(` User: ${github.username ? chalk.cyan('@' + github.username) : chalk.red('[NOT AUTHENTICATED]')}`)
|
|
69
|
+
if (github.name) this.log(` Name: ${github.name}`)
|
|
70
|
+
|
|
71
|
+
this.log(chalk.bold('\nAWS'))
|
|
72
|
+
this.log(` Account: ${aws.accountId ?? chalk.red('[NOT AUTHENTICATED]')}`)
|
|
73
|
+
if (aws.role) this.log(` Role: ${aws.role}`)
|
|
74
|
+
|
|
75
|
+
this.log(chalk.bold('\nClickUp'))
|
|
76
|
+
this.log(` User: ${clickup.username ? chalk.cyan(clickup.username) : chalk.red('[NOT AUTHENTICATED]')}`)
|
|
77
|
+
if (clickup.teamName) this.log(` Team: ${clickup.teamName}`)
|
|
78
|
+
|
|
79
|
+
this.log(chalk.bold('\nCLI'))
|
|
80
|
+
this.log(` Version: ${cli.version}`)
|
|
81
|
+
this.log(` Config: ${cli.configPath}`)
|
|
82
|
+
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** @import { AWSCostEntry } from '../types.js' */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a USD amount as currency string.
|
|
5
|
+
* @param {number} amount
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function formatCurrency(amount) {
|
|
9
|
+
return `$${amount.toFixed(2)}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Calculate total cost from entries.
|
|
14
|
+
* @param {AWSCostEntry[]} entries
|
|
15
|
+
* @returns {number}
|
|
16
|
+
*/
|
|
17
|
+
export function calculateTotal(entries) {
|
|
18
|
+
return entries.reduce((sum, e) => sum + e.amount, 0)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format a trend percentage.
|
|
23
|
+
* @param {number} current
|
|
24
|
+
* @param {number} previous
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function formatTrend(current, previous) {
|
|
28
|
+
if (previous === 0) return 'N/A'
|
|
29
|
+
const pct = ((current - previous) / previous) * 100
|
|
30
|
+
const sign = pct >= 0 ? '+' : ''
|
|
31
|
+
return `${sign}${pct.toFixed(1)}%`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Format cost entries as a printable table string.
|
|
36
|
+
* @param {AWSCostEntry[]} entries
|
|
37
|
+
* @param {string} serviceName
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function formatCostTable(entries, serviceName) {
|
|
41
|
+
const total = calculateTotal(entries)
|
|
42
|
+
const rows = entries
|
|
43
|
+
.sort((a, b) => b.amount - a.amount)
|
|
44
|
+
.map((e) => ` ${e.serviceName.padEnd(40)} ${formatCurrency(e.amount)}`)
|
|
45
|
+
.join('\n')
|
|
46
|
+
const divider = '─'.repeat(50)
|
|
47
|
+
return [
|
|
48
|
+
`Costs for: ${serviceName}`,
|
|
49
|
+
divider,
|
|
50
|
+
rows,
|
|
51
|
+
divider,
|
|
52
|
+
` ${'Total'.padEnd(40)} ${formatCurrency(total)}`,
|
|
53
|
+
].join('\n')
|
|
54
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { marked } from 'marked'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { deflate } from 'pako'
|
|
4
|
+
|
|
5
|
+
// Custom terminal renderer — outputs ANSI-formatted text using chalk.
|
|
6
|
+
// marked-terminal@7 is incompatible with all currently released versions of marked
|
|
7
|
+
// due to an internal API break (this.o.text undefined).
|
|
8
|
+
// This inline renderer has no external dependencies beyond chalk (already in deps).
|
|
9
|
+
const terminalRenderer = {
|
|
10
|
+
heading(text, level) {
|
|
11
|
+
const stripped = text.replace(/<[^>]*>/g, '')
|
|
12
|
+
if (level === 1) return '\n' + chalk.bold.white(stripped) + '\n\n'
|
|
13
|
+
if (level === 2) return '\n' + chalk.bold(stripped) + '\n\n'
|
|
14
|
+
return '\n' + chalk.bold.dim(stripped) + '\n\n'
|
|
15
|
+
},
|
|
16
|
+
paragraph(text) {
|
|
17
|
+
return text + '\n\n'
|
|
18
|
+
},
|
|
19
|
+
strong(text) {
|
|
20
|
+
return chalk.bold(text)
|
|
21
|
+
},
|
|
22
|
+
em(text) {
|
|
23
|
+
return chalk.italic(text)
|
|
24
|
+
},
|
|
25
|
+
codespan(code) {
|
|
26
|
+
return chalk.bgGray.white(` ${code} `)
|
|
27
|
+
},
|
|
28
|
+
code(code, _lang) {
|
|
29
|
+
const lines = code.split('\n').map((l) => ' ' + chalk.gray(l))
|
|
30
|
+
return '\n' + lines.join('\n') + '\n\n'
|
|
31
|
+
},
|
|
32
|
+
blockquote(quote) {
|
|
33
|
+
return quote.split('\n').map((l) => chalk.dim('│ ') + chalk.italic(l)).join('\n') + '\n'
|
|
34
|
+
},
|
|
35
|
+
link(href, _title, text) {
|
|
36
|
+
return `${text} ${chalk.dim(`(${href})`)}`
|
|
37
|
+
},
|
|
38
|
+
image(href, _title, text) {
|
|
39
|
+
return `[image: ${text}] ${chalk.dim(`(${href})`)}`
|
|
40
|
+
},
|
|
41
|
+
list(body, _ordered) {
|
|
42
|
+
return body + '\n'
|
|
43
|
+
},
|
|
44
|
+
listitem(text) {
|
|
45
|
+
return ' • ' + text + '\n'
|
|
46
|
+
},
|
|
47
|
+
hr() {
|
|
48
|
+
return chalk.dim('─'.repeat(60)) + '\n\n'
|
|
49
|
+
},
|
|
50
|
+
br() {
|
|
51
|
+
return '\n'
|
|
52
|
+
},
|
|
53
|
+
del(text) {
|
|
54
|
+
return chalk.strikethrough(text)
|
|
55
|
+
},
|
|
56
|
+
text(text) {
|
|
57
|
+
return text
|
|
58
|
+
},
|
|
59
|
+
html(html) {
|
|
60
|
+
return html.replace(/<[^>]*>/g, '')
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
marked.use({ renderer: terminalRenderer })
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render a markdown string as ANSI-formatted terminal output.
|
|
68
|
+
* @param {string} content - Raw markdown string
|
|
69
|
+
* @returns {string} ANSI-formatted string ready for terminal output
|
|
70
|
+
*/
|
|
71
|
+
export function renderMarkdown(content) {
|
|
72
|
+
return marked(content)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract all Mermaid diagram code blocks from a markdown string.
|
|
77
|
+
* @param {string} content - Raw markdown string
|
|
78
|
+
* @returns {string[]} Array of mermaid diagram source strings (without fences)
|
|
79
|
+
*/
|
|
80
|
+
export function extractMermaidBlocks(content) {
|
|
81
|
+
const regex = /```mermaid\n([\s\S]*?)```/g
|
|
82
|
+
const blocks = []
|
|
83
|
+
let match
|
|
84
|
+
while ((match = regex.exec(content)) !== null) {
|
|
85
|
+
blocks.push(match[1].trim())
|
|
86
|
+
}
|
|
87
|
+
return blocks
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Encode a Mermaid diagram as a mermaid.live URL (pako-compressed).
|
|
92
|
+
* @param {string} diagramCode - Mermaid diagram source code
|
|
93
|
+
* @returns {string} Full mermaid.live view URL
|
|
94
|
+
*/
|
|
95
|
+
export function toMermaidLiveUrl(diagramCode) {
|
|
96
|
+
const state = JSON.stringify({
|
|
97
|
+
code: diagramCode,
|
|
98
|
+
mermaid: JSON.stringify({ theme: 'default' }),
|
|
99
|
+
updateDiagram: true,
|
|
100
|
+
grid: true,
|
|
101
|
+
panZoom: true,
|
|
102
|
+
rough: false,
|
|
103
|
+
})
|
|
104
|
+
const data = new TextEncoder().encode(state)
|
|
105
|
+
const compressed = deflate(data, { level: 9 })
|
|
106
|
+
const encoded = Buffer.from(compressed).toString('base64url')
|
|
107
|
+
return `https://mermaid.live/view#pako:${encoded}`
|
|
108
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { load } from 'js-yaml'
|
|
2
|
+
|
|
3
|
+
/** @import { APIEndpoint, AsyncChannel } from '../types.js' */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a YAML or JSON string, returning null on error.
|
|
7
|
+
* @param {string} content
|
|
8
|
+
* @returns {Record<string, unknown>|null}
|
|
9
|
+
*/
|
|
10
|
+
export function parseYamlOrJson(content) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(content)
|
|
13
|
+
} catch {
|
|
14
|
+
try {
|
|
15
|
+
return /** @type {Record<string, unknown>} */ (load(content))
|
|
16
|
+
} catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check whether a parsed document is an OpenAPI (3.x) or Swagger (2.0) spec.
|
|
24
|
+
* @param {Record<string, unknown>} doc
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function isOpenApi(doc) {
|
|
28
|
+
return Boolean(doc?.openapi || doc?.swagger)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check whether a parsed document is an AsyncAPI spec (2.x or 3.x).
|
|
33
|
+
* @param {Record<string, unknown>} doc
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
export function isAsyncApi(doc) {
|
|
37
|
+
return Boolean(doc?.asyncapi)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse an OpenAPI/Swagger document into a list of APIEndpoints.
|
|
42
|
+
* @param {string} content - Raw YAML or JSON string
|
|
43
|
+
* @returns {{ endpoints: APIEndpoint[], error: string|null }}
|
|
44
|
+
*/
|
|
45
|
+
export function parseOpenApi(content) {
|
|
46
|
+
const doc = parseYamlOrJson(content)
|
|
47
|
+
if (!doc || !isOpenApi(doc)) {
|
|
48
|
+
return { endpoints: [], error: 'Not a valid OpenAPI/Swagger document' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @type {APIEndpoint[]} */
|
|
52
|
+
const endpoints = []
|
|
53
|
+
const paths = /** @type {Record<string, Record<string, unknown>>} */ (doc.paths ?? {})
|
|
54
|
+
|
|
55
|
+
for (const [path, methods] of Object.entries(paths)) {
|
|
56
|
+
if (!methods || typeof methods !== 'object') continue
|
|
57
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
58
|
+
if (!['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].includes(method)) continue
|
|
59
|
+
const operation = /** @type {Record<string, unknown>} */ (op)
|
|
60
|
+
const rawParams = /** @type {Array<Record<string, unknown>>} */ (operation.parameters ?? [])
|
|
61
|
+
const parameters = rawParams
|
|
62
|
+
.map((p) => (p.required ? `${p.name}*` : String(p.name)))
|
|
63
|
+
.join(', ')
|
|
64
|
+
endpoints.push({
|
|
65
|
+
method: method.toUpperCase(),
|
|
66
|
+
path,
|
|
67
|
+
summary: String(operation.summary ?? ''),
|
|
68
|
+
parameters,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { endpoints, error: null }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse an AsyncAPI document (2.x or 3.x) into a list of AsyncChannels.
|
|
78
|
+
* @param {string} content - Raw YAML or JSON string
|
|
79
|
+
* @returns {{ channels: AsyncChannel[], error: string|null }}
|
|
80
|
+
*/
|
|
81
|
+
export function parseAsyncApi(content) {
|
|
82
|
+
const doc = parseYamlOrJson(content)
|
|
83
|
+
if (!doc || !isAsyncApi(doc)) {
|
|
84
|
+
return { channels: [], error: 'Not a valid AsyncAPI document' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @type {AsyncChannel[]} */
|
|
88
|
+
const channels = []
|
|
89
|
+
const version = String(doc.asyncapi ?? '')
|
|
90
|
+
const rawChannels = /** @type {Record<string, unknown>} */ (doc.channels ?? {})
|
|
91
|
+
|
|
92
|
+
if (version.startsWith('3')) {
|
|
93
|
+
// AsyncAPI 3.x: channels + operations
|
|
94
|
+
const rawOps = /** @type {Record<string, Record<string, unknown>>} */ (doc.operations ?? {})
|
|
95
|
+
for (const [channelName] of Object.entries(rawChannels)) {
|
|
96
|
+
const matchingOps = Object.values(rawOps).filter((op) => {
|
|
97
|
+
const ch = /** @type {Record<string, unknown>} */ (op.channel ?? {})
|
|
98
|
+
return String(ch.$ref ?? '').includes(channelName) || String(ch ?? '') === channelName
|
|
99
|
+
})
|
|
100
|
+
if (matchingOps.length === 0) {
|
|
101
|
+
channels.push({ channel: channelName, operation: '—', summary: '', message: '—' })
|
|
102
|
+
}
|
|
103
|
+
for (const op of matchingOps) {
|
|
104
|
+
const msgTitle = resolveMessageTitle(op.messages)
|
|
105
|
+
channels.push({
|
|
106
|
+
channel: channelName,
|
|
107
|
+
operation: String(op.action ?? '—'),
|
|
108
|
+
summary: String(op.summary ?? ''),
|
|
109
|
+
message: msgTitle,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// AsyncAPI 2.x: channels[name].publish / .subscribe
|
|
115
|
+
for (const [channelName, channelDef] of Object.entries(rawChannels)) {
|
|
116
|
+
const def = /** @type {Record<string, unknown>} */ (channelDef ?? {})
|
|
117
|
+
for (const op of ['publish', 'subscribe']) {
|
|
118
|
+
if (!def[op]) continue
|
|
119
|
+
const opDef = /** @type {Record<string, unknown>} */ (def[op])
|
|
120
|
+
const msgDef = /** @type {Record<string, unknown>} */ (opDef.message ?? {})
|
|
121
|
+
const msgTitle = String(msgDef.name ?? msgDef.title ?? '—')
|
|
122
|
+
channels.push({
|
|
123
|
+
channel: channelName,
|
|
124
|
+
operation: op,
|
|
125
|
+
summary: String(opDef.summary ?? ''),
|
|
126
|
+
message: msgTitle,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { channels, error: null }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve a message title from an AsyncAPI 3.x messages ref list.
|
|
137
|
+
* @param {unknown} messages
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function resolveMessageTitle(messages) {
|
|
141
|
+
if (!messages || typeof messages !== 'object') return '—'
|
|
142
|
+
const msgs = Object.values(/** @type {Record<string, unknown>} */ (messages))
|
|
143
|
+
if (msgs.length === 0) return '—'
|
|
144
|
+
const first = /** @type {Record<string, unknown>} */ (msgs[0])
|
|
145
|
+
return String(first.name ?? first.title ?? '—')
|
|
146
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { colorStatus } from './table.js'
|
|
3
|
+
|
|
4
|
+
/** @import { DoctorCheck } from '../types.js' */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format a DoctorCheck for terminal output.
|
|
8
|
+
* @param {DoctorCheck} check
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function formatDoctorCheck(check) {
|
|
12
|
+
const badge = colorStatus(check.status)
|
|
13
|
+
const version = check.version ? chalk.gray(` ${check.version}`) : ''
|
|
14
|
+
const hint = check.status !== 'ok' && check.hint ? chalk.dim(`\n → ${check.hint}`) : ''
|
|
15
|
+
return `${badge} ${check.name}${version}${hint}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Format a summary line for doctor output.
|
|
20
|
+
* @param {{ ok: number, warn: number, fail: number }} summary
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function formatDoctorSummary(summary) {
|
|
24
|
+
const parts = [
|
|
25
|
+
chalk.green(`${summary.ok} ok`),
|
|
26
|
+
chalk.yellow(`${summary.warn} warnings`),
|
|
27
|
+
chalk.red(`${summary.fail} failures`),
|
|
28
|
+
]
|
|
29
|
+
return parts.join(', ')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format a CI status badge.
|
|
34
|
+
* @param {'pass'|'fail'|'pending'|string} status
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function formatCIStatus(status) {
|
|
38
|
+
return colorStatus(status)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format a review status badge.
|
|
43
|
+
* @param {'approved'|'changes_requested'|'pending'|string} status
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function formatReviewStatus(status) {
|
|
47
|
+
return colorStatus(status)
|
|
48
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} TableColumn
|
|
5
|
+
* @property {string} header - Column header text
|
|
6
|
+
* @property {string} key - Key to extract from row object
|
|
7
|
+
* @property {number} [width] - Fixed max column width (truncates with … if exceeded)
|
|
8
|
+
* @property {function(*): string} [format] - Custom cell formatter (plain text, used for width calc)
|
|
9
|
+
* @property {function(string): string} [colorize] - Chalk color applied to formatted value at render time
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Truncate a string to max length, appending … if needed.
|
|
14
|
+
* @param {string} str
|
|
15
|
+
* @param {number} max
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function truncate(str, max) {
|
|
19
|
+
if (str.length <= max) return str
|
|
20
|
+
return str.slice(0, max - 1) + '…'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the plain (no ANSI) formatted value for a cell.
|
|
25
|
+
* @param {TableColumn} col
|
|
26
|
+
* @param {*} rawVal
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function plainCell(col, rawVal) {
|
|
30
|
+
const formatted = col.format ? col.format(rawVal) : String(rawVal ?? '')
|
|
31
|
+
return col.width ? truncate(formatted, col.width) : formatted
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Render a list of objects as a terminal table.
|
|
36
|
+
* @param {Record<string, unknown>[]} rows
|
|
37
|
+
* @param {TableColumn[]} columns
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function renderTable(rows, columns) {
|
|
41
|
+
if (rows.length === 0) return ''
|
|
42
|
+
|
|
43
|
+
// Calculate column widths from plain text (ANSI-safe)
|
|
44
|
+
const widths = columns.map((col) => {
|
|
45
|
+
if (col.width) return col.width
|
|
46
|
+
const headerLen = col.header.length
|
|
47
|
+
const maxDataLen = rows.reduce((max, row) => {
|
|
48
|
+
return Math.max(max, plainCell(col, row[col.key]).length)
|
|
49
|
+
}, 0)
|
|
50
|
+
return Math.max(headerLen, maxDataLen)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Header row
|
|
54
|
+
const header = columns
|
|
55
|
+
.map((col, i) => chalk.bold.white(col.header.padEnd(widths[i])))
|
|
56
|
+
.join(' ')
|
|
57
|
+
|
|
58
|
+
// Divider
|
|
59
|
+
const divider = chalk.dim(widths.map((w) => '─'.repeat(w)).join(' '))
|
|
60
|
+
|
|
61
|
+
// Data rows
|
|
62
|
+
const dataRows = rows.map((row) =>
|
|
63
|
+
columns
|
|
64
|
+
.map((col, i) => {
|
|
65
|
+
const plain = plainCell(col, row[col.key])
|
|
66
|
+
const padding = ' '.repeat(Math.max(0, widths[i] - plain.length))
|
|
67
|
+
const colored = col.colorize ? col.colorize(plain) : plain
|
|
68
|
+
return colored + padding
|
|
69
|
+
})
|
|
70
|
+
.join(' '),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return [header, divider, ...dataRows].join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Format a status value with color.
|
|
78
|
+
* @param {'ok'|'warn'|'fail'|'pass'|'success'|'failure'|string} status
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function colorStatus(status) {
|
|
82
|
+
const s = status?.toLowerCase() ?? ''
|
|
83
|
+
if (['ok', 'pass', 'success', 'approved'].includes(s)) return chalk.green('✓')
|
|
84
|
+
if (['warn', 'pending', 'in_progress', 'queued'].includes(s)) return chalk.yellow('⚠')
|
|
85
|
+
if (['fail', 'failure', 'error', 'changes_requested'].includes(s)) return chalk.red('✗')
|
|
86
|
+
return chalk.gray('○')
|
|
87
|
+
}
|