@nan0web/release 3.3.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.
- package/bin/nan0release.js +33 -2
- package/bin/serve.js +73 -0
- package/package.json +45 -17
- package/src/Release/Document.js +1 -0
- package/src/Release/Scanner.js +3 -2
- package/src/Release.js +7 -7
- package/src/domain/App.js +31 -2
- package/src/domain/BumpCommand.js +10 -3
- package/src/domain/CheckCommand.js +504 -146
- package/src/domain/CheckCommand.story.js +92 -0
- package/src/domain/CloseCommand.js +13 -9
- package/src/domain/Dashboard/Dashboard.story.js +155 -0
- package/src/domain/Dashboard/DashboardModel.js +308 -0
- package/src/domain/Dashboard/ReleaseDiscovery.js +132 -0
- package/src/domain/Dashboard/ReleaseParser.js +226 -0
- package/src/domain/Dashboard/index.js +19 -0
- package/src/domain/DepsCommand.js +5 -2
- package/src/domain/InitCommand.js +18 -16
- package/src/domain/ListCommand.js +45 -0
- package/src/domain/PreviewCommand.js +39 -0
- package/src/domain/PublishCommand.js +7 -3
- package/src/domain/Registry/Registry.story.js +82 -0
- package/src/domain/Registry/RegistryModel.js +124 -0
- package/src/domain/ServeCommand.js +165 -0
- package/src/domain/ServeCommand.story.js +88 -0
- package/src/domain/SpecCommand.js +3 -1
- package/src/domain/StatusCommand.js +249 -3
- package/src/domain/ViewCommand.js +99 -0
- package/src/domain/WebCommand.js +140 -0
- package/src/domain/inspect/ReleaseAuditor.js +508 -0
- package/src/domain/inspect/ReleaseAuditor.story.js +136 -0
- package/src/domain/templates/release.html +34 -0
- package/src/domain/templates/release.html.template.js +34 -0
- package/src/index.js +6 -0
- package/src/inspectors/inspect-js.js +32 -0
- package/src/releases/1/0/v1.0.3/task.test.js +1 -1
- package/src/utils/nodejs/GitTelemetry.js +154 -0
- package/types/Release/Document.d.ts +24 -27
- package/types/Release/MarkdownToTest.d.ts +1 -1
- package/types/Release/Person.d.ts +9 -7
- package/types/Release/Scanner.d.ts +41 -41
- package/types/Release.d.ts +42 -42
- package/types/architecture/ProjectManagementAsCode.d.ts +59 -64
- package/types/domain/App.d.ts +24 -7
- package/types/domain/BumpCommand.d.ts +11 -6
- package/types/domain/CheckCommand.d.ts +128 -16
- package/types/domain/CheckCommand.story.d.ts +1 -0
- package/types/domain/CloseCommand.d.ts +4 -6
- package/types/domain/Dashboard/Dashboard.story.d.ts +1 -0
- package/types/domain/Dashboard/DashboardModel.d.ts +273 -0
- package/types/domain/Dashboard/GitTelemetry.d.ts +23 -0
- package/types/domain/Dashboard/ReleaseDiscovery.d.ts +47 -0
- package/types/domain/Dashboard/ReleaseParser.d.ts +79 -0
- package/types/domain/Dashboard/index.d.ts +3 -0
- package/types/domain/DepsCommand.d.ts +6 -6
- package/types/domain/InitCommand.d.ts +4 -6
- package/types/domain/ListCommand.d.ts +18 -0
- package/types/domain/PreviewCommand.d.ts +6 -0
- package/types/domain/PublishCommand.d.ts +8 -6
- package/types/domain/Registry/Registry.story.d.ts +1 -0
- package/types/domain/Registry/RegistryModel.d.ts +61 -0
- package/types/domain/ServeCommand.d.ts +59 -0
- package/types/domain/ServeCommand.story.d.ts +1 -0
- package/types/domain/SpecCommand.d.ts +4 -6
- package/types/domain/StatusCommand.d.ts +38 -3
- package/types/domain/ViewCommand.d.ts +27 -0
- package/types/domain/WebCommand.d.ts +47 -0
- package/types/domain/inspect/ReleaseAuditor.d.ts +134 -0
- package/types/domain/inspect/ReleaseAuditor.story.d.ts +1 -0
- package/types/domain/templates/release.html.template.d.ts +2 -0
- package/types/index.d.ts +18 -10
- package/types/inspectors/inspect-js.d.ts +2 -0
- 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
|
-
* @
|
|
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
|
}
|