@nan0web/release 3.2.0 → 3.4.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 (69) hide show
  1. package/bin/nan0release.js +33 -2
  2. package/bin/serve.js +73 -0
  3. package/package.json +45 -17
  4. package/src/Release/Document.js +1 -0
  5. package/src/Release/Scanner.js +3 -2
  6. package/src/Release.js +7 -7
  7. package/src/domain/App.js +31 -2
  8. package/src/domain/BumpCommand.js +10 -3
  9. package/src/domain/CheckCommand.js +504 -146
  10. package/src/domain/CheckCommand.story.js +92 -0
  11. package/src/domain/CloseCommand.js +13 -9
  12. package/src/domain/Dashboard/Dashboard.story.js +155 -0
  13. package/src/domain/Dashboard/DashboardModel.js +308 -0
  14. package/src/domain/Dashboard/ReleaseDiscovery.js +132 -0
  15. package/src/domain/Dashboard/ReleaseParser.js +226 -0
  16. package/src/domain/Dashboard/index.js +19 -0
  17. package/src/domain/DepsCommand.js +5 -2
  18. package/src/domain/InitCommand.js +18 -16
  19. package/src/domain/ListCommand.js +45 -0
  20. package/src/domain/PreviewCommand.js +39 -0
  21. package/src/domain/PublishCommand.js +7 -3
  22. package/src/domain/Registry/Registry.story.js +82 -0
  23. package/src/domain/Registry/RegistryModel.js +124 -0
  24. package/src/domain/ServeCommand.js +165 -0
  25. package/src/domain/ServeCommand.story.js +88 -0
  26. package/src/domain/SpecCommand.js +3 -1
  27. package/src/domain/StatusCommand.js +249 -3
  28. package/src/domain/ViewCommand.js +99 -0
  29. package/src/domain/WebCommand.js +140 -0
  30. package/src/domain/inspect/ReleaseAuditor.js +508 -0
  31. package/src/domain/inspect/ReleaseAuditor.story.js +136 -0
  32. package/src/domain/templates/release.html +34 -0
  33. package/src/domain/templates/release.html.template.js +34 -0
  34. package/src/index.js +6 -0
  35. package/src/inspectors/inspect-js.js +32 -0
  36. package/src/releases/1/0/v1.0.3/task.test.js +1 -1
  37. package/src/utils/nodejs/GitTelemetry.js +154 -0
  38. package/types/Release/Document.d.ts +4 -0
  39. package/types/Release/Scanner.d.ts +2 -2
  40. package/types/domain/App.d.ts +19 -3
  41. package/types/domain/BumpCommand.d.ts +9 -3
  42. package/types/domain/CheckCommand.d.ts +127 -15
  43. package/types/domain/CheckCommand.story.d.ts +1 -0
  44. package/types/domain/CloseCommand.d.ts +2 -1
  45. package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
  46. package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
  47. package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
  48. package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
  49. package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
  50. package/types/domain/Dashboard/index.d.ts +3 -0
  51. package/types/domain/DepsCommand.d.ts +4 -2
  52. package/types/domain/InitCommand.d.ts +2 -1
  53. package/types/domain/ListCommand.d.ts +18 -0
  54. package/types/domain/PreviewCommand.d.ts +6 -0
  55. package/types/domain/PublishCommand.d.ts +6 -3
  56. package/types/domain/Registry/Registry.story.d.ts +1 -0
  57. package/types/domain/Registry/RegistryModel.d.ts +61 -0
  58. package/types/domain/ServeCommand.d.ts +59 -0
  59. package/types/domain/ServeCommand.story.d.ts +1 -0
  60. package/types/domain/SpecCommand.d.ts +2 -1
  61. package/types/domain/StatusCommand.d.ts +39 -0
  62. package/types/domain/ViewCommand.d.ts +27 -0
  63. package/types/domain/WebCommand.d.ts +47 -0
  64. package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
  65. package/types/domain/inspect/ReleaseAuditor.story.d.ts +1 -0
  66. package/types/domain/templates/release.html.template.d.ts +2 -0
  67. package/types/index.d.ts +5 -0
  68. package/types/inspectors/inspect-js.d.ts +2 -0
  69. package/types/utils/nodejs/GitTelemetry.d.ts +23 -0
