@nan0web/ui 1.12.1 → 1.12.3
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/README.md +18 -345
- package/package.json +13 -8
- package/src/Model/index.js +2 -2
- package/src/core/GeneratorRunner.js +8 -0
- package/src/core/resolvePositionalArgs.js +51 -0
- package/src/domain/Content.js +5 -5
- package/src/domain/Document.js +1 -1
- package/src/domain/HeroModel.js +1 -1
- package/src/domain/ModelAsApp.js +310 -20
- package/src/domain/ModelAsApp.story.js +117 -0
- package/src/domain/app/GalleryCommand.js +9 -8
- package/src/domain/app/{GalleryRenderIntent.js → GalleryRenderCommand.js} +20 -20
- package/src/domain/app/SnapshotAuditor.js +81 -85
- package/src/domain/app/SnapshotRunner.js +1 -1
- package/src/domain/app/UIApp.js +12 -21
- package/src/index.js +4 -2
- package/src/inspect.js +1 -0
- package/src/testing/SnapshotRunner.js +2 -1
- package/src/testing/SpecRunner.js +37 -0
- package/types/Model/index.d.ts +2 -2
- package/types/core/resolvePositionalArgs.d.ts +24 -0
- package/types/docs/README.md.d.ts +1 -0
- package/types/domain/Content.d.ts +2 -2
- package/types/domain/Document.d.ts +2 -2
- package/types/domain/HeroModel.d.ts +2 -2
- package/types/domain/ModelAsApp.d.ts +49 -5
- package/types/domain/ModelAsApp.story.d.ts +1 -0
- package/types/domain/app/GalleryCommand.d.ts +6 -37
- package/types/domain/app/GalleryRenderCommand.d.ts +27 -0
- package/types/domain/app/SnapshotAuditor.d.ts +33 -23
- package/types/domain/app/SnapshotRunner.d.ts +2 -2
- package/types/domain/app/UIApp.d.ts +14 -11
- package/types/index.d.ts +4 -2
- package/types/inspect.d.ts +1 -0
- package/types/testing/SpecRunner.d.ts +22 -0
- package/types/testing/verifySnapshot.d.ts +1 -1
- package/types/domain/app/GalleryRenderIntent.d.ts +0 -31
package/src/domain/ModelAsApp.js
CHANGED
|
@@ -1,46 +1,336 @@
|
|
|
1
|
-
import { Model } from '@nan0web/types'
|
|
2
|
-
import { result } from '../core/Intent.js'
|
|
1
|
+
import { Model, getMetadata } from '@nan0web/types'
|
|
2
|
+
import { result, ask, show } from '../core/Intent.js'
|
|
3
3
|
import { InputAdapter } from '../core/InputAdapter.js'
|
|
4
|
+
import { resolvePositionalArgs } from '../core/resolvePositionalArgs.js'
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} AppOptions
|
|
8
|
+
* @property {InputAdapter} adapter
|
|
9
|
+
* @property {string} parentPath
|
|
10
|
+
* @property {boolean} _isExplicit
|
|
11
|
+
*/
|
|
12
|
+
/** @typedef {import('@nan0web/types').ModelOptions & AppOptions} ModelAsAppOptions */
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* The model with a run generator.
|
|
16
|
+
* @property {boolean} help Show help
|
|
9
17
|
*/
|
|
10
18
|
export class ModelAsApp extends Model {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
plugins: {},
|
|
15
|
-
adapter: null,
|
|
19
|
+
static help = {
|
|
20
|
+
help: 'Show help',
|
|
21
|
+
default: false,
|
|
16
22
|
}
|
|
23
|
+
|
|
17
24
|
/**
|
|
18
25
|
* @param {Partial<ModelAsApp> | Record<string, any>} [data={}]
|
|
19
26
|
* @param {Partial<ModelAsAppOptions>} [options={}]
|
|
20
27
|
*/
|
|
21
28
|
constructor(data = {}, options = {}) {
|
|
22
29
|
super(data, options)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
options.t ||
|
|
27
|
-
((key, props = {}) =>
|
|
28
|
-
String(key)
|
|
29
|
-
.replace(/{(\w+)}/g, (_, x) => props[x] ?? `{${x}}`)
|
|
30
|
-
.replace(/_/g, ' ')),
|
|
31
|
-
plugins: options.plugins || {},
|
|
30
|
+
/** @type {boolean} Show help */ this.help
|
|
31
|
+
this._ = {
|
|
32
|
+
...this._,
|
|
32
33
|
adapter: options.adapter || new InputAdapter(),
|
|
34
|
+
parentPath: String(options.parentPath || ''),
|
|
35
|
+
_isExplicit: Boolean(options._isExplicit),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Automated Sub-Command Instantiation ──
|
|
39
|
+
const metadata = getMetadata(this.constructor)
|
|
40
|
+
for (const [key, meta] of Object.entries(metadata)) {
|
|
41
|
+
if (meta && typeof meta === 'object' && Array.isArray(meta.options)) {
|
|
42
|
+
const val = /** @type {any} */ (this)[key]
|
|
43
|
+
if (val) {
|
|
44
|
+
/** @type {any} */ this[key] = this._instantiateSubCommand(key, val, data)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Instantiates a subcommand if the value matches one of the options.
|
|
52
|
+
* @param {string} key - Field name.
|
|
53
|
+
* @param {any} val - Current value (string, class, or instance).
|
|
54
|
+
* @param {any} [data={}] - Data to pass to the new instance.
|
|
55
|
+
* @returns {any} Instantiated subcommand or original value.
|
|
56
|
+
*/
|
|
57
|
+
_instantiateSubCommand(key, val, data = {}) {
|
|
58
|
+
if (val && typeof val === 'object' && typeof val.run === 'function') return val
|
|
59
|
+
|
|
60
|
+
const Class = /** @type {typeof ModelAsApp} */ (this.constructor)
|
|
61
|
+
const meta = /** @type {any} */ (Class)[key]
|
|
62
|
+
if (!meta || !Array.isArray(meta.options)) return val
|
|
63
|
+
|
|
64
|
+
let SubClass = null
|
|
65
|
+
let isExplicit = false
|
|
66
|
+
if (typeof val === 'function' && val.prototype && typeof val.prototype.run === 'function') {
|
|
67
|
+
SubClass = val
|
|
68
|
+
} else if (typeof val === 'string') {
|
|
69
|
+
SubClass = meta.options.find((C) => {
|
|
70
|
+
if (typeof C !== 'function') return false
|
|
71
|
+
const className = typeof C.name === 'string' ? C.name : ''
|
|
72
|
+
const alias =
|
|
73
|
+
(typeof C.alias === 'string' ? C.alias : null) ||
|
|
74
|
+
className.replace(/Command|App/g, '').toLowerCase()
|
|
75
|
+
return alias === val
|
|
76
|
+
})
|
|
77
|
+
isExplicit = !!SubClass
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (SubClass) {
|
|
81
|
+
const className = typeof Class.name === 'string' ? Class.name : ''
|
|
82
|
+
const myAlias =
|
|
83
|
+
(typeof /** @type {any} */ (Class).alias === 'string' ? /** @type {any} */ (Class).alias : null) ||
|
|
84
|
+
className.replace(/Command|App/g, '').toLowerCase()
|
|
85
|
+
const fullPath = this._.parentPath ? `${this._.parentPath} ${myAlias}` : myAlias
|
|
86
|
+
|
|
87
|
+
const finalData = resolvePositionalArgs(SubClass, data._positionals || [], data)
|
|
88
|
+
return new SubClass(finalData, { ...this._, parentPath: fullPath, _isExplicit: isExplicit })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return val
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Generate help text for the model
|
|
96
|
+
* @param {string} [parentPath]
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
generateHelp(parentPath = this._.parentPath) {
|
|
100
|
+
const Class = /** @type {typeof ModelAsApp} */ (this.constructor)
|
|
101
|
+
|
|
102
|
+
// Delegate help to sub-command ONLY if it was explicitly requested via arguments.
|
|
103
|
+
// This prevents "store --help" from showing "store list --help" documentation.
|
|
104
|
+
for (const key in this) {
|
|
105
|
+
const val = /** @type {any} */ (this)[key]
|
|
106
|
+
if (val instanceof ModelAsApp && val['help'] && val._._isExplicit) {
|
|
107
|
+
const className = typeof Class.name === 'string' ? Class.name : ''
|
|
108
|
+
const myAlias =
|
|
109
|
+
(typeof /** @type {any} */ (Class).alias === 'string' ? /** @type {any} */ (Class).alias : null) ||
|
|
110
|
+
className.replace(/Command|App/g, '').toLowerCase()
|
|
111
|
+
const fullPath = parentPath ? `${parentPath} ${myAlias}` : myAlias
|
|
112
|
+
return val.generateHelp(fullPath)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const className = typeof Class.name === 'string' ? Class.name : ''
|
|
117
|
+
const myAlias =
|
|
118
|
+
(typeof /** @type {any} */ (Class).alias === 'string' ? /** @type {any} */ (Class).alias : null) ||
|
|
119
|
+
className.replace(/Command|App/g, '').toLowerCase()
|
|
120
|
+
const fullPath = parentPath ? `${parentPath} ${myAlias}` : myAlias
|
|
121
|
+
|
|
122
|
+
const t = this._.t
|
|
123
|
+
const lines = []
|
|
124
|
+
|
|
125
|
+
/** @type {any} */
|
|
126
|
+
const UI =
|
|
127
|
+
typeof (/** @type {any} */ (Class).UI) === 'object' && /** @type {any} */ (Class).UI
|
|
128
|
+
? /** @type {any} */ (Class).UI
|
|
129
|
+
: {}
|
|
130
|
+
|
|
131
|
+
if (UI.title) {
|
|
132
|
+
lines.push(`# ${UI.icon ? UI.icon + ' ' : ''}${t(UI.title)}`.trim())
|
|
133
|
+
if (UI.description) lines.push(`${t(UI.description)}`)
|
|
134
|
+
lines.push('')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const posMeta = []
|
|
138
|
+
const posNames = []
|
|
139
|
+
for (const key in Class) {
|
|
140
|
+
const meta = /** @type {any} */ (Class)[key]
|
|
141
|
+
if (meta && typeof meta === 'object' && meta.help && meta.positional) {
|
|
142
|
+
posNames.push(meta.required ? `<${key}>` : `[${key}]`)
|
|
143
|
+
posMeta.push({ key, meta })
|
|
144
|
+
}
|
|
33
145
|
}
|
|
146
|
+
|
|
147
|
+
const usageTitle = UI.usageTitle ? t(UI.usageTitle) : 'Usage:'
|
|
148
|
+
lines.push(`## ${usageTitle}`)
|
|
149
|
+
lines.push('```bash')
|
|
150
|
+
const posStr = posNames.length > 0 ? ` ${posNames.join(' ')}` : ''
|
|
151
|
+
lines.push(`${fullPath}${posStr} [options]`.trimEnd())
|
|
152
|
+
|
|
153
|
+
let usageExamples = UI.usageExamples
|
|
154
|
+
if (!usageExamples) {
|
|
155
|
+
for (const key in Class) {
|
|
156
|
+
const meta = /** @type {any} */ (Class)[key]
|
|
157
|
+
if (
|
|
158
|
+
meta &&
|
|
159
|
+
typeof meta === 'object' &&
|
|
160
|
+
meta.positional &&
|
|
161
|
+
(Array.isArray(meta.type) || Array.isArray(meta.options))
|
|
162
|
+
) {
|
|
163
|
+
usageExamples = []
|
|
164
|
+
const subcommands = Array.isArray(meta.type) ? meta.type : meta.options
|
|
165
|
+
for (const SubCmd of subcommands) {
|
|
166
|
+
if (SubCmd && SubCmd.prototype && SubCmd.prototype.generateHelp) {
|
|
167
|
+
const subClassName = typeof SubCmd.name === 'string' ? SubCmd.name : ''
|
|
168
|
+
const cmdName = SubCmd.alias || subClassName.replace(/Command|App/g, '').toLowerCase()
|
|
169
|
+
const desc = SubCmd.UI?.title ? t(SubCmd.UI.title) : ''
|
|
170
|
+
usageExamples.push(`${fullPath} ${cmdName} ${desc ? `— ${desc}` : ''}`.trim())
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (usageExamples.length === 0) usageExamples = undefined
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Array.isArray(usageExamples)) {
|
|
180
|
+
let maxLeft = 0
|
|
181
|
+
/** @type {any[]} */
|
|
182
|
+
const parsedExamples = []
|
|
183
|
+
for (const ex of usageExamples) {
|
|
184
|
+
const renderedStr = t(ex, { cmd: fullPath })
|
|
185
|
+
const match = renderedStr.match(/^(.*?)\s+(—|-)\s+(.*)$/)
|
|
186
|
+
if (match) {
|
|
187
|
+
const left = match[1].trim()
|
|
188
|
+
const sep = match[2]
|
|
189
|
+
const right = match[3].trim()
|
|
190
|
+
maxLeft = Math.max(maxLeft, left.length)
|
|
191
|
+
parsedExamples.push({ left, sep, right })
|
|
192
|
+
} else {
|
|
193
|
+
parsedExamples.push({ left: renderedStr.trim(), sep: '', right: '' })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const p of parsedExamples) {
|
|
197
|
+
if (p.right) {
|
|
198
|
+
lines.push(`${p.left.padEnd(maxLeft + 3)}${p.sep} ${p.right}`)
|
|
199
|
+
} else {
|
|
200
|
+
lines.push(p.left)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
lines.push('```')
|
|
205
|
+
lines.push('')
|
|
206
|
+
|
|
207
|
+
if (posMeta.length > 0) {
|
|
208
|
+
lines.push(`## Arguments:`)
|
|
209
|
+
lines.push('```bash')
|
|
210
|
+
let maxPosLen = 0
|
|
211
|
+
for (const p of posMeta) maxPosLen = Math.max(maxPosLen, p.key.length)
|
|
212
|
+
for (const p of posMeta) {
|
|
213
|
+
const desc = t(p.meta.help)
|
|
214
|
+
let defValue = p.meta.default
|
|
215
|
+
if (typeof defValue === 'function' && defValue.prototype) {
|
|
216
|
+
const defClassName = typeof defValue.name === 'string' ? defValue.name : ''
|
|
217
|
+
defValue = defValue.alias || defClassName.replace(/Command|App/g, '').toLowerCase()
|
|
218
|
+
}
|
|
219
|
+
const def = defValue !== undefined ? ` [${defValue}]` : ''
|
|
220
|
+
lines.push(` ${p.key.padEnd(maxPosLen + 2)} - ${desc}${def}`)
|
|
221
|
+
}
|
|
222
|
+
lines.push('```')
|
|
223
|
+
lines.push('')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const optionsTitle = t(UI.optionsTitle || 'Options:')
|
|
227
|
+
lines.push(`## ${optionsTitle}`)
|
|
228
|
+
lines.push('```bash')
|
|
229
|
+
|
|
230
|
+
let maxOptLen = 0
|
|
231
|
+
let hasAlias = false
|
|
232
|
+
/** @type {Array<{key: string, meta: any, left: string}>} */
|
|
233
|
+
const parsedOptions = []
|
|
234
|
+
|
|
235
|
+
for (const key in Class) {
|
|
236
|
+
const meta = /** @type {any} */ (Class)[key]
|
|
237
|
+
if (!meta || typeof meta !== 'object' || !meta.help || key === 'UI' || meta.positional)
|
|
238
|
+
continue
|
|
239
|
+
if (meta.alias) hasAlias = true
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const entries = []
|
|
243
|
+
for (const key in Class) {
|
|
244
|
+
entries.push([key, /** @type {any} */ (Class)[key]])
|
|
245
|
+
}
|
|
246
|
+
const sortedEntries = entries.sort(([a], [b]) => a.localeCompare(b))
|
|
247
|
+
|
|
248
|
+
for (const [key, meta] of sortedEntries) {
|
|
249
|
+
if (!meta || typeof meta !== 'object' || !meta.help || key === 'UI' || meta.positional)
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
let left
|
|
253
|
+
if (hasAlias) {
|
|
254
|
+
left = meta.alias ? ` -${meta.alias}, --${key}` : ` --${key}`
|
|
255
|
+
} else {
|
|
256
|
+
left = ` --${key}`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
maxOptLen = Math.max(maxOptLen, left.length)
|
|
260
|
+
parsedOptions.push({ key, meta, left })
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const opt of parsedOptions) {
|
|
264
|
+
let right = t(opt.meta.help)
|
|
265
|
+
if (
|
|
266
|
+
opt.key !== 'help' &&
|
|
267
|
+
opt.meta.default !== undefined &&
|
|
268
|
+
opt.meta.default !== null &&
|
|
269
|
+
typeof opt.meta.default !== 'function' &&
|
|
270
|
+
!Array.isArray(opt.meta.default)
|
|
271
|
+
) {
|
|
272
|
+
if (['boolean', 'string', 'number'].includes(typeof opt.meta.default)) {
|
|
273
|
+
right += ` [${opt.meta.default}]`
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
lines.push(`${opt.left.padEnd(maxOptLen + 2)} - ${right}`)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
lines.push('```')
|
|
280
|
+
lines.push('')
|
|
281
|
+
return lines.join('\n')
|
|
34
282
|
}
|
|
35
283
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Execute the model programmatically without a UI adapter.
|
|
286
|
+
* @param {any} [data]
|
|
287
|
+
* @param {Partial<ModelAsAppOptions>} [options]
|
|
288
|
+
* @returns {Promise<any>}
|
|
289
|
+
*/
|
|
290
|
+
static async execute(data = {}, options = {}) {
|
|
291
|
+
const app = new this(data, options)
|
|
292
|
+
if (typeof app.run !== 'function') return null
|
|
293
|
+
|
|
294
|
+
let finalData = null
|
|
295
|
+
const gen = app.run()
|
|
296
|
+
let res = await gen.next()
|
|
297
|
+
while (!res.done) {
|
|
298
|
+
const intent = res.value
|
|
299
|
+
if (intent && intent.type === 'result') finalData = intent.data
|
|
300
|
+
res = await gen.next()
|
|
301
|
+
}
|
|
302
|
+
if (res.value && res.value.type === 'result') finalData = res.value.data
|
|
303
|
+
return finalData
|
|
39
304
|
}
|
|
305
|
+
|
|
40
306
|
/**
|
|
307
|
+
* Default execution generator.
|
|
308
|
+
* Automatically delegates to the first instantiated subcommand field.
|
|
309
|
+
*
|
|
41
310
|
* @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
|
|
42
311
|
*/
|
|
43
312
|
async *run() {
|
|
313
|
+
// 1. Automatic Help Handling (Premium OLMUI style)
|
|
314
|
+
if (/** @type {any} */ (this).help) {
|
|
315
|
+
const content = this.generateHelp()
|
|
316
|
+
const UI = /** @type {any} */ (this.constructor).UI || {}
|
|
317
|
+
const title = UI.title || this.constructor.name
|
|
318
|
+
|
|
319
|
+
if (/** @type {any} */ (this).raw) {
|
|
320
|
+
yield show(content, 'info', /** @type {any} */ ({ format: 'markdown', raw: true }))
|
|
321
|
+
} else {
|
|
322
|
+
yield ask('help', { content, title: `${title} Help`, hint: 'content-viewer' })
|
|
323
|
+
}
|
|
324
|
+
return result({})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 2. Automatic Subcommand Delegation
|
|
328
|
+
for (const key in this) {
|
|
329
|
+
const val = /** @type {any} */ (this)[key]
|
|
330
|
+
if (val instanceof ModelAsApp && val !== this) {
|
|
331
|
+
return yield* val.run()
|
|
332
|
+
}
|
|
333
|
+
}
|
|
44
334
|
return result({})
|
|
45
335
|
}
|
|
46
336
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { ModelAsApp } from './ModelAsApp.js'
|
|
4
|
+
import { runGenerator, show, result } from '../index.js'
|
|
5
|
+
import DB from '@nan0web/db'
|
|
6
|
+
|
|
7
|
+
// ─── 1. Mock Models for Testing ───
|
|
8
|
+
|
|
9
|
+
class SubCommand extends ModelAsApp {
|
|
10
|
+
static UI = { title: 'Sub Logic' }
|
|
11
|
+
static alias = 'sub'
|
|
12
|
+
static timeout = {
|
|
13
|
+
help: 'Timeout to cancel',
|
|
14
|
+
default: 333,
|
|
15
|
+
}
|
|
16
|
+
constructor(data = {}, options = {}) {
|
|
17
|
+
super(data, options)
|
|
18
|
+
/** @type {number} Timeout to cancel */ this.timeout
|
|
19
|
+
}
|
|
20
|
+
async *run() {
|
|
21
|
+
yield show('Sub running')
|
|
22
|
+
return result({ ok: true, timeout: this.timeout })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @property {SubCommand} command
|
|
28
|
+
* @property {boolean} help
|
|
29
|
+
*/
|
|
30
|
+
class RootApp extends ModelAsApp {
|
|
31
|
+
static alias = 'root'
|
|
32
|
+
static UI = { title: 'Root App' }
|
|
33
|
+
static command = {
|
|
34
|
+
help: 'Target command',
|
|
35
|
+
options: [SubCommand],
|
|
36
|
+
positional: true,
|
|
37
|
+
}
|
|
38
|
+
static debug = {
|
|
39
|
+
help: 'Debug mode',
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
default: false,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── 2. User Stories ───
|
|
46
|
+
|
|
47
|
+
describe('ModelAsApp User Stories', () => {
|
|
48
|
+
it('Story: Universal Help - renders sorted and inherited options', async () => {
|
|
49
|
+
const app = new RootApp()
|
|
50
|
+
const help = app.generateHelp()
|
|
51
|
+
assert.deepStrictEqual(help.split('\n'), [
|
|
52
|
+
'# Root App',
|
|
53
|
+
'',
|
|
54
|
+
'## Usage:',
|
|
55
|
+
'```bash',
|
|
56
|
+
'root [command] [options]',
|
|
57
|
+
'root sub — Sub Logic',
|
|
58
|
+
'```',
|
|
59
|
+
'',
|
|
60
|
+
'## Arguments:',
|
|
61
|
+
'```bash',
|
|
62
|
+
' command - Target command',
|
|
63
|
+
'```',
|
|
64
|
+
'',
|
|
65
|
+
'## Options:',
|
|
66
|
+
'```bash',
|
|
67
|
+
' --debug - Debug mode [false]',
|
|
68
|
+
' --help - Show help',
|
|
69
|
+
'```',
|
|
70
|
+
'',
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('Story: Subcommand Auto-Routing - instantiates subcommand from string', async () => {
|
|
75
|
+
const app = new RootApp({ command: 'sub' })
|
|
76
|
+
const cmd = /** @type {any} */ (app).command
|
|
77
|
+
assert.ok(cmd instanceof SubCommand, 'Should auto-instantiate SubCommand')
|
|
78
|
+
assert.equal(cmd._.parentPath, 'root', 'Should inject parentPath')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('Story: Subcommand Help - delegates help to subcommand if requested', async () => {
|
|
82
|
+
// Simulating "root sub --help"
|
|
83
|
+
const app = new RootApp({ command: 'sub', help: true })
|
|
84
|
+
const cmd = /** @type {any} */ (app).command
|
|
85
|
+
// In this case, root.command is instantiated and has help: true
|
|
86
|
+
assert.ok(cmd.help, 'Subcommand should have help: true')
|
|
87
|
+
|
|
88
|
+
const helpText = app.generateHelp()
|
|
89
|
+
assert.ok(helpText.includes('# Sub Logic'), 'Should show subcommand help')
|
|
90
|
+
assert.ok(helpText.includes('Usage:'), 'Should have usage')
|
|
91
|
+
assert.ok(helpText.includes('root sub'), 'Should have correct command path')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('Story: Positional Mapping - resolves positionals into model fields', async () => {
|
|
95
|
+
const db = new DB()
|
|
96
|
+
await db.connect()
|
|
97
|
+
|
|
98
|
+
// We use execute to run without real UI
|
|
99
|
+
const res = await RootApp.execute({ command: 'sub', timeout: '3' }, { db })
|
|
100
|
+
assert.deepEqual(res, { ok: true, timeout: 3 }, 'Should execute subcommand and return result')
|
|
101
|
+
assert.equal(res.timeout, 3)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('Story: OLMUI Lifecycle - flows through runGenerator handlers', async () => {
|
|
105
|
+
const app = new RootApp({ command: 'sub' })
|
|
106
|
+
const events = []
|
|
107
|
+
|
|
108
|
+
const data = await runGenerator(app.run(), {
|
|
109
|
+
ask: async () => ({ value: {}, cancelled: false }),
|
|
110
|
+
show: (i) => { events.push(`show:${i.message || i}`) },
|
|
111
|
+
result: (i) => i.data,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
assert.equal(events[0], 'show:Sub running', 'Should capture show intent from subcommand')
|
|
115
|
+
assert.deepEqual(data, { ok: true, timeout: 333 }, 'Should capture final result')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ModelAsApp } from '../ModelAsApp.js'
|
|
2
2
|
import { resolvePositionalArgs } from '@nan0web/ui-cli'
|
|
3
3
|
import SnapshotAuditor from './SnapshotAuditor.js'
|
|
4
|
-
import
|
|
4
|
+
import GalleryRenderCommand from './GalleryRenderCommand.js'
|
|
5
5
|
import { show, result } from '../../core/Intent.js'
|
|
6
6
|
|
|
7
7
|
export class GalleryCommand extends ModelAsApp {
|
|
@@ -14,25 +14,26 @@ export class GalleryCommand extends ModelAsApp {
|
|
|
14
14
|
static action = {
|
|
15
15
|
type: 'string',
|
|
16
16
|
help: 'Command to run',
|
|
17
|
-
options: [SnapshotAuditor,
|
|
18
|
-
default: SnapshotAuditor
|
|
17
|
+
options: [SnapshotAuditor, GalleryRenderCommand],
|
|
18
|
+
default: SnapshotAuditor,
|
|
19
19
|
positional: true,
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* @param {Partial<GalleryCommand> | Record<string, any>} [data={}]
|
|
24
|
-
* @param {import('@nan0web/types').ModelOptions} [options={}]
|
|
24
|
+
* @param {Partial<import('@nan0web/types').ModelOptions>} [options={}]
|
|
25
25
|
*/
|
|
26
26
|
constructor(data = {}, options = {}) {
|
|
27
27
|
super(data, options)
|
|
28
|
-
/** @type {
|
|
28
|
+
/** @type {typeof SnapshotAuditor | typeof GalleryRenderCommand} */ this.action
|
|
29
29
|
/** @type {string[]} */ this._positionals = []
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* @returns {AsyncGenerator<import('@nan0web/ui').Intent, import('@nan0web/ui').ResultIntent, any>}
|
|
34
|
+
*/
|
|
32
35
|
async *run() {
|
|
33
|
-
const TargetAction =
|
|
34
|
-
(opt) => opt.alias === this.action || opt.name === this.action
|
|
35
|
-
)
|
|
36
|
+
const TargetAction = this.action
|
|
36
37
|
|
|
37
38
|
if (!TargetAction) {
|
|
38
39
|
yield show(this._.t(GalleryCommand.UI.unknownAction, { command: this.action }), 'error')
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { Model } from '@nan0web/types'
|
|
2
1
|
import SnapshotRunner from './SnapshotRunner.js'
|
|
3
2
|
|
|
4
3
|
import { show, result } from '../../core/Intent.js'
|
|
4
|
+
import { ModelAsApp } from '../index.js'
|
|
5
5
|
|
|
6
|
-
export class
|
|
6
|
+
export class GalleryRenderCommand extends ModelAsApp {
|
|
7
7
|
static alias = 'render'
|
|
8
8
|
|
|
9
9
|
static UI = {
|
|
@@ -11,36 +11,35 @@ export class GalleryRenderIntent extends Model {
|
|
|
11
11
|
success: '✅ Gallery render complete',
|
|
12
12
|
failed: '🚨 Gallery render failed: {error}',
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
static dataDir = {
|
|
16
|
-
type: 'string',
|
|
14
|
+
|
|
15
|
+
static dataDir = {
|
|
16
|
+
type: 'string',
|
|
17
17
|
default: 'docs/data',
|
|
18
18
|
help: 'Path to source models directory'
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
static dir = {
|
|
22
|
-
type: 'string',
|
|
20
|
+
|
|
21
|
+
static dir = {
|
|
22
|
+
type: 'string',
|
|
23
23
|
default: 'snapshots/core',
|
|
24
24
|
help: 'Path to output snapshots directory'
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
|
|
29
|
-
* @param {Partial<
|
|
30
|
-
|
|
31
|
-
* @param {import('@nan0web/types').ModelOptions} [options={}]
|
|
32
|
-
|
|
28
|
+
* @param {Partial<GalleryRenderCommand> | Record<string, any>} [data={}]
|
|
29
|
+
* @param {Partial<import('@nan0web/types').ModelOptions>} [options={}]
|
|
33
30
|
*/
|
|
34
|
-
|
|
35
31
|
constructor(data = {}, options = {}) {
|
|
36
32
|
super(data, options)
|
|
37
33
|
/** @type {string} */ this.dataDir
|
|
38
34
|
/** @type {string} */ this.dir
|
|
39
35
|
}
|
|
40
36
|
|
|
37
|
+
/**
|
|
38
|
+
* @returns {AsyncGenerator<import('../../core/Intent.js').Intent, import('../../core/Intent.js').ResultIntent, any>}
|
|
39
|
+
*/
|
|
41
40
|
async *run() {
|
|
42
|
-
yield show(this._.t(
|
|
43
|
-
|
|
41
|
+
yield show(this._.t(GalleryRenderCommand.UI.rendering, { dataDir: this.dataDir, dir: this.dir }))
|
|
42
|
+
|
|
44
43
|
const snapshotRunner = new SnapshotRunner({
|
|
45
44
|
dataDir: this.dataDir,
|
|
46
45
|
snapshotsDir: this.dir,
|
|
@@ -62,16 +61,17 @@ export class GalleryRenderIntent extends Model {
|
|
|
62
61
|
try {
|
|
63
62
|
const res = yield* snapshotRunner.run()
|
|
64
63
|
if (res.data && res.data.success) {
|
|
65
|
-
yield show(this._.t(
|
|
64
|
+
yield show(this._.t(GalleryRenderCommand.UI.success, {}))
|
|
66
65
|
} else {
|
|
67
|
-
yield show(this._.t(
|
|
66
|
+
yield show(this._.t(GalleryRenderCommand.UI.failed, { error: 'Audit failed' }), 'error')
|
|
68
67
|
return result({ status: 'error' })
|
|
69
68
|
}
|
|
70
69
|
} catch (error) {
|
|
71
|
-
yield show(this._.t(
|
|
70
|
+
yield show(this._.t(GalleryRenderCommand.UI.failed, { error: /** @type {Error} */ (error).message }), 'error')
|
|
72
71
|
return result({ status: 'error' })
|
|
73
72
|
}
|
|
73
|
+
return result({})
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export default
|
|
77
|
+
export default GalleryRenderCommand
|