@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,124 @@
1
+ import { Model } from '@nan0web/types'
2
+
3
+ /**
4
+ * @typedef {Object} ProjectEntry
5
+ * @property {string} path - Project relative or absolute path
6
+ * @property {string} name - Package name from package.json or folder basename
7
+ * @property {string} version - Package version from package.json
8
+ */
9
+
10
+ /**
11
+ * RegistryModel - PM-as-Code Project Registry manager.
12
+ * Reads project paths from releases.txt and discovers package metadata.
13
+ */
14
+ export class RegistryModel extends Model {
15
+ static filePath = {
16
+ help: 'Path to releases.txt registry file',
17
+ default: 'releases.txt',
18
+ type: 'string',
19
+ }
20
+
21
+ static projects = {
22
+ help: 'List of registered projects',
23
+ default: () => [],
24
+ type: 'array',
25
+ }
26
+
27
+ static UI = {
28
+ fileNotFound: 'Registry file {$path} not found',
29
+ loadingProjects: 'Loading projects from registry {$path}...',
30
+ projectsLoaded: 'Loaded {$count} projects from registry',
31
+ }
32
+
33
+ /**
34
+ * @param {Partial<RegistryModel>} [data]
35
+ * @param {import('@nan0web/types').ModelOptions} [options]
36
+ */
37
+ constructor(data = {}, options = {}) {
38
+ super(data, options)
39
+ /** @type {string} Path to registry file */ this.filePath
40
+ /** @type {ProjectEntry[]} List of registered projects */ this.projects
41
+ }
42
+
43
+ /**
44
+ * Parse raw content of releases.txt into array of clean paths
45
+ * @param {string} content
46
+ * @returns {string[]}
47
+ */
48
+ static parsePaths(content = '') {
49
+ if (typeof content !== 'string') return []
50
+ return content
51
+ .split('\n')
52
+ .map((line) => {
53
+ // Strip comments starting with #
54
+ const withoutComment = line.split('#')[0]
55
+ return withoutComment.trim()
56
+ })
57
+ .filter((line) => line.length > 0)
58
+ }
59
+
60
+ /**
61
+ * Load project entries from registry file using injected DB instance
62
+ * @returns {Promise<ProjectEntry[]>}
63
+ */
64
+ async loadProjects() {
65
+ const db = this._.db
66
+ if (!db) {
67
+ throw new Error('Database instance is required in options ({ db }) to load projects')
68
+ }
69
+
70
+ let content = await db.loadDocument(this.filePath, '')
71
+ let rootPrefix = ''
72
+ if (!content && this.filePath === 'releases.txt') {
73
+ try {
74
+ const parent2 = await db.loadDocument('../../releases.txt', '')
75
+ if (parent2) {
76
+ content = parent2
77
+ rootPrefix = '../../'
78
+ } else {
79
+ const parent1 = await db.loadDocument('../releases.txt', '')
80
+ if (parent1) {
81
+ content = parent1
82
+ rootPrefix = '../'
83
+ }
84
+ }
85
+ } catch {
86
+ // ignore
87
+ }
88
+ }
89
+
90
+ const paths = RegistryModel.parsePaths(String(content || ''))
91
+
92
+ /** @type {ProjectEntry[]} */
93
+ const result = []
94
+
95
+ for (const projectPath of paths) {
96
+ const effectivePath = rootPrefix
97
+ ? projectPath === '.'
98
+ ? rootPrefix.slice(0, -1)
99
+ : `${rootPrefix}${projectPath}`
100
+ : projectPath
101
+ const pkgPath = effectivePath === '.' ? 'package.json' : `${effectivePath.replace(/\/+$/, '')}/package.json`
102
+ let pkg = null
103
+ try {
104
+ pkg = await db.loadDocument(pkgPath, null)
105
+ } catch {
106
+ pkg = null
107
+ }
108
+
109
+ const name = pkg?.name || projectPath
110
+ const version = pkg?.version || '0.0.0'
111
+
112
+ result.push({
113
+ path: projectPath,
114
+ name,
115
+ version,
116
+ })
117
+ }
118
+
119
+ this.projects = result
120
+ return result
121
+ }
122
+ }
123
+
124
+ export default RegistryModel
@@ -0,0 +1,165 @@
1
+ import { ModelAsApp, result, show } from '@nan0web/ui'
2
+ import Markdown from '@nan0web/markdown'
3
+ import DEFAULT_TEMPLATE from './templates/release.html.template.js'
4
+
5
+ /**
6
+ * ServeCommand - Universal ModelAsApp for data or release catalogs.
7
+ *
8
+ * Generates HTML content from README.md and release templates using $db and Markdown.
9
+ * Pure OLMUI model: does not start HTTP server, does not call node:fs or child_process.
10
+ *
11
+ * Usage:
12
+ * pnpm exec serve releases/ # Runs via CLI bootstrap
13
+ */
14
+ export default class ServeCommand extends ModelAsApp {
15
+ static alias = 'serve'
16
+
17
+ static UI = {
18
+ title: 'serve',
19
+ help: 'Serve data or releases catalog over HTTP',
20
+ serving: '🌐 Serving {$dir} on http://localhost:{$port} (Press Ctrl+C to stop)',
21
+ dirNotFound: 'Directory {$dir} does not exist.',
22
+ }
23
+
24
+ static dir = {
25
+ help: 'Directory to serve (e.g. releases/ or data/)',
26
+ positional: true,
27
+ type: 'string',
28
+ default: 'releases',
29
+ }
30
+
31
+ static port = {
32
+ help: 'HTTP server port',
33
+ default: 3131,
34
+ type: 'number',
35
+ alias: 'p',
36
+ }
37
+
38
+ static open = {
39
+ help: 'Automatically open in browser',
40
+ default: true,
41
+ type: 'boolean',
42
+ }
43
+
44
+ /**
45
+ * @param {Partial<ServeCommand>} [data]
46
+ * @param {import('@nan0web/types').ModelOptions} [options]
47
+ */
48
+ constructor(data = {}, options = {}) {
49
+ super(data, options)
50
+ /** @type {string} */
51
+ this.dir = String(data.dir || (options && 'dir' in options ? /** @type {any} */ (options).dir : 'releases'))
52
+ /** @type {number} */
53
+ this.port = Number(data.port ?? (options && 'port' in options ? /** @type {any} */ (options).port : 3131))
54
+ /** @type {boolean} */
55
+ this.open = Boolean(data.open ?? (options && 'open' in options ? /** @type {any} */ (options).open : true))
56
+ }
57
+
58
+ /**
59
+ * Convert markdown string to HTML using @nan0web/markdown
60
+ * @param {string} mdRaw
61
+ * @returns {string}
62
+ */
63
+ renderMarkdownToHtml(mdRaw) {
64
+ if (!mdRaw || typeof mdRaw !== 'string') return ''
65
+ const md = new Markdown(mdRaw)
66
+ return md.stringify()
67
+ }
68
+
69
+ /**
70
+ * Render HTML content for the target directory
71
+ * @returns {Promise<string>}
72
+ */
73
+ async renderHtml() {
74
+ const db = this.$db
75
+ let readmeContent = ''
76
+ const readmePath = db.resolveSync ? db.resolveSync(this.dir, 'README.md') : `${this.dir}/README.md`
77
+
78
+ try {
79
+ const mdRaw = await db.loadDocument(readmePath, null)
80
+ if (mdRaw) {
81
+ readmeContent = this.renderMarkdownToHtml(typeof mdRaw === 'string' ? mdRaw : mdRaw.content || String(mdRaw))
82
+ }
83
+ } catch {}
84
+
85
+ if (!readmeContent) {
86
+ // If no README.md in directory, scan directory entries and list them nicely
87
+ let listItems = []
88
+ try {
89
+ if (typeof db.listDir === 'function') {
90
+ const entries = await db.listDir(this.dir)
91
+ if (entries && Array.isArray(entries)) {
92
+ listItems = entries
93
+ .map((e) => e?.name || e?.path || String(e))
94
+ .filter((name) => !name.startsWith('.'))
95
+ }
96
+ }
97
+ } catch {}
98
+
99
+ const title = this.dir === 'releases' ? 'Releases Catalog' : `Index of ${this.dir}`
100
+ let md = `# ${title}\n\n`
101
+ if (listItems.length > 0) {
102
+ md += `Found **${listItems.length}** item(s) in \`${this.dir}\`:\n\n`
103
+ for (const item of listItems) {
104
+ md += `- **\`${item}\`**\n`
105
+ }
106
+ } else {
107
+ md += `Directory \`${this.dir}\` is currently empty or contains no catalog entries.\n`
108
+ }
109
+ readmeContent = this.renderMarkdownToHtml(md)
110
+ }
111
+
112
+ // Load template via DB or fallback to bundled template
113
+ let templateRaw = null
114
+ try {
115
+ templateRaw = await db.loadDocument('packages/release/src/domain/templates/release.html', null)
116
+ } catch {}
117
+
118
+ if (!templateRaw) {
119
+ templateRaw = DEFAULT_TEMPLATE
120
+ }
121
+
122
+ return String(templateRaw).replace('{{CONTENT}}', readmeContent)
123
+ }
124
+
125
+ async *run() {
126
+ const db = this.$db
127
+
128
+ // Check if directory exists via db.listDir or db.stat
129
+ let dirExists = false
130
+ try {
131
+ if (typeof db.listDir === 'function') {
132
+ const entries = await db.listDir(this.dir)
133
+ dirExists = entries && entries.length > 0
134
+ }
135
+ if (!dirExists && typeof db.stat === 'function') {
136
+ const stat = await db.stat(this.dir)
137
+ dirExists = Boolean(stat && (stat.exists || stat.isDirectory || stat.isFile))
138
+ }
139
+ } catch {
140
+ dirExists = false
141
+ }
142
+
143
+ if (!dirExists) {
144
+ yield show(ServeCommand.UI.dirNotFound.replace('{$dir}', this.dir), 'error')
145
+ return result({ success: false })
146
+ }
147
+
148
+ const html = await this.renderHtml()
149
+
150
+ yield show(
151
+ ServeCommand.UI.serving.replace('{$dir}', this.dir).replace('{$port}', String(this.port)),
152
+ 'success'
153
+ )
154
+
155
+ return result({
156
+ success: true,
157
+ dir: this.dir,
158
+ port: this.port,
159
+ open: this.open,
160
+ html,
161
+ })
162
+ }
163
+ }
164
+
165
+ export { ServeCommand }
@@ -0,0 +1,88 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import DB from '@nan0web/db'
4
+ import ServeCommand from './ServeCommand.js'
5
+
6
+ describe('ServeCommand OLMUI Scenario & Model Test', () => {
7
+ it('generates HTML from releases/README.md when directory exists', async () => {
8
+ const readmeMd = `# Releases
9
+
10
+ This is the main catalog of releases.
11
+
12
+ ## Active Projects
13
+ - @nan0web/ui
14
+ - @nan0web/db
15
+ `
16
+ const templateHtml = `<!DOCTYPE html><html><body><main>{{CONTENT}}</main></body></html>`
17
+
18
+ const db = new DB({
19
+ predefined: [
20
+ ['releases/README.md', readmeMd],
21
+ ['packages/release/src/domain/templates/release.html', templateHtml],
22
+ ],
23
+ })
24
+ await db.connect()
25
+
26
+ const cmd = new ServeCommand({ dir: 'releases', port: 3131 }, { db })
27
+ const gen = cmd.run()
28
+
29
+ let step = await gen.next()
30
+ const outputs = []
31
+ while (!step.done) {
32
+ outputs.push(step.value)
33
+ step = await gen.next()
34
+ }
35
+
36
+ const res = step.value?.data || step.value
37
+ assert.ok(res.success !== false, 'Execution should succeed')
38
+ assert.equal(res.dir, 'releases')
39
+ assert.equal(res.port, 3131)
40
+ assert.ok(typeof res.html === 'string', 'Should return html string')
41
+ assert.ok(res.html.includes('Releases'), 'HTML should contain rendered markdown title')
42
+ assert.ok(res.html.includes('main'), 'HTML should use the template')
43
+ })
44
+
45
+ it('yields error when target directory does not exist', async () => {
46
+ const db = new DB({
47
+ predefined: [],
48
+ })
49
+ await db.connect()
50
+
51
+ const cmd = new ServeCommand({ dir: 'nonexistent-dir' }, { db })
52
+ const gen = cmd.run()
53
+
54
+ let step = await gen.next()
55
+ const outputs = []
56
+ while (!step.done) {
57
+ outputs.push(step.value)
58
+ step = await gen.next()
59
+ }
60
+
61
+ const res = step.value?.data || step.value
62
+ assert.equal(res.success, false)
63
+ assert.ok(outputs.some((o) => o?.type === 'show' || o?.tag === 'show' || String(o).includes('Directory') || o?.content?.includes('nonexistent-dir')))
64
+ })
65
+
66
+ it('handles missing README.md with fallback content', async () => {
67
+ const templateHtml = `<!DOCTYPE html><html><body>{{CONTENT}}</body></html>`
68
+ const db = new DB({
69
+ predefined: [
70
+ ['releases/.gitkeep', ''],
71
+ ['packages/release/src/domain/templates/release.html', templateHtml],
72
+ ],
73
+ })
74
+ await db.connect()
75
+
76
+ const cmd = new ServeCommand({ dir: 'releases' }, { db })
77
+ const gen = cmd.run()
78
+
79
+ let step = await gen.next()
80
+ while (!step.done) {
81
+ step = await gen.next()
82
+ }
83
+
84
+ const res = step.value?.data || step.value
85
+ assert.ok(res.success !== false)
86
+ assert.ok(res.html.includes('Releases'))
87
+ })
88
+ })
@@ -3,6 +3,9 @@ import { runSpawn } from '@nan0web/test'
3
3
  import Scanner from '../Release/Scanner.js'