@@ -0,0 +1,132 @@
1
+ import { extractTasks } from './ReleaseParser.js'
2
+
3
+ /**
4
+ * Parse semver string into [major, minor, patch] numbers
5
+ * @param {string} [v='']
6
+ * @returns {[number, number, number]}
7
+ */
8
+ export function parseSemver(v = '') {
9
+ const match = String(v).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/)
10
+ if (!match) return [0, 0, 0]
11
+ return [
12
+ parseInt(match[1] || '0', 10),
13
+ parseInt(match[2] || '0', 10),
14
+ parseInt(match[3] || '0', 10),
15
+ ]
16
+ }
17
+
18
+ /**
19
+ * Compare two semver strings in descending order
20
+ * @param {string} [a='']
21
+ * @param {string} [b='']
22
+ * @returns {number}
23
+ */
24
+ export function compareSemverDesc(a = '', b = '') {
25
+ const pa = parseSemver(a)
26
+ const pb = parseSemver(b)
27
+ if (pa[0] !== pb[0]) return pb[0] - pa[0]
28
+ if (pa[1] !== pb[1]) return pb[1] - pa[1]
29
+ return pb[2] - pa[2]
30
+ }
31
+
32
+ /**
33
+ * Format release version to path segments e.g. "1.0.0" -> "1/0/v1.0.0"
34
+ * @param {string} [version='']
35
+ * @returns {string}
36
+ */
37
+ export function versionToPath(version = '') {
38
+ const clean = version.replace(/^v/, '')
39
+ const parts = clean.split('.').slice(0, 2)
40
+ return [...parts, `v${clean}`].join('/')
41
+ }
42
+
43
+ /**
44
+ * Check whether a document exists in the virtual database
45
+ * @param {any} db
46
+ * @param {string} path
47
+ * @returns {Promise<boolean>}
48
+ */
49
+ export async function documentExists(db, path) {
50
+ try {
51
+ const doc = await db.loadDocument(path, null)
52
+ return doc !== null && doc !== undefined
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Load release doc (release.md, task.md, plan.md) and extract tasks
60
+ * @param {any} db
61
+ * @param {string} releaseDir
62
+ * @returns {Promise<{ releaseContent: string, sourceDoc: string, tasks: import('./ReleaseParser.js').TaskItem[] }>}
63
+ */
64
+ export async function loadReleaseDoc(db, releaseDir) {
65
+ let releaseContent = ''
66
+ let sourceDoc = `${releaseDir}/release.md`
67
+ for (const docFile of ['release.md', 'task.md', 'plan.md']) {
68
+ try {
69
+ const content = await db.loadDocument(`${releaseDir}/${docFile}`, '')
70
+ if (content) {
71
+ releaseContent = content
72
+ sourceDoc = `${releaseDir}/${docFile}`
73
+ break
74
+ }
75
+ } catch {}
76
+ }
77
+ return { releaseContent, sourceDoc, tasks: releaseContent ? extractTasks(releaseContent) : [] }
78
+ }
79
+
80
+ /**
81
+ * Discover release version directories using database listDir
82
+ * @param {any} db
83
+ * @param {string} projectRoot
84
+ * @returns {Promise<Array<{ version: string, path: string }>>}
85
+ */
86
+ export async function discoverReleases(db, projectRoot) {
87
+ const results = []
88
+ const releasesUri = `${projectRoot}releases`.replace(/^\/+/, '') || 'releases'
89
+
90
+ try {
91
+ if (typeof db.listDir === 'function') {
92
+ const queue = [{ uri: releasesUri, depth: 0 }]
93
+ while (queue.length > 0) {
94
+ const item = queue.shift()
95
+ if (!item || item.depth > 3) continue
96
+ let entries = []
97
+ try {
98
+ entries = await db.listDir(item.uri)
99
+ } catch {
100
+ continue
101
+ }
102
+ if (!Array.isArray(entries)) continue
103
+
104
+ for (const entry of entries) {
105
+ const rawName = entry.name || (entry.path || '').split('/').pop() || ''
106
+ const name = rawName.replace(/\/$/, '')
107
+ const isDir = Boolean(
108
+ entry.stat?.isDirectory ||
109
+ entry.isDirectory ||
110
+ entry.isDir ||
111
+ rawName.endsWith('/')
112
+ )
113
+
114
+ const dirMatch = name.match(/^v(\d+\.\d+\.\d+[a-zA-Z0-9._-]*)/)
115
+ if (dirMatch) {
116
+ const version = dirMatch[1]
117
+ const entryPath = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
118
+ const normalizedPath = entryPath.replace(/^\.\//, '')
119
+ if (!results.find((r) => r.path === normalizedPath)) {
120
+ results.push({ version, path: normalizedPath })
121
+ }
122
+ } else if (isDir && !name.startsWith('.') && name !== 'node_modules' && name !== '_') {
123
+ const nextUri = entry.path ? entry.path.replace(/\/$/, '') : `${item.uri}/${name}`
124
+ queue.push({ uri: nextUri, depth: item.depth + 1 })
125
+ }
126
+ }
127
+ }
128
+ }
129
+ } catch {}
130
+
131
+ return results.sort((a, b) => compareSemverDesc(a.version, b.version))
132
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @typedef {Object} TaskItem
3
+ * @property {string} content - Task title or description
4
+ * @property {string} status - todo | InProgress | Done
5
+ * @property {string} slug - Task slug / identifier
6
+ * @property {'0' | '1' | '2' | '3'} [priority] - Numeric priority
7
+ */
8
+
9
+ /**
10
+ * Parse frontmatter YAML from markdown text
11
+ * @param {*} input
12
+ * @returns {Record<string, any>}
13
+ */
14
+ export function parseFrontmatter(input) {
15
+ const content = String(input || '')
16
+ if (!content.trim()) return {}
17
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
18
+ if (!match) return {}
19
+ const res = {}
20
+ for (const rawLine of match[1].split('\n')) {
21
+ const line = rawLine.trim()
22
+ if (!line || line.startsWith('#') || !line.includes(':')) continue
23
+ const key = line.slice(0, line.indexOf(':')).trim()
24
+ const val = line.slice(line.indexOf(':') + 1).trim().replace(/^['"](.*)['"]$/, '$1')
25
+ res[key] = val
26
+ }
27
+ return res
28
+ }
29
+
30
+ /**
31
+ * Normalize priority to numeric string '0' | '1' | '2' | '3'
32
+ * @param {any} input
33
+ * @returns {'0' | '1' | '2' | '3'}
34
+ */
35
+ export function normalizePriority(input) {
36
+ const str = String(input ?? '1').trim().toLowerCase()
37
+ if (str === '0' || str === 'p0' || str.includes('urgent') || str.includes('high') || str.includes('критич')) return '0'
38
+ if (str === '2' || str === 'p2' || str.includes('low') || str.includes('backlog') || str.includes('фон')) return '2'
39
+ if (str === '3' || str === 'p3') return '3'
40
+ return '1'
41
+ }
42
+
43
+ function detectPriority(text) {
44
+ const m = text.match(/\b(p0|p1|p2|p3|urgent|high|med|medium|low)\b/i)
45
+ if (!m) return '1'
46
+ const p = m[1].toLowerCase()
47
+ if (p === 'p0' || p === 'urgent' || p === 'high') return '0'
48
+ if (p === 'p2') return '2'
49
+ if (p === 'p3' || p === 'low') return '3'
50
+ return '1'
51
+ }
52
+
53
+ function cleanTaskTitle(text) {
54
+ return text.replace(/\[\s*\]\([^)]+\)/g, '').replace(/\[([^\]]+)\]$/, '').replace(/^\*\*|\*\*$/g, '').trim()
55
+ }
56
+
57
+ /**
58
+ * Extract clean task items from release markdown
59
+ * @param {*} input
60
+ * @returns {TaskItem[]}
61
+ */
62
+ export function extractTasks(input) {
63
+ const content = String(input || '')
64
+ if (!content.trim()) return []
65
+ const tasks = []
66
+
67
+ for (const rawLine of content.split('\n')) {
68
+ const line = rawLine.trim()
69
+ if (!line) continue
70
+
71
+ const checkMatch = line.match(/^[-*+]?\s*(?:\[([ xX])\]|\d+\.\s*\[([ xX])\])\s*(.+)$/)
72
+ if (checkMatch) {
73
+ const isChecked = (checkMatch[1] || checkMatch[2] || '').toLowerCase() === 'x'
74
+ const text = cleanTaskTitle(checkMatch[3])
75
+ tasks.push({
76
+ content: text,
77
+ status: isChecked ? 'Done' : 'Todo',
78
+ slug: text.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
79
+ priority: detectPriority(checkMatch[3]),
80
+ })
81
+ continue
82
+ }
83
+
84
+ const headingMatch = line.match(/^###\s*(Done|InProgress|Todo|Draft)\s+(.+)$/i)
85
+ if (headingMatch) {
86
+ const statusWord = headingMatch[1].toLowerCase()
87
+ const rawTitle = headingMatch[2].trim()
88
+ const slugMatch = rawTitle.match(/\[([^\]]+)\]$/)
89
+ const slug = slugMatch ? slugMatch[1] : ''
90
+ const text = cleanTaskTitle(rawTitle)
91
+ const status = statusWord === 'done' ? 'Done' : statusWord === 'inprogress' ? 'InProgress' : 'Todo'
92
+ tasks.push({
93
+ content: text,
94
+ status,
95
+ slug: slug || text.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
96
+ priority: detectPriority(rawTitle),
97
+ })
98
+ continue
99
+ }
100
+
101
+ const numMatch = line.match(/^###\s*\d+\.\s*(.+)$/)
102
+ if (numMatch) {
103
+ const rawTitle = numMatch[1].trim()
104
+ if (/^(Team|Tasks|Overview|Section|User Stories|Feedback|Acceptance Criteria)/i.test(rawTitle)) continue
105
+ const text = cleanTaskTitle(rawTitle)
106
+ if (text.length > 0) {
107
+ tasks.push({
108
+ content: text,
109
+ status: 'Todo',
110
+ slug: text.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
111
+ priority: detectPriority(rawTitle),
112
+ })
113
+ }
114
+ }
115
+ }
116
+ return tasks
117
+ }
118
+
119
+ /**
120
+ * Calculate days remaining until deadline
121
+ * @param {string} [deadlineStr]
122
+ * @returns {number | null}
123
+ */
124
+ export function calculateDaysRemaining(deadlineStr = '') {
125
+ if (!deadlineStr) return null
126
+ try {
127
+ const target = new Date(deadlineStr)
128
+ if (isNaN(target.getTime())) return null
129
+ return Math.ceil((target.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
130
+ } catch {
131
+ return null
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Parse user.md content for telemetry metrics (hours, iterations, RRS) across languages
137
+ * @param {*} [input]
138
+ * @returns {{ hours: number, iterations: number, rrs: number }}
139
+ */
140
+ export function parseUserMetrics(input = '') {
141
+ const content = String(input || '')
142
+ if (!content.trim()) return { hours: 0, iterations: 0, rrs: 0 }
143
+
144
+ let hours = 0, iterations = 0, rrs = 0
145
+ const fm = parseFrontmatter(content)
146
+ if (fm.hours || fm.spentHours || fm.time) hours = parseFloat(fm.hours || fm.spentHours || fm.time || '0') || 0
147
+ if (fm.iterations || fm.iters || fm.runs) iterations = parseInt(fm.iterations || fm.iters || fm.runs || '0', 10) || 0
148
+ if (fm.rrs || fm.score) rrs = parseInt(fm.rrs || fm.score || '0', 10) || 0
149
+
150
+ for (const rawLine of content.split('\n')) {
151
+ const line = rawLine.trim()
152
+ if (!line || !line.includes(':')) continue
153
+ const beforeColon = line.slice(0, line.indexOf(':')).toLowerCase()
154
+ const numMatch = line.slice(line.indexOf(':') + 1).match(/([0-9]+(?:\.[0-9]+)?)/)
155
+ if (!numMatch) continue
156
+ const val = parseFloat(numMatch[1])
157
+
158
+ if (hours === 0 && /(витрачений час|час розробки|time spent|development time|spent time|dev hours|hours|годин)/.test(beforeColon)) {
159
+ hours = val
160
+ } else if (iterations === 0 && /(кількість ітерацій|ітерацій|iteration count|test runs|refactor runs|iterations|runs)/.test(beforeColon)) {
161
+ iterations = Math.round(val)
162
+ } else if (rrs === 0 && /(rrs|release readiness score|фінальний бал|бал rrs|readiness score)/.test(beforeColon)) {
163
+ rrs = Math.round(val)
164
+ }
165
+ }
166
+ return { hours, iterations, rrs }
167
+ }
168
+
169
+ /**
170
+ * Resolve priority from frontmatter or days remaining
171
+ * @param {Record<string, any>} fm
172
+ * @param {number | null} daysRemaining
173
+ * @returns {'0' | '1' | '2' | '3'}
174
+ */
175
+ export function resolvePriority(fm = {}, daysRemaining = null) {
176
+ let priority = '1'
177
+ const rawPrio = String(fm.priority || fm.prio || '').toLowerCase()
178
+ if (rawPrio.includes('p0') || rawPrio.includes('high') || rawPrio.includes('urgent') || rawPrio.includes('критич')) priority = '0'
179
+ else if (rawPrio.includes('p2') || rawPrio.includes('low') || rawPrio.includes('backlog') || rawPrio.includes('фон')) priority = '2'
180
+ else if (rawPrio.includes('p3')) priority = '3'
181
+ else if (rawPrio.includes('p1') || rawPrio.includes('med')) priority = '1'
182
+ else if (daysRemaining !== null) {
183
+ if (daysRemaining <= 3) priority = '0'
184
+ else if (daysRemaining <= 7) priority = '1'
185
+ else priority = '2'
186
+ }
187
+ return normalizePriority(priority)
188
+ }
189
+
190
+ /**
191
+ * Sort tasks by criteria ('priority', 'status', 'alphabetical')
192
+ * @param {TaskItem[]} [tasks=[]]
193
+ * @param {'priority' | 'status' | 'alphabetical'} [criteria='priority']
194
+ * @returns {TaskItem[]}
195
+ */
196
+ export function sortTasks(tasks = [], criteria = 'priority') {
197
+ const list = [...tasks]
198
+ if (criteria === 'priority') {
199
+ return list.sort((a, b) => parseInt(normalizePriority(a.priority), 10) - parseInt(normalizePriority(b.priority), 10))
200
+ }
201
+ if (criteria === 'status') {
202
+ const weight = { Todo: 0, InProgress: 1, Done: 2 }
203
+ return list.sort((a, b) => (weight[a.status] ?? 99) - (weight[b.status] ?? 99))
204
+ }
205
+ if (criteria === 'alphabetical') {
206
+ return list.sort((a, b) => (a.content || '').localeCompare(b.content || ''))
207
+ }
208
+ return list
209
+ }
210
+
211
+ /**
212
+ * Determine release status ('wip' | 'closed' | 'unknown')
213
+ * @param {object} params
214
+ * @returns {'wip' | 'closed' | 'unknown'}
215
+ */
216
+ export function determineReleaseStatus({ fmStatus = '', hasSpec = false, hasTest = false, tasks = [], userStr = '', metrics = { hours: 0, rrs: 0 } } = {}) {
217
+ const s = (fmStatus || '').toLowerCase()
218
+ if (s === 'active' || s === 'wip') return 'wip'
219
+ if (s === 'closed' || s === 'done' || s === 'sealed' || hasTest) return 'closed'
220
+ if (hasSpec) return 'wip'
221
+ if (tasks.length > 0) return tasks.every((t) => t.status === 'Done') ? 'closed' : 'wip'
222
+ if (userStr.includes('status: active') || userStr.includes('status: wip')) return 'wip'
223
+ if (userStr.includes('status: closed') || userStr.includes('status: done')) return 'closed'
224
+ if (metrics.hours > 0 || metrics.rrs > 0 || userStr.length > 0) return 'wip'
225
+ return 'unknown'
226
+ }
@@ -0,0 +1,19 @@
1
+ export { DashboardModel } from './DashboardModel.js'
2
+ export {
3
+ parseFrontmatter,
4
+ extractTasks,
5
+ calculateDaysRemaining,
6
+ parseUserMetrics,
7
+ normalizePriority,
8
+ resolvePriority,
9
+ sortTasks,
10
+ determineReleaseStatus,
11
+ } from './ReleaseParser.js'
12
+ export {
13
+ parseSemver,
14
+ compareSemverDesc,
15
+ versionToPath,
16
+ documentExists,
17
+ loadReleaseDoc,
18
+ discoverReleases,
19
+ } from './ReleaseDiscovery.js'
@@ -2,6 +2,11 @@ import { ModelAsApp, result, show } from '@nan0web/ui'
2
2
  import { runSpawn } from '@nan0web/test'
3
3
 
4
4
  export default class DepsCommand extends ModelAsApp {
5
+ /** @type {boolean} Apply fixes automatically */
6
+ fix = false
7
+ /** @type {boolean} Update packages to the latest available versions */
8
+ latest = false
9
+
5
10
  static fix = {
6
11
  help: 'Apply fixes automatically',
7
12
  default: false,
@@ -21,8 +26,6 @@ export default class DepsCommand extends ModelAsApp {
21
26
  */
22
27
  constructor(data = {}, options = {}) {
23
28
  super(data, options)
24
- /** @type {boolean} Apply fixes automatically */ this.fix
25
- /** @type {boolean} Update packages to the latest available versions */ this.latest
26
29
  }
27
30
 
28
31
  /**
@@ -1,6 +1,9 @@
1
1
  import { ModelAsApp, result, show } from '@nan0web/ui'
2
2
 
3
3
  export default class InitCommand extends ModelAsApp {
4
+ /** @type {string} Release version */
5
+ version = ''
6
+
4
7
  static version = {
5
8
  help: 'Release version',
6
9
  default: '',
@@ -17,7 +20,6 @@ export default class InitCommand extends ModelAsApp {
17
20
  */
18
21
  constructor(data = {}, options = {}) {
19
22
  super(data, options)
20
- /** @type {string} Release version */ this.version
21
23
  }
22
24
 
23
25
  /**
@@ -25,8 +27,11 @@ export default class InitCommand extends ModelAsApp {
25
27
  */
26
28
  async *run() {
27
29
  const { t, db } = this._
30
+ if (!db) {
31
+ throw new Error('Database instance ({ db }) is required in InitCommand')
32
+ }
28
33
  if (!this.version) {
29
- throw new Error(t(InitCommand.version.errorRequired))
34
+ throw new Error(t ? t(InitCommand.version.errorRequired) : InitCommand.version.errorRequired)
30
35
  }
31
36
  const v = this.version.startsWith('v') ? this.version : `v${this.version}`
32
37
 
@@ -34,24 +39,21 @@ export default class InitCommand extends ModelAsApp {
34
39
  const major = parts[0] || '0'
35
40
  const minor = parts[1] || '0'
36
41
 
37
- const { mkdirSync, writeFileSync } = await import('node:fs')
38
- const { join } = await import('node:path')
39
-
40
- const dir = join(process.cwd(), 'releases', major, minor, v)
41
- mkdirSync(dir, { recursive: true })
42
+ const taskPath = `releases/${major}/${minor}/${v}/task.md`
43
+ await db.saveDocument(taskPath, `# Release ${v}\n\n- [ ] Task 1\n`)
42
44
 
43
- const taskPath = join(dir, 'task.md')
44
- writeFileSync(taskPath, `# Release ${v}\n\n- [ ] Task 1`, 'utf8')
45
-
46
- const specPath = join(dir, 'test.spec.js')
47
- writeFileSync(
45
+ const specPath = `releases/${major}/${minor}/${v}/task.spec.js`
46
+ await db.saveDocument(
48
47
  specPath,
49
- `import test from 'node:test'\nimport assert from 'node:assert'\n\ntest('Sample spec', () => {\n\tassert.ok(true)\n})\n`,
50
- 'utf8',
48
+ `import test from 'node:test'\nimport assert from 'node:assert'\n\ntest('Release ${v} Specification', () => {\n\tassert.ok(true)\n})\n`,
51
49
  )
52
50
 
53
- yield show(`✅ Initialized release ${v} in ${dir}`, 'success')
51
+ yield show(`✅ Initialized release ${v} in releases/${major}/${minor}/${v}`, 'success')
54
52
 
55
- return result({})
53
+ return result({
54
+ version: v,
55
+ taskPath,
56
+ specPath,
57
+ })
56
58
  }
57
59
  }
@@ -0,0 +1,45 @@
1
+ import { ModelAsApp, show, result } from '@nan0web/ui'
2
+ import Scanner from '../Release/Scanner.js'
3
+
4
+ /**
5
+ * ListCommand - ModelAsApp Subcommand to list all available releases in chronological order.
6
+ */
7
+ export default class ListCommand extends ModelAsApp {
8
+ static alias = 'list'
9
+
10
+ static UI = {
11
+ title: 'list',
12
+ help: 'List all available release versions',
13
+ noReleases: 'No releases found.',
14
+ }
15
+
16
+ /**
17
+ * @param {Partial<ListCommand>} [data]
18
+ * @param {import('@nan0web/types').ModelOptions} [options]
19
+ */
20
+ constructor(data = {}, options = {}) {
21
+ super(data, options)
22
+ }
23
+
24
+ async *run() {
25
+ const targetDir = process.env.RELEASE_CWD || process.env.INIT_CWD || process.cwd()
26
+ const scanner = new Scanner(targetDir)
27
+ const statuses = scanner.status()
28
+
29
+ if (!statuses.length) {
30
+ yield show(ListCommand.UI.noReleases, 'warn')
31
+ return result({ versions: [] })
32
+ }
33
+
34
+ // Sort SemVer descending (newest first)
35
+ const sorted = [...statuses].sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))
36
+
37
+ for (const item of sorted) {
38
+ yield show(item.version)
39
+ }
40
+
41
+ return result({ versions: sorted.map((s) => s.version) })
42
+ }
43
+ }
44
+
45
+ export { ListCommand }
@@ -0,0 +1,39 @@
1
+ import WebCommand from './WebCommand.js'
2
+
3
+ /**
4
+ * PreviewCommand - ModelAsApp Subcommand to generate, serve and preview PM-as-Code Web Dashboard.
5
+ */
6
+ export default class PreviewCommand extends WebCommand {
7
+ static alias = 'preview'
8
+
9
+ static UI = {
10
+ title: 'preview',
11
+ help: 'Generate, serve and preview PM-as-Code Web Dashboard',
12
+ loading: 'Aggregating release telemetry and preparing Web Dashboard preview...',
13
+ generated: '✨ Web Dashboard успішно згенеровано: {$path}',
14
+ serving: '🌐 Web Dashboard запущено: http://localhost:{$port} (Натисніть Ctrl+C для зупинки)',
15
+ }
16
+
17
+ static port = {
18
+ help: 'Port to serve HTML dashboard over HTTP',
19
+ default: 3333,
20
+ type: 'number',
21
+ alias: 'p',
22
+ }
23
+
24
+ static open = {
25
+ help: 'Automatically open in browser',
26
+ default: true,
27
+ type: 'boolean',
28
+ }
29
+
30
+ /**
31
+ * @param {Partial<PreviewCommand>} [data]
32
+ * @param {import('@nan0web/types').ModelOptions} [options]
33
+ */
34
+ constructor(data = {}, options = {}) {
35
+ super({ port: 3333, open: true, ...data }, options)
36
+ }
37
+ }
38
+
39
+ export { PreviewCommand }
@@ -2,6 +2,13 @@ import { ModelAsApp, result, show } from '@nan0web/ui'
2
2
  import { runSpawn } from '@nan0web/test'
3
3
 
4
4
  export default class PublishCommand extends ModelAsApp {
5
+ /** @type {boolean} Bump major version */
6
+ major = false
7
+ /** @type {boolean} Bump minor version */
8
+ minor = false
9
+ /** @type {boolean} Bump patch version */
10
+ patch = false
11
+
5
12
  static major = {
6
13
  help: 'Bump major version',
7
14
  default: false,
@@ -25,9 +32,6 @@ export default class PublishCommand extends ModelAsApp {
25
32
  */
26
33
  constructor(data = {}, options = {}) {
27
34
  super(data, options)
28
- /** @type {boolean} Bump major version */ this.major
29
- /** @type {boolean} Bump minor version */ this.minor
30
- /** @type {boolean} Bump patch version */ this.patch
31
35
  }
32
36
 
33
37
  /**
@@ -0,0 +1,82 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import DB from '@nan0web/db'
4
+ import { RegistryModel } from './RegistryModel.js'
5
+
6
+ describe('RegistryModel OLMUI Scenario', () => {
7
+ it('parses releases.txt and extracts valid project paths ignoring comments and empty lines', async () => {
8
+ const rawContent = `# Global Registry
9
+ .
10
+ # packages
11
+ packages/release
12
+
13
+ # apps
14
+ apps/release.app
15
+ apps/3rdparty/industrialbank/bank
16
+ `
17
+ const db = new DB({
18
+ predefined: [
19
+ ['releases.txt', rawContent],
20
+ ['package.json', { name: '@nan0web/monorepo', version: '1.0.0' }],
21
+ ['packages/release/package.json', { name: '@nan0web/release', version: '3.3.0' }],
22
+ ['apps/release.app/package.json', { name: 'release.app', version: '0.1.0' }],
23
+ ['apps/3rdparty/industrialbank/bank/package.json', { name: '@industrialbank/bank', version: '3.3.0' }],
24
+ ],
25
+ })
26
+ await db.connect()
27
+
28
+ const registry = new RegistryModel({ filePath: 'releases.txt' }, { db })
29
+ const projects = await registry.loadProjects()
30
+
31
+ assert.equal(projects.length, 4)
32
+ assert.deepEqual(projects[0], {
33
+ path: '.',
34
+ name: '@nan0web/monorepo',
35
+ version: '1.0.0',
36
+ })
37
+ assert.deepEqual(projects[1], {
38
+ path: 'packages/release',
39
+ name: '@nan0web/release',
40
+ version: '3.3.0',
41
+ })
42
+ assert.deepEqual(projects[2], {
43
+ path: 'apps/release.app',
44
+ name: 'release.app',
45
+ version: '0.1.0',
46
+ })
47
+ assert.deepEqual(projects[3], {
48
+ path: 'apps/3rdparty/industrialbank/bank',
49
+ name: '@industrialbank/bank',
50
+ version: '3.3.0',
51
+ })
52
+ })
53
+
54
+ it('handles projects without package.json gracefully', async () => {
55
+ const db = new DB({
56
+ predefined: [
57
+ ['releases.txt', 'packages/custom-pkg\n'],
58
+ ],
59
+ })
60
+ await db.connect()
61
+
62
+ const registry = new RegistryModel({ filePath: 'releases.txt' }, { db })
63
+ const projects = await registry.loadProjects()
64
+
65
+ assert.equal(projects.length, 1)
66
+ assert.equal(projects[0].name, 'packages/custom-pkg')
67
+ assert.equal(projects[0].version, '0.0.0')
68
+ })
69
+
70
+ it('parses raw text paths via static parsePaths utility', () => {
71
+ const text = `
72
+ # Comment 1
73
+ /path/one
74
+
75
+ /path/two # inline comment
76
+ # Comment 2
77
+ /path/three
78
+ `
79
+ const paths = RegistryModel.parsePaths(text)
80
+ assert.deepEqual(paths, ['/path/one', '/path/two', '/path/three'])
81
+ })
82
+ })