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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/bin/dev.cmd +3 -0
  4. package/bin/dev.js +5 -0
  5. package/bin/run.cmd +3 -0
  6. package/bin/run.js +5 -0
  7. package/oclif.manifest.json +1238 -0
  8. package/package.json +161 -0
  9. package/src/commands/auth/login.js +89 -0
  10. package/src/commands/changelog.js +102 -0
  11. package/src/commands/costs/get.js +73 -0
  12. package/src/commands/create/repo.js +196 -0
  13. package/src/commands/docs/list.js +110 -0
  14. package/src/commands/docs/projects.js +92 -0
  15. package/src/commands/docs/read.js +172 -0
  16. package/src/commands/docs/search.js +103 -0
  17. package/src/commands/doctor.js +115 -0
  18. package/src/commands/init.js +222 -0
  19. package/src/commands/open.js +75 -0
  20. package/src/commands/pipeline/logs.js +41 -0
  21. package/src/commands/pipeline/rerun.js +66 -0
  22. package/src/commands/pipeline/status.js +62 -0
  23. package/src/commands/pr/create.js +114 -0
  24. package/src/commands/pr/detail.js +83 -0
  25. package/src/commands/pr/review.js +51 -0
  26. package/src/commands/pr/status.js +70 -0
  27. package/src/commands/repo/list.js +113 -0
  28. package/src/commands/search.js +62 -0
  29. package/src/commands/tasks/assigned.js +131 -0
  30. package/src/commands/tasks/list.js +133 -0
  31. package/src/commands/tasks/today.js +73 -0
  32. package/src/commands/upgrade.js +52 -0
  33. package/src/commands/whoami.js +85 -0
  34. package/src/formatters/cost.js +54 -0
  35. package/src/formatters/markdown.js +108 -0
  36. package/src/formatters/openapi.js +146 -0
  37. package/src/formatters/status.js +48 -0
  38. package/src/formatters/table.js +87 -0
  39. package/src/help.js +312 -0
  40. package/src/hooks/init.js +9 -0
  41. package/src/hooks/postrun.js +18 -0
  42. package/src/index.js +1 -0
  43. package/src/services/auth.js +83 -0
  44. package/src/services/aws-costs.js +80 -0
  45. package/src/services/clickup.js +288 -0
  46. package/src/services/config.js +59 -0
  47. package/src/services/docs.js +210 -0
  48. package/src/services/github.js +377 -0
  49. package/src/services/platform.js +48 -0
  50. package/src/services/shell.js +42 -0
  51. package/src/services/version-check.js +58 -0
  52. package/src/types.js +228 -0
  53. package/src/utils/banner.js +48 -0
  54. package/src/utils/errors.js +61 -0
  55. package/src/utils/gradient.js +130 -0
  56. package/src/utils/open-browser.js +29 -0
  57. package/src/utils/typewriter.js +48 -0
  58. package/src/validators/repo-name.js +42 -0
