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,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
|
+
}
|