4
4
 
5
5
  export default class SpecCommand extends ModelAsApp {
6
+ /** @type {string} Release version filter */
7
+ version = ''
8
+
6
9
  static version = {
7
10
  help: 'Release version filter',
8
11
  default: '',
@@ -18,7 +21,6 @@ export default class SpecCommand extends ModelAsApp {
18
21
  */
19
22
  constructor(data = {}, options = {}) {
20
23
  super(data, options)
21
- /** @type {string} Release version filter */ this.version
22
24
  }
23
25
 
24
26
  /**
@@ -1,16 +1,262 @@
1
1
  import { ModelAsApp, result, show } from '@nan0web/ui'
2
+ import { DashboardModel } from './Dashboard/DashboardModel.js'
2
3
  import Scanner from '../Release/Scanner.js'
3
4
 
5
+ /**
6
+ * StatusCommand - ModelAsApp Subcommand to display PM-as-Code dashboard and project inspector.
7
+ */
4
8
  export default class StatusCommand extends ModelAsApp {
9
+ static alias = 'status'
10
+
5
11
  static UI = {
6
12
  title: 'status',
7
- help: 'Show release status',
13
+ help: 'Show release status dashboard or project inspector',
14
+ header: '📊 Global PM-as-Code Release Dashboard ({$file})',
15
+ summaryHeader: '📈 Summary:',
16
+ summaryRow1: ' Total Projects: {$total} | Active (WIP): {$wip} | Closed: {$closed}',
17
+ summaryRow2: ' Total Hours: {$hours}h | Total Iterations: {$iterations}',
18
+ noProjects: 'No projects registered in {$file}',
19
+ }
20
+
21
+ static registryFile = {
22
+ help: 'Path to releases.txt registry file',
23
+ default: 'releases.txt',
24
+ type: 'string',
25
+ alias: 'f',
26
+ }
27
+
28
+ static target = {
29
+ help: 'Target project # or name for detailed inspection',
30
+ default: '',
31
+ positional: true,
32
+ type: 'string',
33
+ }
34
+
35
+ static sort = {
36
+ help: 'Sort projects by column (hours, rrs, iters, state, name)',
37
+ default: '',
38
+ type: 'string',
39
+ alias: 's',
8
40
  }
9
41
 
10
42
  /**
11
- * @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
43
+ * @param {Partial<StatusCommand>} [data]
44
+ * @param {import('@nan0web/types').ModelOptions} [options]
45
+ */
46
+ constructor(data = {}, options = {}) {
47
+ super(data, options)
48
+ /** @type {string} */ this.registryFile
49
+ /** @type {string} */ this.target
50
+ /** @type {string} */ this.sort
51
+ }
52
+
53
+ /**
54
+ * @returns {AsyncGenerator<import('@nan0web/ui').Intent, any, any>}
12
55
  */
13
56
  async *run() {
57
+ const db = this._.db
58
+ if (!db) {
59
+ throw new Error('Database instance is required in options ({ db })')
60
+ }
61
+
62
+ let hasRegistry = false
63
+ try {
64
+ const regDoc = await db.loadDocument(this.registryFile || 'releases.txt', null)
65
+ hasRegistry = regDoc !== null
66
+ } catch {}
67
+
68
+ if (hasRegistry || this.target || this.sort) {
69
+ let gitTelemetry = null
70
+ try {
71
+ gitTelemetry = await import('../utils/nodejs/GitTelemetry.js')
72
+ } catch {}
73
+ const dashboard = new DashboardModel(
74
+ { registryFile: this.registryFile || 'releases.txt' },
75
+ { db, gitTelemetry }
76
+ )
77
+ let { summary, projects } = await dashboard.aggregate()
78
+
79
+ // 1. Sorting if requested
80
+ if (this.sort) {
81
+ const s = String(this.sort).toLowerCase()
82
+ if (s === 'hours') {
83
+ projects = [...projects].sort((a, b) => (b.hours || 0) - (a.hours || 0))
84
+ } else if (s === 'rrs') {
85
+ projects = [...projects].sort((a, b) => (b.rrs || 0) - (a.rrs || 0))
86
+ } else if (s === 'iters' || s === 'iterations') {
87
+ projects = [...projects].sort((a, b) => (b.iterations || 0) - (a.iterations || 0))
88
+ } else if (s === 'name') {
89
+ projects = [...projects].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
90
+ } else if (s === 'state' || s === 'status') {
91
+ const priority = { wip: 1, closed: 2, unknown: 3 }
92
+ projects = [...projects].sort((a, b) => (priority[a.status] || 99) - (priority[b.status] || 99))
93
+ }
94
+ }
95
+
96
+ // 2. Inspector mode if target is specified
97
+ if (this.target) {
98
+ const targetStr = String(this.target).trim()
99
+ let matchedIndex = -1
100
+ let matchedProj = null
101
+
102
+ const targetNum = parseInt(targetStr, 10)
103
+ if (!isNaN(targetNum) && targetNum >= 1 && targetNum <= projects.length) {
104
+ matchedIndex = targetNum - 1
105
+ matchedProj = projects[matchedIndex]
106
+ } else {
107
+ matchedIndex = projects.findIndex(
108
+ (p) =>
109
+ p.name.toLowerCase() === targetStr.toLowerCase() ||
110
+ p.path.toLowerCase() === targetStr.toLowerCase() ||
111
+ p.name.toLowerCase().includes(targetStr.toLowerCase()) ||
112
+ p.path.toLowerCase().includes(targetStr.toLowerCase()),
113
+ )
114
+ if (matchedIndex !== -1) {
115
+ matchedProj = projects[matchedIndex]
116
+ }
117
+ }
118
+
119
+ if (matchedProj) {
120
+ yield show(`🔍 Project Inspector: #${matchedIndex + 1} ${matchedProj.name}@${matchedProj.version}`, 'info')
121
+ yield show(
122
+ ` • Path: ${matchedProj.path} | Branch: ${matchedProj.branch || 'main'} (${matchedProj.gitStatus || 'clean'})`,
123
+ 'info',
124
+ )
125
+ yield show(
126
+ ` • State: [${(matchedProj.status || 'unknown').toUpperCase()}] | Hours: ${matchedProj.hours}h | Iterations: ${matchedProj.iterations} | RRS: ${matchedProj.rrs}`,
127
+ 'info',
128
+ )
129
+
130
+ yield show(`📋 Tasks & Checklist:`, 'info')
131
+ let renderedTasksCount = 0
132
+ if (matchedProj.releaseContent) {
133
+ const lines = matchedProj.releaseContent.split('\n')
134
+ for (const rawLine of lines) {
135
+ const l = rawLine.trim()
136
+ if (l.startsWith('- [x]') || l.startsWith('- [ ]') || l.startsWith('* [x]') || l.startsWith('* [ ]')) {
137
+ yield show(` ${l}`, 'info')
138
+ renderedTasksCount++
139
+ } else if (
140
+ l.startsWith('### Done') ||
141
+ l.startsWith('### InProgress') ||
142
+ l.startsWith('### Todo') ||
143
+ l.startsWith('### Draft')
144
+ ) {
145
+ const icon = l.startsWith('### Done') ? '[x]' : '[ ]'
146
+ const desc = l.replace(/^###\s+(?:Done|InProgress|Todo|Draft)\s+/, '')
147
+ yield show(` - ${icon} ${desc}`, 'info')
148
+ renderedTasksCount++
149
+ }
150
+ }
151
+ }
152
+
153
+ if (renderedTasksCount === 0 && Array.isArray(matchedProj.tasks) && matchedProj.tasks.length > 0) {
154
+ for (const t of matchedProj.tasks) {
155
+ const icon = t.status === 'Done' ? '[x]' : '[ ]'
156
+ yield show(` - ${icon} ${t.content || t.slug}`, 'info')
157
+ }
158
+ }
159
+
160
+ if (matchedProj.userContent) {
161
+ yield show(`📝 User Metrics & Feedback (user.md):`, 'info')
162
+ const userLines = matchedProj.userContent
163
+ .split('\n')
164
+ .map((l) => l.trim())
165
+ .filter(
166
+ (l) =>
167
+ l.length > 0 &&
168
+ !l.startsWith('---') &&
169
+ !l.startsWith('name:') &&
170
+ !l.startsWith('version:') &&
171
+ !l.startsWith('locale:'),
172
+ )
173
+ for (const ul of userLines) {
174
+ yield show(` ${ul}`, 'info')
175
+ }
176
+ }
177
+ } else {
178
+ yield show(`❌ Project matching target "${this.target}" not found`, 'warn')
179
+ }
180
+
181
+ return result({
182
+ status: 'ok',
183
+ summary,
184
+ projects,
185
+ })
186
+ }
187
+
188
+ // 3. Normal Table mode
189
+ yield show(StatusCommand.UI.header.replace('{$file}', this.registryFile || 'releases.txt'), 'info')
190
+
191
+ if (projects.length === 0) {
192
+ yield show(StatusCommand.UI.noProjects.replace('{$file}', this.registryFile || 'releases.txt'), 'warn')
193
+ } else {
194
+ const colNum = '#'.padEnd(3)
195
+ const colProj = 'Project'.padEnd(25)
196
+ const colVer = 'Version'.padEnd(9)
197
+ const colBranch = 'Branch'.padEnd(12)
198
+ const colGit = 'Git'.padEnd(7)
199
+ const colState = 'State'.padEnd(10)
200
+ const colHours = 'Hours'.padEnd(7)
201
+ const colIters = 'Iters'.padEnd(7)
202
+ const colRRS = 'RRS'.padEnd(5)
203
+ const colTasks = 'Tasks'
204
+
205
+ const tableHeader = `┌─────┬───────────────────────────┬───────────┬──────────────┬─────────┬────────────┬─────────┬─────────┬───────┬───────┐`
206
+ const headerRow = `│ ${colNum} │ ${colProj} │ ${colVer} │ ${colBranch} │ ${colGit} │ ${colState} │ ${colHours} │ ${colIters} │ ${colRRS} │ ${colTasks.padEnd(5)} │`
207
+ const separator = `├─────┼───────────────────────────┼───────────┼──────────────┼─────────┼────────────┼─────────┼─────────┼───────┼───────┤`
208
+ const tableFooter = `└─────┴───────────────────────────┴───────────┴──────────────┴─────────┴────────────┴─────────┴─────────┴───────┴───────┘`
209
+
210
+ yield show(tableHeader, 'info')
211
+ yield show(headerRow, 'info')
212
+ yield show(separator, 'info')
213
+
214
+ for (let i = 0; i < projects.length; i++) {
215
+ const proj = projects[i]
216
+ const statusTag = proj.status === 'wip' ? '[WIP]' : proj.status === 'closed' ? '[CLOSED]' : '[UNKNOWN]'
217
+ const rowNum = String(i + 1).padEnd(3)
218
+ const rowName = (proj.name || '').padEnd(25).slice(0, 25)
219
+ const rowVer = (proj.version || '').padEnd(9).slice(0, 9)
220
+ const rowBranch = (proj.branch || 'main').padEnd(12).slice(0, 12)
221
+ const rowGit = (proj.gitStatus || 'clean').padEnd(7).slice(0, 7)
222
+ const rowState = statusTag.padEnd(10)
223
+ const rowHours = `${proj.hours}h`.padEnd(7)
224
+ const rowIters = String(proj.iterations).padEnd(7)
225
+ const rowRRS = String(proj.rrs).padEnd(5)
226
+ const doneTasks = Array.isArray(proj.tasks) ? proj.tasks.filter((t) => t.status === 'Done').length : 0
227
+ const totalTasks = Array.isArray(proj.tasks) ? proj.tasks.length : 0
228
+ const rowTasks = `${doneTasks}/${totalTasks}`.padEnd(5)
229
+
230
+ const rowStr = `│ ${rowNum} │ ${rowName} │ ${rowVer} │ ${rowBranch} │ ${rowGit} │ ${rowState} │ ${rowHours} │ ${rowIters} │ ${rowRRS} │ ${rowTasks} │`
231
+ yield show(rowStr, proj.status === 'wip' ? 'warn' : 'info')
232
+ }
233
+
234
+ yield show(tableFooter, 'info')
235
+
236
+ yield show(StatusCommand.UI.summaryHeader, 'info')
237
+ yield show(
238
+ StatusCommand.UI.summaryRow1
239
+ .replace('{$total}', String(summary.totalProjects))
240
+ .replace('{$wip}', String(summary.wipCount))
241
+ .replace('{$closed}', String(summary.closedCount)),
242
+ 'info',
243
+ )
244
+ yield show(
245
+ StatusCommand.UI.summaryRow2
246
+ .replace('{$hours}', String(summary.totalHours))
247
+ .replace('{$iterations}', String(summary.totalIterations)),
248
+ 'info',
249
+ )
250
+ }
251
+
252
+ return result({
253
+ status: 'ok',
254
+ summary,
255
+ projects,
256
+ })
257
+ }
258
+
259
+ // Local package fallback
14
260
  const scanner = new Scanner(process.cwd())
15
261
  const statuses = scanner.status()
16
262
 
@@ -20,6 +266,6 @@ export default class StatusCommand extends ModelAsApp {
20
266
  `${st.version.padEnd(10)} | ${st.state.padEnd(6)} | Specs: ${st.specs.length}, Tests: ${st.tests.length}`,
21
267
  )
22
268
  }
23
- return result({})
269
+ return result({ statuses })
24
270
  }
25
271
  }