@@ -0,0 +1,377 @@
1
+ import { Octokit } from 'octokit'
2
+ import { exec } from './shell.js'
3
+ import { AuthError } from '../utils/errors.js'
4
+
5
+ /** @import { Template, Repository, PullRequest, PRComment, QAStep, PRDetail, PipelineRun } from '../types.js' */
6
+
7
+ /**
8
+ * Get GitHub token from gh CLI.
9
+ * @returns {Promise<string>}
10
+ */
11
+ async function getToken() {
12
+ const result = await exec('gh', ['auth', 'token'])
13
+ if (!result.stdout) throw new AuthError('GitHub')
14
+ return result.stdout
15
+ }
16
+
17
+ /**
18
+ * Create an authenticated Octokit instance using gh CLI token.
19
+ * @returns {Promise<Octokit>}
20
+ */
21
+ export async function createOctokit() {
22
+ const token = await getToken()
23
+ return new Octokit({ auth: token })
24
+ }
25
+
26
+ /**
27
+ * List all repositories in an org the user has access to.
28
+ * @param {string} org
29
+ * @param {{ language?: string, topic?: string }} [filters]
30
+ * @returns {Promise<Repository[]>}
31
+ */
32
+ export async function listRepos(org, filters = {}) {
33
+ const octokit = await createOctokit()
34
+ const repos = await octokit.paginate(octokit.rest.repos.listForOrg, {
35
+ org,
36
+ type: 'all',
37
+ per_page: 100,
38
+ })
39
+ let results = repos.map((r) => ({
40
+ name: r.name,
41
+ description: r.description ?? '',
42
+ language: r.language ?? '',
43
+ htmlUrl: r.html_url,
44
+ pushedAt: r.pushed_at ?? '',
45
+ topics: r.topics ?? [],
46
+ isPrivate: r.private,
47
+ }))
48
+ if (filters.language) {
49
+ results = results.filter(
50
+ (r) => r.language?.toLowerCase() === filters.language?.toLowerCase(),
51
+ )
52
+ }
53
+ if (filters.topic) {
54
+ results = results.filter((r) => r.topics.includes(filters.topic ?? ''))
55
+ }
56
+ return results
57
+ }
58
+
59
+ /**
60
+ * List template repositories in an org.
61
+ * @param {string} org
62
+ * @returns {Promise<Template[]>}
63
+ */
64
+ export async function listTemplates(org) {
65
+ const octokit = await createOctokit()
66
+ const repos = await octokit.paginate(octokit.rest.repos.listForOrg, {
67
+ org,
68
+ type: 'all',
69
+ per_page: 100,
70
+ })
71
+ return repos
72
+ .filter((r) => r.is_template)
73
+ .map((r) => ({
74
+ name: r.name,
75
+ description: r.description ?? '',
76
+ language: r.language ?? '',
77
+ htmlUrl: r.html_url,
78
+ updatedAt: r.updated_at ?? '',
79
+ }))
80
+ }
81
+
82
+ /**
83
+ * Create a repository from a template.
84
+ * @param {{ templateOwner: string, templateRepo: string, name: string, org: string, description: string, isPrivate: boolean }} opts
85
+ * @returns {Promise<{ name: string, htmlUrl: string, cloneUrl: string }>}
86
+ */
87
+ export async function createFromTemplate(opts) {
88
+ const octokit = await createOctokit()
89
+ const { data } = await octokit.rest.repos.createUsingTemplate({
90
+ template_owner: opts.templateOwner,
91
+ template_repo: opts.templateRepo,
92
+ name: opts.name,
93
+ owner: opts.org,
94
+ description: opts.description,
95
+ private: opts.isPrivate,
96
+ include_all_branches: false,
97
+ })
98
+ return { name: data.name, htmlUrl: data.html_url, cloneUrl: data.clone_url }
99
+ }
100
+
101
+ /**
102
+ * Configure branch protection on main.
103
+ * @param {string} owner
104
+ * @param {string} repo
105
+ * @returns {Promise<void>}
106
+ */
107
+ export async function setBranchProtection(owner, repo) {
108
+ const octokit = await createOctokit()
109
+ await octokit.rest.repos.updateBranchProtection({
110
+ owner,
111
+ repo,
112
+ branch: 'main',
113
+ required_status_checks: null,
114
+ enforce_admins: false,
115
+ required_pull_request_reviews: { required_approving_review_count: 0 },
116
+ restrictions: null,
117
+ allow_force_pushes: false,
118
+ allow_deletions: false,
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Enable Dependabot alerts on a repo.
124
+ * @param {string} owner
125
+ * @param {string} repo
126
+ * @returns {Promise<void>}
127
+ */
128
+ export async function enableDependabot(owner, repo) {
129
+ const octokit = await createOctokit()
130
+ await octokit.rest.repos.enableAutomatedSecurityFixes({ owner, repo })
131
+ await octokit.rest.repos.enableVulnerabilityAlerts({ owner, repo })
132
+ }
133
+
134
+ /**
135
+ * Create a pull request.
136
+ * @param {{ owner: string, repo: string, title: string, body: string, head: string, base: string, draft: boolean, labels: string[], reviewers: string[] }} opts
137
+ * @returns {Promise<{ number: number, htmlUrl: string }>}
138
+ */
139
+ export async function createPR(opts) {
140
+ const octokit = await createOctokit()
141
+ const { data } = await octokit.rest.pulls.create({
142
+ owner: opts.owner,
143
+ repo: opts.repo,
144
+ title: opts.title,
145
+ body: opts.body,
146
+ head: opts.head,
147
+ base: opts.base,
148
+ draft: opts.draft,
149
+ })
150
+ if (opts.labels?.length) {
151
+ await octokit.rest.issues.addLabels({
152
+ owner: opts.owner,
153
+ repo: opts.repo,
154
+ issue_number: data.number,
155
+ labels: opts.labels,
156
+ })
157
+ }
158
+ if (opts.reviewers?.length) {
159
+ await octokit.rest.pulls.requestReviewers({
160
+ owner: opts.owner,
161
+ repo: opts.repo,
162
+ pull_number: data.number,
163
+ reviewers: opts.reviewers,
164
+ })
165
+ }
166
+ return { number: data.number, htmlUrl: data.html_url }
167
+ }
168
+
169
+ /**
170
+ * List PRs authored by or reviewing the current user.
171
+ * @param {string} org
172
+ * @returns {Promise<{ authored: PullRequest[], reviewing: PullRequest[] }>}
173
+ */
174
+ export async function listMyPRs(org) {
175
+ const octokit = await createOctokit()
176
+ const { data: user } = await octokit.rest.users.getAuthenticated()
177
+ const login = user.login
178
+
179
+ const [authoredRes, reviewingRes] = await Promise.all([
180
+ octokit.rest.search.issuesAndPullRequests({
181
+ q: `is:pr is:open author:${login} org:${org}`,
182
+ per_page: 30,
183
+ }),
184
+ octokit.rest.search.issuesAndPullRequests({
185
+ q: `is:pr is:open review-requested:${login} org:${org}`,
186
+ per_page: 30,
187
+ }),
188
+ ])
189
+
190
+ /**
191
+ * @param {object[]} items
192
+ * @returns {PullRequest[]}
193
+ */
194
+ const mapItems = (items) =>
195
+ items.map((item) => ({
196
+ number: item.number,
197
+ title: item.title,
198
+ state: item.state,
199
+ htmlUrl: item.html_url,
200
+ headBranch: item.pull_request?.head?.ref ?? '',
201
+ baseBranch: item.pull_request?.base?.ref ?? '',
202
+ isDraft: item.draft ?? false,
203
+ ciStatus: 'pending',
204
+ reviewStatus: 'pending',
205
+ mergeable: true,
206
+ author: item.user?.login ?? '',
207
+ reviewers: [],
208
+ }))
209
+
210
+ return {
211
+ authored: mapItems(authoredRes.data.items),
212
+ reviewing: mapItems(reviewingRes.data.items),
213
+ }
214
+ }
215
+
216
+ /**
217
+ * List workflow runs for a repo.
218
+ * @param {string} owner
219
+ * @param {string} repo
220
+ * @param {{ branch?: string, limit?: number }} [filters]
221
+ * @returns {Promise<PipelineRun[]>}
222
+ */
223
+ export async function listWorkflowRuns(owner, repo, filters = {}) {
224
+ const octokit = await createOctokit()
225
+ const params = {
226
+ owner,
227
+ repo,
228
+ per_page: filters.limit ?? 10,
229
+ ...(filters.branch ? { branch: filters.branch } : {}),
230
+ }
231
+ const { data } = await octokit.rest.actions.listWorkflowRunsForRepo(params)
232
+ return data.workflow_runs.map((run) => {
233
+ const start = new Date(run.created_at)
234
+ const end = run.updated_at ? new Date(run.updated_at) : new Date()
235
+ const duration = Math.round((end.getTime() - start.getTime()) / 1000)
236
+ return {
237
+ id: run.id,
238
+ name: run.name ?? run.display_title,
239
+ status: /** @type {import('../types.js').RunStatus} */ (run.status ?? 'queued'),
240
+ conclusion: /** @type {import('../types.js').RunConclusion} */ (run.conclusion ?? null),
241
+ branch: run.head_branch ?? '',
242
+ duration,
243
+ actor: run.actor?.login ?? '',
244
+ createdAt: run.created_at,
245
+ htmlUrl: run.html_url,
246
+ }
247
+ })
248
+ }
249
+
250
+ /**
251
+ * Rerun a workflow run.
252
+ * @param {string} owner
253
+ * @param {string} repo
254
+ * @param {number} runId
255
+ * @param {boolean} [failedOnly]
256
+ * @returns {Promise<void>}
257
+ */
258
+ export async function rerunWorkflow(owner, repo, runId, failedOnly = false) {
259
+ const octokit = await createOctokit()
260
+ if (failedOnly) {
261
+ await octokit.rest.actions.reRunWorkflowFailedJobs({ owner, repo, run_id: runId })
262
+ } else {
263
+ await octokit.rest.actions.reRunWorkflow({ owner, repo, run_id: runId })
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Search code across the org.
269
+ * @param {string} org
270
+ * @param {string} query
271
+ * @param {{ language?: string, repo?: string, limit?: number }} [opts]
272
+ * @returns {Promise<Array<{ repo: string, file: string, line: number, match: string, htmlUrl: string }>>}
273
+ */
274
+ export async function searchCode(org, query, opts = {}) {
275
+ const octokit = await createOctokit()
276
+ let q = `${query} org:${org}`
277
+ if (opts.language) q += ` language:${opts.language}`
278
+ if (opts.repo) q += ` repo:${org}/${opts.repo}`
279
+ const { data } = await octokit.rest.search.code({ q, per_page: opts.limit ?? 20 })
280
+ return data.items.map((item) => ({
281
+ repo: item.repository.name,
282
+ file: item.path,
283
+ line: 0,
284
+ match: item.name,
285
+ htmlUrl: item.html_url,
286
+ }))
287
+ }
288
+
289
+ /**
290
+ * Estrae gli step QA (checklist markdown) dal corpo di un commento.
291
+ * @param {string} body
292
+ * @returns {QAStep[]}
293
+ */
294
+ export function extractQASteps(body) {
295
+ const steps = []
296
+ for (const line of body.split('\n')) {
297
+ const match = line.match(/^\s*-\s*\[([xX ])\]\s+(.+)/)
298
+ if (match) {
299
+ steps.push({ text: match[2].trim(), checked: match[1].toLowerCase() === 'x' })
300
+ }
301
+ }
302
+ return steps
303
+ }
304
+
305
+ /**
306
+ * Determina se un commento è relativo a QA.
307
+ * @param {string} body
308
+ * @param {string} [author]
309
+ * @returns {boolean}
310
+ */
311
+ export function isQAComment(body, author = '') {
312
+ const bodyLower = body.toLowerCase()
313
+ return (
314
+ author.toLowerCase().includes('qa') ||
315
+ bodyLower.startsWith('qa:') ||
316
+ bodyLower.includes('qa review') ||
317
+ bodyLower.includes('qa step') ||
318
+ /^\s*-\s*\[[x ]\]/im.test(body)
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Recupera i dettagli completi di una PR inclusi commenti e step QA.
324
+ * @param {string} owner
325
+ * @param {string} repo
326
+ * @param {number} prNumber
327
+ * @returns {Promise<PRDetail>}
328
+ */
329
+ export async function getPRDetail(owner, repo, prNumber) {
330
+ const octokit = await createOctokit()
331
+
332
+ const [prRes, commentsRes, reviewsRes] = await Promise.all([
333
+ octokit.rest.pulls.get({ owner, repo, pull_number: prNumber }),
334
+ octokit.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page: 100 }),
335
+ octokit.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page: 100 }),
336
+ ])
337
+
338
+ const pr = prRes.data
339
+
340
+ /** @type {PRComment[]} */
341
+ const allComments = [
342
+ ...commentsRes.data.map((c) => ({
343
+ id: c.id,
344
+ author: c.user?.login ?? '',
345
+ body: c.body ?? '',
346
+ createdAt: c.created_at,
347
+ type: /** @type {'issue'} */ ('issue'),
348
+ })),
349
+ ...reviewsRes.data
350
+ .filter((r) => r.body?.trim())
351
+ .map((r) => ({
352
+ id: r.id,
353
+ author: r.user?.login ?? '',
354
+ body: r.body ?? '',
355
+ createdAt: r.submitted_at ?? '',
356
+ type: /** @type {'review'} */ ('review'),
357
+ })),
358
+ ]
359
+
360
+ const qaComments = allComments.filter((c) => isQAComment(c.body, c.author))
361
+ const qaSteps = qaComments.flatMap((c) => extractQASteps(c.body))
362
+
363
+ return {
364
+ number: pr.number,
365
+ title: pr.title,
366
+ state: pr.state,
367
+ htmlUrl: pr.html_url,
368
+ author: pr.user?.login ?? '',
369
+ headBranch: pr.head.ref,
370
+ baseBranch: pr.base.ref,
371
+ isDraft: pr.draft ?? false,
372
+ labels: pr.labels.map((l) => l.name),
373
+ reviewers: pr.requested_reviewers?.map((r) => r.login) ?? [],
374
+ qaComments,
375
+ qaSteps,
376
+ }
377
+ }
@@ -0,0 +1,48 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+
4
+ /** @import { Platform, PlatformInfo } from '../types.js' */
5
+
6
+ /**
7
+ * Detect current platform (macOS, WSL2, or Linux).
8
+ * @returns {Promise<PlatformInfo>}
9
+ */
10
+ export async function detectPlatform() {
11
+ if (process.platform === 'darwin') {
12
+ return {
13
+ platform: 'macos',
14
+ openCommand: 'open',
15
+ credentialHelper: 'osxkeychain',
16
+ }
17
+ }
18
+
19
+ if (process.platform === 'linux') {
20
+ // WSL2 has "microsoft" in /proc/version
21
+ if (existsSync('/proc/version')) {
22
+ try {
23
+ const version = await readFile('/proc/version', 'utf8')
24
+ if (version.toLowerCase().includes('microsoft')) {
25
+ return {
26
+ platform: 'wsl2',
27
+ openCommand: 'wslview',
28
+ credentialHelper: 'manager',
29
+ }
30
+ }
31
+ } catch {
32
+ // fall through to linux
33
+ }
34
+ }
35
+ return {
36
+ platform: 'linux',
37
+ openCommand: 'xdg-open',
38
+ credentialHelper: 'store',
39
+ }
40
+ }
41
+
42
+ // Fallback
43
+ return {
44
+ platform: 'linux',
45
+ openCommand: 'xdg-open',
46
+ credentialHelper: 'store',
47
+ }
48
+ }
@@ -0,0 +1,42 @@
1
+ import { execa } from 'execa'
2
+
3
+ /** @import { ExecResult } from '../types.js' */
4
+
5
+ /**
6
+ * Execute a shell command and return result. Never throws on non-zero exit.
7
+ * @param {string} command
8
+ * @param {string[]} [args]
9
+ * @param {object} [opts] - execa options
10
+ * @returns {Promise<ExecResult>}
11
+ */
12
+ export async function exec(command, args = [], opts = {}) {
13
+ const result = await execa(command, args, { reject: false, ...opts })
14
+ return {
15
+ stdout: result.stdout?.trim() ?? '',
16
+ stderr: result.stderr?.trim() ?? '',
17
+ exitCode: result.exitCode ?? 1,
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Check whether a binary is available in PATH.
23
+ * @param {string} binary
24
+ * @returns {Promise<string|null>} Resolved path or null if not found
25
+ */
26
+ export async function which(binary) {
27
+ const result = await execa('which', [binary], { reject: false })
28
+ if (result.exitCode !== 0 || !result.stdout) return null
29
+ return result.stdout.trim()
30
+ }
31
+
32
+ /**
33
+ * Run a command and return trimmed stdout. Throws on failure.
34
+ * @param {string} command
35
+ * @param {string[]} [args]
36
+ * @param {object} [opts]
37
+ * @returns {Promise<string>}
38
+ */
39
+ export async function execOrThrow(command, args = [], opts = {}) {
40
+ const result = await execa(command, args, { reject: true, ...opts })
41
+ return result.stdout?.trim() ?? ''
42
+ }
@@ -0,0 +1,58 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { loadConfig, saveConfig } from './config.js'
5
+ import { exec } from './shell.js'
6
+
7
+ const PKG_PATH = join(fileURLToPath(import.meta.url), '..', '..', '..', 'package.json')
8
+ const REPO = 'devvami/devvami'
9
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
10
+
11
+ /**
12
+ * Get current CLI version from package.json.
13
+ * @returns {Promise<string>}
14
+ */
15
+ export async function getCurrentVersion() {
16
+ const raw = await readFile(PKG_PATH, 'utf8')
17
+ return JSON.parse(raw).version
18
+ }
19
+
20
+ /**
21
+ * Fetch latest version via `npm view` (uses ~/.npmrc auth automatically).
22
+ * @param {{ force?: boolean }} [opts]
23
+ * @returns {Promise<string|null>}
24
+ */
25
+ export async function getLatestVersion({ force = false } = {}) {
26
+ const config = await loadConfig()
27
+ const now = Date.now()
28
+ const lastCheck = config.lastVersionCheck ? new Date(config.lastVersionCheck).getTime() : 0
29
+
30
+ if (!force && config.latestVersion && now - lastCheck < CACHE_TTL_MS) {
31
+ return config.latestVersion
32
+ }
33
+
34
+ try {
35
+ // Usa gh CLI (già autenticato) per leggere l'ultima GitHub Release
36
+ const result = await exec('gh', ['api', `repos/${REPO}/releases/latest`, '--jq', '.tag_name'])
37
+ if (result.exitCode !== 0) return null
38
+ // Il tag è nel formato "v1.0.0" — rimuove il prefisso "v"
39
+ const latest = result.stdout.trim().replace(/^v/, '') || null
40
+ if (latest) {
41
+ await saveConfig({ ...config, latestVersion: latest, lastVersionCheck: new Date().toISOString() })
42
+ }
43
+ return latest
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if a newer version is available.
51
+ * @param {{ force?: boolean }} [opts]
52
+ * @returns {Promise<{ hasUpdate: boolean, current: string, latest: string|null }>}
53
+ */
54
+ export async function checkForUpdate({ force = false } = {}) {
55
+ const [current, latest] = await Promise.all([getCurrentVersion(), getLatestVersion({ force })])
56
+ const hasUpdate = Boolean(latest && latest !== current)
57
+ return { hasUpdate, current, latest }
58
+ }