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
package/src/types.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @module types
3
+ * Shared JSDoc typedefs for the devvami CLI.
4
+ */
5
+
6
+ /**
7
+ * @typedef {Object} CLIConfig
8
+ * @property {string} org - GitHub org name (e.g. "devvami")
9
+ * @property {string} awsProfile - Default aws-vault profile name
10
+ * @property {string} [awsRegion] - Default AWS region (fallback: eu-west-1)
11
+ * @property {string} [shell] - Detected shell: bash | zsh | fish
12
+ * @property {{ teamId?: string, teamName?: string, authMethod?: 'oauth' | 'personal_token' }} [clickup] - ClickUp workspace config
13
+ * @property {string} [lastVersionCheck] - ISO8601 timestamp of last version check
14
+ * @property {string} [latestVersion] - Latest known CLI version
15
+ */
16
+
17
+ /**
18
+ * @typedef {'ok'|'warn'|'fail'} CheckStatus
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} DoctorCheck
23
+ * @property {string} name - Component name (e.g. "Node.js")
24
+ * @property {CheckStatus} status - Check result
25
+ * @property {string|null} version - Found version (if applicable)
26
+ * @property {string|null} required - Minimum required version (if applicable)
27
+ * @property {string|null} hint - Actionable hint to fix the issue
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} BranchName
32
+ * @property {'feature'|'fix'|'chore'|'hotfix'} type - Branch type
33
+ * @property {string} description - kebab-case description
34
+ * @property {string} full - Full branch name: `{type}/{description}`
35
+ */
36
+
37
+ /**
38
+ * @typedef {Object} Developer
39
+ * @property {string} githubUsername
40
+ * @property {string} githubName
41
+ * @property {string[]} githubOrgs
42
+ * @property {string[]} githubTeams
43
+ * @property {string} [awsAccountId]
44
+ * @property {string} [awsArn]
45
+ * @property {string} [awsRegion]
46
+ * @property {string} cliVersion
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} Template
51
+ * @property {string} name
52
+ * @property {string} description
53
+ * @property {string} language
54
+ * @property {string} htmlUrl
55
+ * @property {string} updatedAt
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} Repository
60
+ * @property {string} name
61
+ * @property {string} description
62
+ * @property {string} language
63
+ * @property {string} htmlUrl
64
+ * @property {string} pushedAt
65
+ * @property {string[]} topics
66
+ * @property {boolean} isPrivate
67
+ */
68
+
69
+ /**
70
+ * @typedef {'pass'|'fail'|'pending'} CIStatus
71
+ * @typedef {'approved'|'changes_requested'|'pending'} ReviewStatus
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} PullRequest
76
+ * @property {number} number
77
+ * @property {string} title
78
+ * @property {string} state
79
+ * @property {string} htmlUrl
80
+ * @property {string} headBranch
81
+ * @property {string} baseBranch
82
+ * @property {boolean} isDraft
83
+ * @property {CIStatus} ciStatus
84
+ * @property {ReviewStatus} reviewStatus
85
+ * @property {boolean} mergeable
86
+ * @property {string} author
87
+ * @property {string[]} reviewers
88
+ */
89
+
90
+ /**
91
+ * @typedef {Object} PRComment
92
+ * @property {number} id
93
+ * @property {string} author - GitHub login dell'autore
94
+ * @property {string} body - Corpo del commento in markdown
95
+ * @property {string} createdAt - ISO8601 timestamp
96
+ * @property {'issue'|'review'} type - Sorgente del commento
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} QAStep
101
+ * @property {string} text - Testo dello step
102
+ * @property {boolean} checked - true se completato (`[x]`)
103
+ */
104
+
105
+ /**
106
+ * @typedef {Object} PRDetail
107
+ * @property {number} number
108
+ * @property {string} title
109
+ * @property {string} state
110
+ * @property {string} htmlUrl
111
+ * @property {string} author
112
+ * @property {string} headBranch
113
+ * @property {string} baseBranch
114
+ * @property {boolean} isDraft
115
+ * @property {string[]} labels
116
+ * @property {string[]} reviewers
117
+ * @property {PRComment[]} qaComments - Commenti identificati come QA
118
+ * @property {QAStep[]} qaSteps - Step QA estratti dai commenti
119
+ */
120
+
121
+ /**
122
+ * @typedef {'completed'|'in_progress'|'queued'} RunStatus
123
+ * @typedef {'success'|'failure'|'cancelled'|null} RunConclusion
124
+ */
125
+
126
+ /**
127
+ * @typedef {Object} PipelineRun
128
+ * @property {number} id
129
+ * @property {string} name
130
+ * @property {RunStatus} status
131
+ * @property {RunConclusion} conclusion
132
+ * @property {string} branch
133
+ * @property {number} duration - seconds
134
+ * @property {string} actor
135
+ * @property {string} createdAt
136
+ * @property {string} htmlUrl
137
+ */
138
+
139
+ /**
140
+ * @typedef {Object} ClickUpTask
141
+ * @property {string} id
142
+ * @property {string} name
143
+ * @property {string} status
144
+ * @property {string} statusType - ClickUp internal status type: 'open' | 'in_progress' | 'review' | 'custom' | 'closed'
145
+ * @property {number} priority - 1=urgent, 2=high, 3=normal, 4=low
146
+ * @property {string|null} startDate - YYYY-MM-DD local date, null if not set
147
+ * @property {string|null} dueDate - YYYY-MM-DD local date, null if not set
148
+ * @property {string} url
149
+ * @property {string[]} assignees
150
+ * @property {string|null} listId - ClickUp list ID the task belongs to
151
+ * @property {string|null} listName - ClickUp list name the task belongs to
152
+ * @property {string|null} folderId - ClickUp folder ID, null if list is at root level
153
+ * @property {string|null} folderName - ClickUp folder name, null if list is at root level
154
+ */
155
+
156
+ /**
157
+ * @typedef {Object} AWSCostEntry
158
+ * @property {string} serviceName
159
+ * @property {number} amount
160
+ * @property {string} unit
161
+ * @property {{ start: string, end: string }} period
162
+ */
163
+
164
+ /**
165
+ * @typedef {Object} ExecResult
166
+ * @property {string} stdout
167
+ * @property {string} stderr
168
+ * @property {number} exitCode
169
+ */
170
+
171
+ /**
172
+ * @typedef {'macos'|'wsl2'|'linux'} Platform
173
+ */
174
+
175
+ /**
176
+ * @typedef {Object} PlatformInfo
177
+ * @property {Platform} platform
178
+ * @property {string} openCommand - Command to open browser
179
+ * @property {string} credentialHelper - Git credential helper
180
+ */
181
+
182
+ /**
183
+ * @typedef {Object} DocumentEntry
184
+ * @property {string} name - File name (e.g. "README.md")
185
+ * @property {string} path - Relative path in repo (e.g. "docs/architecture.md")
186
+ * @property {'readme'|'doc'|'swagger'|'asyncapi'} type - Classified doc type
187
+ * @property {number} size - File size in bytes
188
+ */
189
+
190
+ /**
191
+ * @typedef {Object} RepoDocsIndex
192
+ * @property {string} repo - Repository name
193
+ * @property {boolean} hasReadme - Has at least one README file
194
+ * @property {number} docsCount - Number of files in docs/ folder
195
+ * @property {boolean} hasSwagger - Has at least one OpenAPI/Swagger file
196
+ * @property {boolean} hasAsyncApi - Has at least one AsyncAPI file
197
+ * @property {DocumentEntry[]} entries - Full list of DocumentEntry found
198
+ */
199
+
200
+ /**
201
+ * @typedef {Object} SearchMatch
202
+ * @property {string} file - File path (e.g. "docs/deploy.md")
203
+ * @property {number} line - Line number (1-based)
204
+ * @property {string} context - Line text containing the match
205
+ * @property {number} occurrences - Total number of occurrences in the file
206
+ */
207
+
208
+ /**
209
+ * @typedef {Object} APIEndpoint
210
+ * @property {string} method - HTTP method (GET, POST, PUT, PATCH, DELETE…)
211
+ * @property {string} path - Endpoint path (e.g. "/users/{id}")
212
+ * @property {string} summary - Operation summary
213
+ * @property {string} parameters - Comma-separated params; required ones marked with *
214
+ */
215
+
216
+ /**
217
+ * @typedef {Object} AsyncChannel
218
+ * @property {string} channel - Channel name (e.g. "user/created")
219
+ * @property {string} operation - publish | subscribe | send | receive
220
+ * @property {string} summary - Operation summary
221
+ * @property {string} message - Message name/title or "—"
222
+ */
223
+
224
+ /**
225
+ * @typedef {Object} DetectedRepo
226
+ * @property {string} owner - GitHub owner (org or user)
227
+ * @property {string} repo - Repository name
228
+ */
@@ -0,0 +1,48 @@
1
+ import figlet from 'figlet'
2
+ import chalk from 'chalk'
3
+ import { BRAND_GRADIENT, animateGradientBanner, isColorEnabled } from './gradient.js'
4
+
5
+ // Brand colors
6
+ export const ORANGE = '#FF6B2B'
7
+ export const BLUE = '#4A9EFF'
8
+
9
+ /**
10
+ * Render figlet text as a Promise.
11
+ * @param {string} text
12
+ * @param {figlet.Options} opts
13
+ * @returns {Promise<string>}
14
+ */
15
+ function figletAsync(text, opts) {
16
+ return new Promise((resolve, reject) =>
17
+ figlet.text(text, opts, (err, result) => (err ? reject(err) : resolve(result ?? ''))),
18
+ )
19
+ }
20
+
21
+ /**
22
+ * Print the devvami welcome banner con gradient animato ciano→blu→indaco.
23
+ * In ambienti non-TTY (CI, pipe, --json) stampa un banner statico senza ANSI.
24
+ * @returns {Promise<void>}
25
+ */
26
+ export async function printBanner() {
27
+ const art = await figletAsync('DVMI', { font: 'ANSI Shadow' })
28
+ const artLines = art.split('\n').filter((l) => l.trim() !== '')
29
+ const width = Math.max(...artLines.map((l) => l.length)) + 4
30
+
31
+ const tagline = isColorEnabled
32
+ ? chalk.hex(BLUE).bold(' Devvami Developer CLI')
33
+ : ' Devvami Developer CLI'
34
+
35
+ const separator = isColorEnabled
36
+ ? chalk.hex(BLUE).dim('─'.repeat(Math.min(width, 60)))
37
+ : '─'.repeat(Math.min(width, 60))
38
+
39
+ process.stdout.write('\n')
40
+
41
+ // Anima ogni riga dell'ASCII art con gradient brand
42
+ await animateGradientBanner(artLines, BRAND_GRADIENT)
43
+
44
+ process.stdout.write(separator + '\n')
45
+ process.stdout.write(tagline + '\n')
46
+ process.stdout.write(separator + '\n')
47
+ process.stdout.write('\n')
48
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Base CLI error with an actionable hint for the user.
3
+ */
4
+ export class DvmiError extends Error {
5
+ /**
6
+ * @param {string} message - Human-readable error message
7
+ * @param {string} hint - Actionable suggestion to resolve the error
8
+ * @param {number} [exitCode] - Process exit code (default: 1)
9
+ */
10
+ constructor(message, hint, exitCode = 1) {
11
+ super(message)
12
+ this.name = 'DvmiError'
13
+ /** @type {string} */
14
+ this.hint = hint
15
+ /** @type {number} */
16
+ this.exitCode = exitCode
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Validation error for invalid user input (exit code 2).
22
+ */
23
+ export class ValidationError extends DvmiError {
24
+ /**
25
+ * @param {string} message
26
+ * @param {string} hint
27
+ */
28
+ constructor(message, hint) {
29
+ super(message, hint, 2)
30
+ this.name = 'ValidationError'
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Auth error for missing or expired authentication.
36
+ */
37
+ export class AuthError extends DvmiError {
38
+ /**
39
+ * @param {string} service - Service name (e.g. "GitHub", "AWS")
40
+ */
41
+ constructor(service) {
42
+ super(
43
+ `${service} authentication required`,
44
+ `Run \`dvmi auth login\` to authenticate`,
45
+ 1,
46
+ )
47
+ this.name = 'AuthError'
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Format an error for display in the terminal.
53
+ * @param {Error} err
54
+ * @returns {string}
55
+ */
56
+ export function formatError(err) {
57
+ if (err instanceof DvmiError) {
58
+ return `Error: ${err.message}\nHint: ${err.hint}`
59
+ }
60
+ return `Error: ${err.message}`
61
+ }
@@ -0,0 +1,130 @@
1
+ import chalk from 'chalk'
2
+ import readline from 'node:readline'
3
+
4
+ // ──────────────────────────────────────────────────────────────────────────────
5
+ // Brand gradient: ciano → blu vivido → indaco
6
+ // ──────────────────────────────────────────────────────────────────────────────
7
+
8
+ /**
9
+ * @typedef {[number, number, number]} GradientStop
10
+ * RGB tuple: [red 0-255, green 0-255, blue 0-255]
11
+ */
12
+
13
+ /** @type {GradientStop[]} */
14
+ export const BRAND_GRADIENT = [
15
+ [0, 212, 255], // #00D4FF — ciano elettrico
16
+ [0, 100, 255], // #0064FF — blu vivido
17
+ [100, 0, 220], // #6400DC — indaco profondo
18
+ ]
19
+
20
+ // ──────────────────────────────────────────────────────────────────────────────
21
+ // Terminal capability flags
22
+ // ──────────────────────────────────────────────────────────────────────────────
23
+
24
+ /** @type {boolean} true se chalk ha colori E NO_COLOR non è impostato */
25
+ export const isColorEnabled = chalk.level > 0 && process.env.NO_COLOR === undefined
26
+
27
+ /** @type {boolean} true se isTTY E isColorEnabled */
28
+ export const isAnimationEnabled = process.stdout.isTTY === true && isColorEnabled
29
+
30
+ // ──────────────────────────────────────────────────────────────────────────────
31
+ // gradientText
32
+ // ──────────────────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Colora ogni carattere del testo con un gradient RGB interpolato.
36
+ * Se chalk.level === 0 o NO_COLOR impostato, ritorna `text` invariato.
37
+ * Gli spazi non vengono colorati per evitare artefatti.
38
+ *
39
+ * @param {string} text - Testo da colorare
40
+ * @param {GradientStop[]} stops - Almeno 2 color stops RGB
41
+ * @param {number} [phase=0] - Offset 0.0–1.0 per shift animato
42
+ * @returns {string} - Stringa ANSI-colorata, o text invariato se no-color
43
+ */
44
+ export function gradientText(text, stops, phase = 0) {
45
+ if (!isColorEnabled) return text
46
+ if (stops.length < 2) throw new Error('At least 2 gradient stops required')
47
+
48
+ const chars = [...text]
49
+ const len = chars.length
50
+ if (len === 0) return ''
51
+
52
+ const segments = stops.length - 1
53
+
54
+ return chars.map((char, i) => {
55
+ if (char === ' ') return char
56
+
57
+ // Normalise t in [0, 1] with phase shift
58
+ const t = ((i / Math.max(len - 1, 1)) + phase) % 1
59
+
60
+ const seg = Math.min(Math.floor(t * segments), segments - 1)
61
+ const localT = t * segments - seg
62
+
63
+ const [r1, g1, b1] = stops[seg]
64
+ const [r2, g2, b2] = stops[seg + 1]
65
+
66
+ const r = Math.round(r1 + (r2 - r1) * localT)
67
+ const g = Math.round(g1 + (g2 - g1) * localT)
68
+ const b = Math.round(b1 + (b2 - b1) * localT)
69
+
70
+ return chalk.rgb(r, g, b)(char)
71
+ }).join('')
72
+ }
73
+
74
+ // ──────────────────────────────────────────────────────────────────────────────
75
+ // animateGradientBanner
76
+ // ──────────────────────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Anima un banner multi-riga con gradient in scrolling, poi si ferma.
80
+ * Se !isTTY o no-color, stampa le righe statiche con gradient e ritorna subito.
81
+ * Nasconde il cursore durante l'animazione e lo ripristina sempre.
82
+ *
83
+ * @param {string[]} lines - Righe del banner (output figlet)
84
+ * @param {GradientStop[]} stops - Color stops
85
+ * @param {number} [durationMs=1500] - Durata animazione in ms
86
+ * @returns {Promise<void>}
87
+ */
88
+ export async function animateGradientBanner(lines, stops, durationMs = 1500) {
89
+ // Stampa sempre il banner statico prima (per terminali che non supportano animazione)
90
+ const printStatic = (phase = 0) => {
91
+ for (const line of lines) {
92
+ process.stdout.write(gradientText(line, stops, phase) + '\n')
93
+ }
94
+ }
95
+
96
+ if (!isAnimationEnabled) {
97
+ printStatic()
98
+ return
99
+ }
100
+
101
+ // Stampa il banner iniziale e prendi nota di quante righe occupa
102
+ printStatic()
103
+ process.stdout.write('\x1B[?25l') // nascondi cursore
104
+
105
+ let phase = 0
106
+ const intervalMs = 80
107
+ const frames = Math.ceil(durationMs / intervalMs)
108
+ let frameCount = 0
109
+
110
+ await new Promise((resolve) => {
111
+ const id = setInterval(() => {
112
+ frameCount++
113
+ phase = (phase + 0.03) % 1
114
+
115
+ // Risali di `lines.length` righe e ridisegna
116
+ readline.moveCursor(process.stdout, 0, -lines.length)
117
+ for (const line of lines) {
118
+ readline.clearLine(process.stdout, 0)
119
+ process.stdout.write(gradientText(line, stops, phase) + '\n')
120
+ }
121
+
122
+ if (frameCount >= frames) {
123
+ clearInterval(id)
124
+ resolve(undefined)
125
+ }
126
+ }, intervalMs)
127
+ })
128
+
129
+ process.stdout.write('\x1B[?25h') // ripristina cursore
130
+ }
@@ -0,0 +1,29 @@
1
+ import open from 'open'
2
+ import { detectPlatform } from '../services/platform.js'
3
+ import { exec } from '../services/shell.js'
4
+
5
+ /**
6
+ * Open a URL in the default browser, using the platform-appropriate command.
7
+ * @param {string} url
8
+ * @returns {Promise<void>}
9
+ */
10
+ export async function openBrowser(url) {
11
+ const { platform, openCommand } = await detectPlatform()
12
+
13
+ if (platform === 'macos') {
14
+ await open(url)
15
+ return
16
+ }
17
+
18
+ // WSL2: try wslview first, fallback to xdg-open
19
+ if (platform === 'wsl2') {
20
+ const wslview = await exec('wslview', [url])
21
+ if (wslview.exitCode === 0) return
22
+ }
23
+
24
+ // Linux / fallback
25
+ const result = await exec(openCommand, [url])
26
+ if (result.exitCode !== 0) {
27
+ await open(url) // final fallback via open package
28
+ }
29
+ }
@@ -0,0 +1,48 @@
1
+ import readline from 'node:readline'
2
+ import { isAnimationEnabled, BRAND_GRADIENT, gradientText } from './gradient.js'
3
+
4
+ /**
5
+ * Stampa testo con effetto typewriter (lettera per lettera).
6
+ * Se !isTTY o no-color, stampa tutto in una volta.
7
+ *
8
+ * @param {string} text - Testo da stampare
9
+ * @param {Object} [opts]
10
+ * @param {number} [opts.interval=30] - Ms per carattere
11
+ * @param {import('./gradient.js').GradientStop[]} [opts.gradient] - Se fornito, applica gradient
12
+ * @returns {Promise<void>}
13
+ */
14
+ export async function typewriter(text, opts = {}) {
15
+ const { interval = 30, gradient } = opts
16
+
17
+ if (!isAnimationEnabled) {
18
+ const out = gradient ? gradientText(text, gradient) : text
19
+ process.stdout.write(out + '\n')
20
+ return
21
+ }
22
+
23
+ const chars = [...text]
24
+
25
+ for (let i = 0; i <= chars.length; i++) {
26
+ const partial = chars.slice(0, i).join('')
27
+ const colored = gradient ? gradientText(partial, gradient) : partial
28
+
29
+ readline.cursorTo(process.stdout, 0)
30
+ readline.clearLine(process.stdout, 0)
31
+ process.stdout.write(colored)
32
+
33
+ await new Promise((r) => setTimeout(r, interval))
34
+ }
35
+
36
+ process.stdout.write('\n')
37
+ }
38
+
39
+ /**
40
+ * Wrapper sintetico per typewriter con BRAND_GRADIENT di default.
41
+ *
42
+ * @param {string} text - Messaggio di completamento
43
+ * @param {import('./gradient.js').GradientStop[]} [gradient=BRAND_GRADIENT]
44
+ * @returns {Promise<void>}
45
+ */
46
+ export async function typewriterLine(text, gradient = BRAND_GRADIENT) {
47
+ return typewriter(text, { gradient, interval: 25 })
48
+ }
@@ -0,0 +1,42 @@
1
+ const REPO_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
2
+ const MAX_LENGTH = 100
3
+
4
+ /**
5
+ * @typedef {Object} ValidationResult
6
+ * @property {boolean} valid
7
+ * @property {string} [error]
8
+ * @property {string} [suggestion]
9
+ */
10
+
11
+ /**
12
+ * Validate a GitHub repository name.
13
+ * @param {string} name
14
+ * @returns {ValidationResult}
15
+ */
16
+ export function validateRepoName(name) {
17
+ if (!name || name.length === 0) {
18
+ return { valid: false, error: 'Repository name cannot be empty' }
19
+ }
20
+
21
+ if (name.length > MAX_LENGTH) {
22
+ return {
23
+ valid: false,
24
+ error: `Repository name too long (${name.length} chars, max ${MAX_LENGTH})`,
25
+ }
26
+ }
27
+
28
+ if (!REPO_NAME_RE.test(name)) {
29
+ const suggested = name
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9-]/g, '-')
32
+ .replace(/-+/g, '-')
33
+ .replace(/^-|-$/g, '')
34
+ return {
35
+ valid: false,
36
+ error: `Repository name must be lowercase kebab-case. Got "${name}"`,
37
+ suggestion: suggested,
38
+ }
39
+ }
40
+
41
+ return { valid: true }
42
+ }