@jseeio/jsee 0.8.2 → 0.8.8

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/src/cli.js CHANGED
@@ -2,10 +2,11 @@ const fs = require('fs')
2
2
  const path = require('path')
3
3
  const os = require('os')
4
4
  const crypto = require('crypto')
5
+ const Module = require('module')
5
6
 
6
7
  const minimist = require('minimist')
7
8
 
8
- const { getModelFuncJS, sanitizeName, generateOpenAPISpec, serializeResult, parseMultipart, fileExtToOutputType } = require('./utils.js')
9
+ const { getModelFuncJS, sanitizeName, isRecordObject, normalizeFileOutputValue, generateOpenAPISpec, serializeResult, parseMultipart, fileExtToOutputType } = require('./utils.js')
9
10
 
10
11
  let jsdoc2md
11
12
  function getJsdocToMarkdown () {
@@ -69,6 +70,137 @@ function readDirListing (resolved, relative) {
69
70
  }))
70
71
  }
71
72
 
73
+ function isPackageSpecifier (value) {
74
+ if (typeof value !== 'string') return false
75
+ if (!value || value.startsWith('.') || value.startsWith('/') || value.startsWith('file:')) return false
76
+ if (isHttpUrl(value)) return false
77
+ if (value.includes('\\')) return false
78
+ if (value.startsWith('@')) {
79
+ return /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value)
80
+ }
81
+ return /^[A-Za-z0-9._-]+$/.test(value)
82
+ }
83
+
84
+ function looksLikeMissingPackageInput (value, cwd) {
85
+ if (!isPackageSpecifier(value)) return false
86
+ if (value.includes('.json') || value.includes('.js')) return false
87
+ return !fs.existsSync(path.resolve(cwd, value))
88
+ }
89
+
90
+ function getPackageInputInstallHint (specifier) {
91
+ return `npx -p @jseeio/jsee -p ${specifier} jsee ${specifier} --serve`
92
+ }
93
+
94
+ function resolvePackageJsonPath (specifier, cwd) {
95
+ const resolver = Module.createRequire(path.join(cwd, '__jsee_resolve__.js'))
96
+ const searchPaths = resolver.resolve.paths(specifier) || []
97
+
98
+ for (const base of searchPaths) {
99
+ const candidate = path.join(base, specifier, 'package.json')
100
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
101
+ }
102
+
103
+ try {
104
+ return require.resolve(`${specifier}/package.json`, { paths: [cwd] })
105
+ } catch (error) {
106
+ return null
107
+ }
108
+ }
109
+
110
+ function readJseePackageInput (specifier, packageJsonPath) {
111
+ const packageRoot = path.dirname(packageJsonPath)
112
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
113
+ const appConfig = packageJson.jsee
114
+ let schemaRel
115
+ let descriptionRel
116
+
117
+ if (typeof appConfig === 'string') {
118
+ schemaRel = appConfig
119
+ } else if (appConfig && typeof appConfig === 'object') {
120
+ schemaRel = appConfig.schema || appConfig.input || appConfig.inputs
121
+ descriptionRel = appConfig.description
122
+ }
123
+
124
+ if (!schemaRel) {
125
+ throw new Error(`${specifier} does not declare a JSEE app. Add "jsee": "schema.json" to its package.json.`)
126
+ }
127
+
128
+ const schemaPath = path.resolve(packageRoot, schemaRel)
129
+ if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
130
+ throw new Error(`${specifier} declares JSEE schema ${schemaRel}, but the file was not found.`)
131
+ }
132
+
133
+ let descriptionPath = null
134
+ if (descriptionRel) {
135
+ descriptionPath = path.resolve(packageRoot, descriptionRel)
136
+ } else {
137
+ const readmePath = path.join(packageRoot, 'README.md')
138
+ if (fs.existsSync(readmePath) && fs.statSync(readmePath).isFile()) descriptionPath = readmePath
139
+ }
140
+
141
+ return {
142
+ packageName: packageJson.name || specifier,
143
+ packageRoot,
144
+ schemaPath,
145
+ descriptionPath
146
+ }
147
+ }
148
+
149
+ function resolveJseePackageInput (specifier, cwd) {
150
+ if (!isPackageSpecifier(specifier)) return null
151
+
152
+ const packageJsonPath = resolvePackageJsonPath(specifier, cwd)
153
+ if (!packageJsonPath) return null
154
+
155
+ return readJseePackageInput(specifier, packageJsonPath)
156
+ }
157
+
158
+ function findPackageRoot (startDir) {
159
+ let dir = path.resolve(startDir)
160
+ if (fs.existsSync(dir) && fs.statSync(dir).isFile()) dir = path.dirname(dir)
161
+
162
+ while (true) {
163
+ const packageJsonPath = path.join(dir, 'package.json')
164
+ if (fs.existsSync(packageJsonPath) && fs.statSync(packageJsonPath).isFile()) return dir
165
+
166
+ const parent = path.dirname(dir)
167
+ if (parent === dir) throw new Error(`No package.json found from ${startDir}`)
168
+ dir = parent
169
+ }
170
+ }
171
+
172
+ function hasArgOption (args, names) {
173
+ return args.some(arg => names.some(name => arg === name || arg.startsWith(name + '=')))
174
+ }
175
+
176
+ async function runPackage (dirname, args=[]) {
177
+ if (!Array.isArray(args)) throw new Error('runPackage args must be an array')
178
+
179
+ const packageRoot = findPackageRoot(dirname)
180
+ const packageInput = readJseePackageInput(packageRoot, path.join(packageRoot, 'package.json'))
181
+ const pargv = []
182
+
183
+ if (!hasArgOption(args, ['--inputs', '-i'])) {
184
+ pargv.push('--inputs', packageInput.schemaPath)
185
+ }
186
+ if (!hasArgOption(args, ['--description', '-d']) && packageInput.descriptionPath) {
187
+ pargv.push('--description', packageInput.descriptionPath)
188
+ }
189
+ pargv.push(...args)
190
+
191
+ if (hasArgOption(args, ['--run'])) {
192
+ return await gen(pargv)
193
+ }
194
+
195
+ const previousCwd = process.cwd()
196
+ process.chdir(packageInput.packageRoot)
197
+ try {
198
+ return await gen(pargv)
199
+ } finally {
200
+ process.chdir(previousCwd)
201
+ }
202
+ }
203
+
72
204
  function generateIdentitySchema (target, cwd) {
73
205
  const resolved = path.isAbsolute(target) ? target : path.join(cwd, target)
74
206
  const stat = fs.existsSync(resolved) ? fs.statSync(resolved) : null
@@ -259,6 +391,402 @@ function resolveOutputPath (cwd, outputPath) {
259
391
  return path.join(cwd, outputPath)
260
392
  }
261
393
 
394
+ function writeOutputFile (cwd, outputPath, content) {
395
+ const resolved = resolveOutputPath(cwd, outputPath)
396
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
397
+ fs.writeFileSync(resolved, content)
398
+ }
399
+
400
+ // Run mode helpers
401
+ function getInputDefaultValue (input) {
402
+ if (!input || typeof input !== 'object') return undefined
403
+ if (input.type === 'group' && Array.isArray(input.elements)) {
404
+ const value = {}
405
+ input.elements.forEach(element => {
406
+ const elementValue = getInputDefaultValue(element)
407
+ if (typeof elementValue !== 'undefined' && element.name) value[element.name] = elementValue
408
+ })
409
+ return value
410
+ }
411
+ if (Object.prototype.hasOwnProperty.call(input, 'default')) return input.default
412
+
413
+ switch (input.type) {
414
+ case 'int':
415
+ case 'float':
416
+ case 'number':
417
+ return 0
418
+ case 'string':
419
+ case 'text':
420
+ return ''
421
+ case 'color':
422
+ return '#000000'
423
+ case 'categorical':
424
+ case 'select':
425
+ case 'radio':
426
+ return input.options && input.options.length ? input.options[0] : ''
427
+ case 'bool':
428
+ case 'checkbox':
429
+ case 'toggle':
430
+ return false
431
+ case 'multi-select':
432
+ return []
433
+ case 'range':
434
+ return [input.min || 0, input.max || 100]
435
+ case 'slider':
436
+ return input.min || 0
437
+ case 'folder':
438
+ return input.default || []
439
+ case 'file':
440
+ return ''
441
+ default:
442
+ return undefined
443
+ }
444
+ }
445
+
446
+ function getSchemaInputDefaults (schema) {
447
+ const data = {}
448
+ if (!schema.inputs) return data
449
+ schema.inputs.forEach(input => {
450
+ if (!input.name) return
451
+ const value = getInputDefaultValue(input)
452
+ if (typeof value !== 'undefined') data[input.name] = value
453
+ })
454
+ return data
455
+ }
456
+
457
+ function loadBrowserStyleModelFunction (src, model, modelPath) {
458
+ const modelName = getModelExportName(model)
459
+ const localRequire = modelPath
460
+ ? Module.createRequire(modelPath)
461
+ : require
462
+ const moduleShim = { exports: {} }
463
+ const dirname = modelPath ? path.dirname(modelPath) : process.cwd()
464
+ const filename = modelPath || path.join(dirname, '__jsee_model__.js')
465
+
466
+ const fn = new Function(
467
+ 'require',
468
+ 'module',
469
+ 'exports',
470
+ '__filename',
471
+ '__dirname',
472
+ `${src}
473
+ return typeof ${toJsIdentifier(modelName)} === 'function' ? ${toJsIdentifier(modelName)} : (module.exports && (module.exports.default || module.exports[${JSON.stringify(modelName)}] || module.exports))`
474
+ )
475
+ const target = fn(localRequire, moduleShim, moduleShim.exports, filename, dirname)
476
+ if (typeof target === 'function') return target
477
+ if (target && typeof target === 'object') {
478
+ if (typeof target[modelName] === 'function') return target[modelName]
479
+ if (typeof target.default === 'function') return target.default
480
+ }
481
+ return target
482
+ }
483
+
484
+ function resolveExportedTarget (target, model) {
485
+ const modelName = getModelExportName(model)
486
+ if (typeof target === 'function') return target
487
+ if (target && typeof target === 'object') {
488
+ if (typeof target[modelName] === 'function') return target[modelName]
489
+ if (typeof target.default === 'function') return target.default
490
+ }
491
+ return target
492
+ }
493
+
494
+ async function loadCliModelFunction (model, schemaPath, cwd, log) {
495
+ if (model.type === 'get' || model.type === 'post') {
496
+ throw new Error(`--run cannot execute remote ${model.type.toUpperCase()} model ${model.name || model.url}. Use a local JavaScript model.`)
497
+ }
498
+ if (model.type === 'py') {
499
+ throw new Error('--run currently supports local JavaScript models. Python model execution is available through the dev server/API path.')
500
+ }
501
+
502
+ let target
503
+ let modelPath = null
504
+
505
+ if (typeof model.code === 'function') {
506
+ target = model.code
507
+ } else if (typeof model.code === 'string' && model.code.trim()) {
508
+ try {
509
+ target = loadBrowserStyleModelFunction(model.code, model, null)
510
+ } catch (error) {
511
+ target = undefined
512
+ }
513
+ if (typeof target !== 'function') {
514
+ try {
515
+ target = new Function(`return (${model.code})`)()
516
+ } catch (error) {
517
+ // Keep the clearer error below.
518
+ }
519
+ }
520
+ } else if (model.url && !isHttpUrl(model.url)) {
521
+ modelPath = path.resolve(schemaPath ? path.dirname(schemaPath) : cwd, model.url)
522
+ target = resolveExportedTarget(require(modelPath), model)
523
+ if (typeof target !== 'function' && (!target || Object.keys(target).length === 0)) {
524
+ const src = fs.readFileSync(modelPath, 'utf-8')
525
+ target = loadBrowserStyleModelFunction(src, model, modelPath)
526
+ }
527
+ } else {
528
+ throw new Error(`--run requires local model code or a local model url for ${model.name || 'model'}`)
529
+ }
530
+
531
+ target = resolveExportedTarget(target, model)
532
+ if (typeof target !== 'function') {
533
+ throw new Error(`Could not load a callable function for model ${model.name || model.url || 'model'}`)
534
+ }
535
+
536
+ return getModelFuncJS(model, target, {
537
+ log,
538
+ progress: () => {},
539
+ isCancelled: () => false
540
+ })
541
+ }
542
+
543
+ async function runSchemaOnce (schema, data, schemaPath, cwd, log) {
544
+ let result = data
545
+ for (const model of schema.model) {
546
+ const modelFunc = await loadCliModelFunction(model, schemaPath, cwd, log)
547
+ const next = await modelFunc(result)
548
+ if (isRecordObject(result) && isRecordObject(next)) {
549
+ result = Object.assign({}, result, next)
550
+ } else if (typeof next !== 'undefined') {
551
+ result = next
552
+ }
553
+ if (isRecordObject(result) && result.stop) break
554
+ }
555
+ return result
556
+ }
557
+
558
+ function flattenOutputs (outputs, flat=[]) {
559
+ if (!Array.isArray(outputs)) return flat
560
+ outputs.forEach(output => {
561
+ if (output && output.type === 'group' && Array.isArray(output.elements)) {
562
+ flattenOutputs(output.elements, flat)
563
+ } else if (output) {
564
+ flat.push(output)
565
+ }
566
+ })
567
+ return flat
568
+ }
569
+
570
+ function cleanRunResultObject (result) {
571
+ if (!isRecordObject(result)) return result
572
+ const clean = Object.assign({}, result)
573
+ delete clean.caller
574
+ delete clean.stop
575
+ delete clean._status
576
+ delete clean._log
577
+ delete clean._progress
578
+ return clean
579
+ }
580
+
581
+ function getRunOutputValue (result, output) {
582
+ if (!isRecordObject(result)) return result
583
+ const names = [output.name, sanitizeName(output.name)]
584
+ if (output.alias) names.push(output.alias)
585
+ for (const name of names) {
586
+ if (name && Object.prototype.hasOwnProperty.call(result, name)) return result[name]
587
+ }
588
+ if (Object.prototype.hasOwnProperty.call(output, 'value')) return output.value
589
+ return undefined
590
+ }
591
+
592
+ function getRunOutputEntries (schema, result) {
593
+ const cleanResult = cleanRunResultObject(result)
594
+ const outputs = flattenOutputs(schema.outputs || [])
595
+ if (outputs.length) {
596
+ return outputs.map((output, index) => {
597
+ let value = getRunOutputValue(cleanResult, output)
598
+ if (typeof value === 'undefined' && outputs.length === 1 && !isRecordObject(cleanResult)) value = cleanResult
599
+ return {
600
+ output,
601
+ name: output.name || `output_${index + 1}`,
602
+ value
603
+ }
604
+ }).filter(entry => typeof entry.value !== 'undefined')
605
+ }
606
+
607
+ if (isRecordObject(cleanResult)) {
608
+ return Object.keys(cleanResult).map(key => ({
609
+ output: { name: key, type: typeof cleanResult[key] },
610
+ name: key,
611
+ value: cleanResult[key]
612
+ }))
613
+ }
614
+
615
+ return [{
616
+ output: { name: 'result', type: typeof cleanResult },
617
+ name: 'result',
618
+ value: cleanResult
619
+ }]
620
+ }
621
+
622
+ function getRunFileDescriptor (output, value) {
623
+ if (output.type !== 'file') {
624
+ return { value, filename: output.filename || output.name, mime: output.mime || output.contentType }
625
+ }
626
+ return normalizeFileOutputValue(output, value)
627
+ }
628
+
629
+ function extensionFromMime (mime) {
630
+ if (!mime || typeof mime !== 'string') return null
631
+ const clean = mime.split(';')[0].trim().toLowerCase()
632
+ const map = {
633
+ 'text/csv': '.csv',
634
+ 'text/plain': '.txt',
635
+ 'text/markdown': '.md',
636
+ 'text/html': '.html',
637
+ 'application/json': '.json',
638
+ 'application/pdf': '.pdf',
639
+ 'image/png': '.png',
640
+ 'image/jpeg': '.jpg',
641
+ 'image/svg+xml': '.svg'
642
+ }
643
+ return map[clean] || null
644
+ }
645
+
646
+ function extensionForRunOutput (output, value, descriptor) {
647
+ if (descriptor && descriptor.filename && path.extname(descriptor.filename)) return path.extname(descriptor.filename)
648
+ if (descriptor && descriptor.mime) {
649
+ const mimeExt = extensionFromMime(descriptor.mime)
650
+ if (mimeExt) return mimeExt
651
+ }
652
+
653
+ switch ((output.type || '').toLowerCase()) {
654
+ case 'file':
655
+ return '.txt'
656
+ case 'markdown':
657
+ return '.md'
658
+ case 'html':
659
+ return '.html'
660
+ case 'code':
661
+ case 'text':
662
+ case 'string':
663
+ case 'chat':
664
+ return '.txt'
665
+ case 'image':
666
+ return '.png'
667
+ case 'pdf':
668
+ return '.pdf'
669
+ default:
670
+ return '.json'
671
+ }
672
+ }
673
+
674
+ function getRunOutputFilename (entry) {
675
+ const descriptor = getRunFileDescriptor(entry.output, entry.value)
676
+ const base = descriptor.filename
677
+ ? path.basename(String(descriptor.filename))
678
+ : sanitizeName(entry.name || 'output')
679
+ if (path.extname(base)) return base
680
+ return base + extensionForRunOutput(entry.output, descriptor.value, descriptor)
681
+ }
682
+
683
+ function formatRunOutputContent (entry, forStdout=false) {
684
+ const descriptor = getRunFileDescriptor(entry.output, entry.value)
685
+ const value = descriptor.value
686
+ const type = (entry.output.type || '').toLowerCase()
687
+
688
+ if (Buffer.isBuffer(value)) return { content: value, addNewline: false }
689
+ if (value instanceof Uint8Array) return { content: Buffer.from(value), addNewline: false }
690
+
691
+ if (type === 'file') {
692
+ if (isRecordObject(value) || Array.isArray(value)) {
693
+ return { content: JSON.stringify(value, null, 2), addNewline: true }
694
+ }
695
+ return { content: typeof value === 'undefined' || value === null ? '' : String(value), addNewline: false }
696
+ }
697
+
698
+ if (['text', 'string', 'code', 'markdown', 'html', 'chat'].includes(type)) {
699
+ if (isRecordObject(value) || Array.isArray(value)) {
700
+ return { content: JSON.stringify(value, null, 2), addNewline: true }
701
+ }
702
+ return { content: typeof value === 'undefined' || value === null ? '' : String(value), addNewline: true }
703
+ }
704
+
705
+ if (typeof value === 'string' && forStdout && !['table', 'object', 'array', 'json'].includes(type)) {
706
+ return { content: value, addNewline: true }
707
+ }
708
+
709
+ return { content: JSON.stringify(value, null, 2), addNewline: true }
710
+ }
711
+
712
+ function writeRunContent (target, formatted) {
713
+ let content = formatted.content
714
+ if (typeof content === 'string' && formatted.addNewline && !content.endsWith('\n')) content += '\n'
715
+ fs.mkdirSync(path.dirname(target), { recursive: true })
716
+ fs.writeFileSync(target, content)
717
+ }
718
+
719
+ function writeRunStdout (formatted) {
720
+ let content = formatted.content
721
+ if (typeof content === 'string' && formatted.addNewline && !content.endsWith('\n')) content += '\n'
722
+ process.stdout.write(content)
723
+ }
724
+
725
+ function getRunOutputsDirectory (outputs, cwd) {
726
+ if (!outputs) return null
727
+ const value = Array.isArray(outputs) ? outputs[0] : outputs
728
+ if (value === true || typeof value !== 'string') {
729
+ throw new Error('--outputs in --run mode expects a directory path')
730
+ }
731
+ return resolveOutputPath(cwd, value)
732
+ }
733
+
734
+ function getRunOutputTarget (entry, argv, inputNames, cwd) {
735
+ const outputName = sanitizeName(entry.name)
736
+ const explicitKey = `output-${outputName}`
737
+ let value
738
+ if (Object.prototype.hasOwnProperty.call(argv, explicitKey)) {
739
+ value = argv[explicitKey]
740
+ } else if (!inputNames.has(outputName) && Object.prototype.hasOwnProperty.call(argv, outputName)) {
741
+ value = argv[outputName]
742
+ }
743
+ if (typeof value === 'undefined') return null
744
+ if (value === true || typeof value !== 'string') {
745
+ throw new Error(`Output target --${outputName} requires a file path`)
746
+ }
747
+ return resolveOutputPath(cwd, value)
748
+ }
749
+
750
+ function emitRunOutputs (schema, result, argv, outputs, cwd) {
751
+ const entries = getRunOutputEntries(schema, result)
752
+ const inputNames = new Set((schema.inputs || []).map(input => sanitizeName(input.name)))
753
+ const outputDir = getRunOutputsDirectory(outputs, cwd)
754
+ const targets = new Map()
755
+
756
+ entries.forEach(entry => {
757
+ const target = getRunOutputTarget(entry, argv, inputNames, cwd)
758
+ if (target) targets.set(entry.name, target)
759
+ })
760
+
761
+ if (outputDir) {
762
+ fs.mkdirSync(outputDir, { recursive: true })
763
+ entries.forEach(entry => {
764
+ const target = targets.get(entry.name) || path.join(outputDir, getRunOutputFilename(entry))
765
+ writeRunContent(target, formatRunOutputContent(entry))
766
+ })
767
+ return
768
+ }
769
+
770
+ if (targets.size) {
771
+ entries.forEach(entry => {
772
+ const target = targets.get(entry.name)
773
+ if (target) writeRunContent(target, formatRunOutputContent(entry))
774
+ })
775
+ return
776
+ }
777
+
778
+ if (entries.length === 1) {
779
+ writeRunStdout(formatRunOutputContent(entries[0], true))
780
+ return
781
+ }
782
+
783
+ const resultObject = {}
784
+ entries.forEach(entry => {
785
+ resultObject[entry.name] = entry.value
786
+ })
787
+ writeRunStdout({ content: JSON.stringify(resultObject, null, 2), addNewline: true })
788
+ }
789
+
262
790
  let optionalEsbuild
263
791
  function getOptionalEsbuild () {
264
792
  if (optionalEsbuild) return optionalEsbuild
@@ -324,6 +852,64 @@ function buildBundledModelExposeCode (bundleGlobalName, modelName) {
324
852
  `
325
853
  }
326
854
 
855
+
856
+ function escapeRegExp (value) {
857
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
858
+ }
859
+
860
+ function readPackageJsonForFile (filePath) {
861
+ try {
862
+ const packageRoot = findPackageRoot(filePath)
863
+ const packageJsonPath = path.join(packageRoot, 'package.json')
864
+ return {
865
+ packageRoot,
866
+ packageJson: JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
867
+ }
868
+ } catch (error) {
869
+ return null
870
+ }
871
+ }
872
+
873
+ function createEmptyModulePlugin (specifiers) {
874
+ const list = Array.from(new Set(specifiers.filter(Boolean)))
875
+ if (!list.length) return null
876
+ const filter = new RegExp(`^(?:${list.map(escapeRegExp).join('|')})$`)
877
+ return {
878
+ name: 'jsee-empty-browser-modules',
879
+ setup (build) {
880
+ build.onResolve({ filter }, args => ({ path: args.path, namespace: 'jsee-empty' }))
881
+ build.onLoad({ filter: /.*/, namespace: 'jsee-empty' }, () => ({ contents: 'module.exports = {}', loader: 'js' }))
882
+ }
883
+ }
884
+ }
885
+
886
+ function getBrowserFieldEsbuildOptions (modelPath) {
887
+ const packageInfo = readPackageJsonForFile(modelPath)
888
+ if (!packageInfo) return {}
889
+
890
+ const browser = packageInfo.packageJson.browser
891
+ if (!browser || typeof browser !== 'object' || Array.isArray(browser)) {
892
+ return { absWorkingDir: packageInfo.packageRoot }
893
+ }
894
+
895
+ const alias = {}
896
+ const empty = []
897
+ Object.entries(browser).forEach(([key, value]) => {
898
+ if (!key || key.startsWith('.') || key.startsWith('/')) return
899
+ if (value === false) empty.push(key)
900
+ else if (typeof value === 'string' && value.length) alias[key] = value
901
+ })
902
+
903
+ const plugins = []
904
+ const emptyPlugin = createEmptyModulePlugin(empty)
905
+ if (emptyPlugin) plugins.push(emptyPlugin)
906
+
907
+ const options = { absWorkingDir: packageInfo.packageRoot }
908
+ if (Object.keys(alias).length) options.alias = alias
909
+ if (plugins.length) options.plugins = plugins
910
+ return options
911
+ }
912
+
327
913
  async function bundleModelCode (model, modelPath, code) {
328
914
  if (!shouldBundleModelCode(code)) return code
329
915
 
@@ -339,14 +925,14 @@ async function bundleModelCode (model, modelPath, code) {
339
925
  throw new Error(`Bundling ${model.url || modelPath} requires a valid JavaScript model name or an explicit module export.`)
340
926
  }
341
927
 
342
- const options = {
928
+ const options = Object.assign({
343
929
  bundle: true,
344
930
  write: false,
345
931
  platform: 'browser',
346
932
  format: 'iife',
347
933
  globalName: bundleGlobalName,
348
934
  logLevel: 'silent'
349
- }
935
+ }, getBrowserFieldEsbuildOptions(modelPath))
350
936
 
351
937
  if (needsExportShim) {
352
938
  options.stdin = {
@@ -704,10 +1290,18 @@ function template(schema, blocks) {
704
1290
  table th { background-color: #f0f0f0; border: 1px solid #e0e0e0; }
705
1291
  table td { border: 1px solid #e8e8e8; }
706
1292
  @media screen and (max-width: 800px) { table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } }
707
- .site-header { border-bottom: 1px solid #e8e8e8; min-height: 55.95px; line-height: 54px; position: relative; }
708
- .site-title { font-size: 1.625rem; font-weight: 800; letter-spacing: -1px; margin-bottom: 0; float: left; }
709
- @media screen and (max-width: 600px) { .site-title { padding-right: 45px; } }
1293
+ .site-header { border-bottom: 1px solid #e8e8e8; min-height: 55.95px; position: relative; }
1294
+ .site-header .wrapper { min-height: 54px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
1295
+ .site-header .wrapper:after { content: none; }
1296
+ .site-title { font-size: 1.625rem; font-weight: 800; letter-spacing: -1px; line-height: 1.2; margin-bottom: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
710
1297
  .site-title, .site-title:visited { color: #424242; }
1298
+ .jsee-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; font-size: 12px; line-height: 1; }
1299
+ .jsee-header-badge, .jsee-header-button { border: 1px solid #e0e0e0; border-radius: 4px; background: #f8f8f8; color: #555; padding: 6px 10px; font: inherit; line-height: 1; white-space: nowrap; }
1300
+ .jsee-header-badge { font-family: Menlo, Inconsolata, Consolas, Roboto Mono, Ubuntu Mono, Liberation Mono, Courier New, monospace; }
1301
+ .jsee-header-button { cursor: pointer; }
1302
+ .jsee-header-button:hover { background: #f0f0f0; color: #111; }
1303
+ .jsee-header-toggle { display: flex; align-items: center; gap: 4px; color: #666; cursor: pointer; white-space: nowrap; }
1304
+ @media screen and (max-width: 600px) { .site-header .wrapper { flex-wrap: wrap; gap: 8px; padding-top: 10px; padding-bottom: 10px; } .site-title { flex: 1 1 100%; white-space: normal; } .jsee-header-actions { width: 100%; justify-content: flex-start; } }
711
1305
  .site-nav { position: absolute; top: 9px; right: 15px; background-color: #fdfdfd; border: 1px solid #e8e8e8; border-radius: 5px; text-align: right; }
712
1306
  .site-nav .nav-trigger { display: none; }
713
1307
  .site-nav .menu-icon { float: right; width: 36px; height: 26px; line-height: 0; padding-top: 10px; text-align: center; }
@@ -767,7 +1361,6 @@ function template(schema, blocks) {
767
1361
  @media screen and (min-width: 800px) { .one-half { width: calc(50% - (30px / 2)); } }
768
1362
  /** Jsee elements */
769
1363
  .app-container { background-color: #F0F1F4; border-bottom: 1px solid #e8e8e8; padding-bottom: 55px }
770
- #save-html-btn:hover { background-color: #f0f0f0 !important; }
771
1364
  .schema-description { background-color: #f8f8fa; padding: 20px; margin-top: 20px; border-radius: 10px; border: 1px solid #e8e8e8; }
772
1365
  .schema-description h2, .schema-description h3, .schema-description h4 { margin-top: 10px; }
773
1366
  /** Logos */
@@ -782,10 +1375,10 @@ function template(schema, blocks) {
782
1375
  </head>
783
1376
  <body>
784
1377
  ${blocks.hiddenElementHtml}
785
- ${blocks.serveBarHtml}
786
1378
  <header class="site-header">
787
1379
  <div class="wrapper">
788
1380
  <span class="site-title">${title}</span>
1381
+ ${blocks.headerRightHtml}
789
1382
  </div>
790
1383
  </header>
791
1384
  <div class="page-content app-container">
@@ -836,9 +1429,39 @@ function template(schema, blocks) {
836
1429
  env = new JSEE({ container: container, schema: currentSchema })
837
1430
  })
838
1431
  }
839
- var saveBtn = document.getElementById('save-html-btn')
840
- if (saveBtn) {
841
- saveBtn.addEventListener('click', function () { env.download("${title}") })
1432
+ var downloadBtn = document.getElementById('download-html-btn')
1433
+ if (downloadBtn) {
1434
+ var downloadTitle = ${JSON.stringify(title)}
1435
+ function formatHtmlSize(bytes) {
1436
+ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
1437
+ if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB'
1438
+ return bytes + ' B'
1439
+ }
1440
+ function getDownloadHtml() {
1441
+ return '<!DOCTYPE html>\\n' + document.documentElement.outerHTML
1442
+ }
1443
+ function getDownloadFilename() {
1444
+ var base = (downloadTitle || 'jsee').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
1445
+ return (base || 'jsee') + '.html'
1446
+ }
1447
+ function updateDownloadLabel() {
1448
+ var html = getDownloadHtml()
1449
+ var size = new Blob([html], { type: 'text/html' }).size
1450
+ downloadBtn.textContent = 'Download HTML - ' + formatHtmlSize(size)
1451
+ }
1452
+ downloadBtn.addEventListener('click', function () {
1453
+ var html = getDownloadHtml()
1454
+ var blob = new Blob([html], { type: 'text/html' })
1455
+ var url = URL.createObjectURL(blob)
1456
+ var a = document.createElement('a')
1457
+ a.href = url
1458
+ a.download = getDownloadFilename()
1459
+ document.body.appendChild(a)
1460
+ a.click()
1461
+ a.remove()
1462
+ setTimeout(function () { URL.revokeObjectURL(url) }, 0)
1463
+ })
1464
+ setTimeout(updateDownloadLabel, 0)
842
1465
  }
843
1466
  </script>
844
1467
  </body>
@@ -863,6 +1486,7 @@ async function gen (pargv, returnHtml=false) {
863
1486
  execute: 'e',
864
1487
  cdn: 'c',
865
1488
  runtime: 'r',
1489
+ serve: 's'
866
1490
  }
867
1491
  const argvDefault = {
868
1492
  execute: 'auto', // execute the model code on the server (auto = server-side when serving local .js models)
@@ -872,12 +1496,15 @@ async function gen (pargv, returnHtml=false) {
872
1496
  version: 'latest', // default version of JSEE runtime to use
873
1497
  verbose: false, // verbose mode
874
1498
  cdn: false,
875
- runtime: 'auto'
1499
+ runtime: 'auto',
1500
+ serve: false,
1501
+ run: false
876
1502
  }
1503
+ const booleanArgs = ['help', 'h', 'html', 'client', 'serve', 'run']
877
1504
  let argv = minimist(pargv, {
878
1505
  alias: argvAlias,
879
1506
  default: argvDefault,
880
- boolean: ['help', 'h', 'html', 'client'],
1507
+ boolean: booleanArgs,
881
1508
  })
882
1509
 
883
1510
  // ── jsee init <template> ──────────────────────────────────────────
@@ -1015,7 +1642,7 @@ npx @jseeio/jsee schema.json
1015
1642
 
1016
1643
  if (argv.help || argv.h) {
1017
1644
  console.log(`
1018
- Usage: jsee [schema.json] [data...] [options]
1645
+ Usage: jsee [schema.json|package] [data...] [options]
1019
1646
  jsee init [template] [--html]
1020
1647
 
1021
1648
  Commands:
@@ -1030,6 +1657,8 @@ Options:
1030
1657
  -v, --version <version> JSEE runtime version (default: latest)
1031
1658
  -b, --bundle Bundle runtime + dependencies into output
1032
1659
  -f, --fetch Alias for --bundle
1660
+ -s, --serve Serve explicitly (default when no output is provided)
1661
+ --run Execute the model once and write pipeable outputs
1033
1662
  -e, --execute Execute model server-side (auto-enabled when serving local .js models)
1034
1663
  --client Force client-side execution (disable auto server-side)
1035
1664
  -c, --cdn <url|bool> Rewrite model URLs for CDN deployment
@@ -1047,11 +1676,14 @@ Examples:
1047
1676
  jsee init chat Scaffold chat project
1048
1677
  jsee init --html Generate single index.html
1049
1678
  jsee schema.json Start dev server with schema
1679
+ jsee @scope/app --serve Start dev server from a JSEE app package
1050
1680
  jsee schema.json 42 hello Pass positional data to first two inputs
1051
1681
  jsee schema.json --a=100 --b=200 Pass named data inputs
1052
1682
  jsee schema.json data.csv Pass a file path as input
1053
1683
  jsee schema.json -o app.html Generate static HTML file
1054
1684
  jsee schema.json -o app.html --bundle Generate self-contained HTML with bundled runtime
1685
+ jsee @statsim/gen --run --dataset Moons --format CSV --nSamples 500
1686
+ jsee @statsim/gen --run --dataset Moons --file moons.csv
1055
1687
  jsee -p 8080 Start dev server on port 8080
1056
1688
  jsee report.pdf Serve a PDF file (auto-detected viewer)
1057
1689
  jsee data/ Serve a folder (file browser with preview)
@@ -1061,14 +1693,18 @@ Documentation: https://jsee.org
1061
1693
  return
1062
1694
  }
1063
1695
 
1064
- // Check if first positional arg is a file or directory for identity serving
1696
+ // Check if first positional arg is a file/directory or JSEE app package.
1065
1697
  let identityResult = null
1698
+ let packageInput = null
1066
1699
  if (!imported && argv._.length > 0) {
1067
1700
  identityResult = generateIdentitySchema(argv._[0], process.cwd())
1701
+ if (!identityResult) {
1702
+ packageInput = resolveJseePackageInput(argv._[0], process.cwd())
1703
+ }
1068
1704
  }
1069
1705
 
1070
- // Set argv.inputs to the first positional argument if it looks like a schema/script
1071
- if (!identityResult && !imported && argv._.length > 0 && argv.inputs === argvDefault.inputs) {
1706
+ // Set argv.inputs to the first positional argument if it looks like a schema/script/package.
1707
+ if (!identityResult && !packageInput && !imported && argv._.length > 0 && argv.inputs === argvDefault.inputs) {
1072
1708
  argv.inputs = argv._[0]
1073
1709
  }
1074
1710
 
@@ -1087,8 +1723,22 @@ Documentation: https://jsee.org
1087
1723
 
1088
1724
  let cwd = process.cwd()
1089
1725
  let inputs = argv.inputs
1090
- let outputs = argv.outputs
1091
- let description = argv.description
1726
+ let outputs = argv.serve ? false : argv.outputs
1727
+ let description = argv.description || ''
1728
+
1729
+ if (packageInput || (!identityResult && typeof inputs === 'string')) {
1730
+ const resolvedPackageInput = packageInput || resolveJseePackageInput(inputs, cwd)
1731
+ if (resolvedPackageInput) {
1732
+ packageInput = resolvedPackageInput
1733
+ cwd = packageInput.packageRoot
1734
+ inputs = [packageInput.schemaPath]
1735
+ if (!description && packageInput.descriptionPath) {
1736
+ description = path.relative(cwd, packageInput.descriptionPath)
1737
+ }
1738
+ } else if (looksLikeMissingPackageInput(inputs, cwd)) {
1739
+ throw new Error(`Cannot resolve JSEE app package ${inputs}. Install it in this project or let npm provide both packages:\n ${getPackageInputInstallHint(inputs)}`)
1740
+ }
1741
+ }
1092
1742
  let schema
1093
1743
  let schemaPath
1094
1744
  let descriptionTxt = ''
@@ -1099,13 +1749,13 @@ Documentation: https://jsee.org
1099
1749
  // Determine the inputs and outputs
1100
1750
  // if inputs is a string with js file names, split it into an array
1101
1751
  if (typeof inputs === 'string') {
1102
- if (inputs.includes('.js')) {
1752
+ if (inputs.includes('.js') || inputs.includes('.json') || inputs.includes(',')) {
1103
1753
  inputs = inputs.split(',')
1104
1754
  }
1105
1755
  }
1106
1756
 
1107
1757
  // if outputs is a string with js file names, split it into an array
1108
- if (typeof outputs === 'string') {
1758
+ if (typeof outputs === 'string' && !argv.run) {
1109
1759
  outputs = outputs.split(',')
1110
1760
  }
1111
1761
 
@@ -1150,8 +1800,11 @@ Documentation: https://jsee.org
1150
1800
  schema.inputs.forEach((inp, inp_index) => {
1151
1801
  if (inp.name) {
1152
1802
  const inputName = sanitizeName(inp.name)
1153
- if (inp.alias) {
1154
- argvAlias[inputName] = inp.alias
1803
+ const inputAliases = []
1804
+ if (inp.alias) inputAliases.push(...toArray(inp.alias))
1805
+ if (inp.name !== inputName) inputAliases.push(inp.name)
1806
+ if (inputAliases.length) {
1807
+ argvAlias[inputName] = inputAliases
1155
1808
  }
1156
1809
  // Use positional arguments as schema input defaults
1157
1810
  if (imported && argv._.length > inp_index) {
@@ -1174,6 +1827,7 @@ Documentation: https://jsee.org
1174
1827
  argv = minimist(pargv, {
1175
1828
  alias: argvAlias,
1176
1829
  default: argvDefault,
1830
+ boolean: booleanArgs
1177
1831
  })
1178
1832
 
1179
1833
  // Now deactivate the inputs present in argv
@@ -1204,6 +1858,17 @@ Documentation: https://jsee.org
1204
1858
  schema.model = [schema.model]
1205
1859
  }
1206
1860
 
1861
+ if (argv.run) {
1862
+ const data = Object.assign(
1863
+ {},
1864
+ getSchemaInputDefaults(schema),
1865
+ getDataFromArgv(schema, argv, true)
1866
+ )
1867
+ const result = await runSchemaOnce(schema, data, schemaPath, cwd, log)
1868
+ emitRunOutputs(schema, result, argv, outputs, process.cwd())
1869
+ return result
1870
+ }
1871
+
1207
1872
  // Resolve server-side execution mode
1208
1873
  const hasOutputs = Array.isArray(outputs) ? outputs.length > 0 : Boolean(outputs)
1209
1874
  let shouldExecute = argv.execute
@@ -1230,18 +1895,7 @@ Documentation: https://jsee.org
1230
1895
  if (shouldExecute) {
1231
1896
  await Promise.all(schema.model.map(async m => {
1232
1897
  log('Preparing a model to run on the server side:', m.name, m.url)
1233
- const modelPath = path.join(schemaPath ? path.dirname(schemaPath) : cwd, m.url)
1234
- let target = require(modelPath)
1235
- // Handle browser-style model files (no module.exports) — eval the source
1236
- // to extract the named function, similar to how the worker does it
1237
- if (typeof target !== 'function' && (!target || Object.keys(target).length === 0)) {
1238
- const src = fs.readFileSync(modelPath, 'utf-8')
1239
- const fn = new Function(src + `\nreturn typeof ${m.name} === 'function' ? ${m.name} : undefined`)()
1240
- if (typeof fn === 'function') {
1241
- target = fn
1242
- }
1243
- }
1244
- modelFuncs[m.name] = await getModelFuncJS(m, target, {log})
1898
+ modelFuncs[m.name] = await loadCliModelFunction(m, schemaPath, cwd, log)
1245
1899
  m.type = 'post'
1246
1900
  m.url = `/${m.name}`
1247
1901
  m.worker = false
@@ -1285,7 +1939,8 @@ Documentation: https://jsee.org
1285
1939
 
1286
1940
  // Generate description block
1287
1941
  if (description) {
1288
- const descriptionMd = fs.readFileSync(path.join(cwd, description), 'utf8')
1942
+ const descriptionPath = path.isAbsolute(description) ? description : path.join(cwd, description)
1943
+ const descriptionMd = fs.readFileSync(descriptionPath, 'utf8')
1289
1944
  descriptionHtml = getMarkdownConverter().render(descriptionMd)
1290
1945
 
1291
1946
  if (descriptionMd.includes('---')) {
@@ -1455,20 +2110,17 @@ Documentation: https://jsee.org
1455
2110
 
1456
2111
  }
1457
2112
 
1458
- // Build serve bar (only when serving, not for -o output)
1459
- let serveBarHtml = ''
2113
+ // Build right-side header status/action. Serve mode shows only runtime status; bundled files can download themselves.
2114
+ let headerRightHtml = ''
1460
2115
  let schemaScript = ''
1461
2116
  const isServing = !hasOutputs
1462
2117
  if (isServing) {
1463
2118
  const toggleHtml = shouldExecute
1464
- ? '<label style="cursor:pointer"><input type="checkbox" id="exec-toggle" style="margin-right:4px">Browser</label>'
2119
+ ? '<label class="jsee-header-toggle"><input type="checkbox" id="exec-toggle">Browser</label>'
1465
2120
  : ''
1466
- serveBarHtml = `<div id="jsee-serve-bar" style="background:#f8f8f8;border-bottom:1px solid #e0e0e0;padding:6px 15px;font-size:13px;color:#828282;display:flex;align-items:center;gap:16px">
1467
- <span style="font-family:monospace">localhost:${argv.port}</span>
1468
- <span style="flex:1"></span>
1469
- ${toggleHtml}
1470
- <button id="save-html-btn" style="background:none;border:1px solid #ddd;border-radius:3px;padding:3px 10px;font-size:12px;color:#555;cursor:pointer" title="Save as self-contained HTML file">Save HTML</button>
1471
- </div>`
2121
+ headerRightHtml = `<div class="jsee-header-actions">${toggleHtml}<span class="jsee-header-badge">localhost:${argv.port}</span></div>`
2122
+ } else if (shouldBundle) {
2123
+ headerRightHtml = '<div class="jsee-header-actions"><button id="download-html-btn" class="jsee-header-button" type="button" title="Download this standalone HTML file">Download HTML</button></div>'
1472
2124
  }
1473
2125
  if (shouldExecute) {
1474
2126
  const clientSchema = JSON.parse(JSON.stringify(schema))
@@ -1486,7 +2138,7 @@ Documentation: https://jsee.org
1486
2138
  hiddenElementHtml: hiddenElementHtml,
1487
2139
  socialHtml: pad(socialHtml, 2, 1),
1488
2140
  orgHtml: pad(orgHtml, 2, 1),
1489
- serveBarHtml: serveBarHtml,
2141
+ headerRightHtml: headerRightHtml,
1490
2142
  schemaScript: schemaScript,
1491
2143
  })
1492
2144
 
@@ -1499,11 +2151,11 @@ Documentation: https://jsee.org
1499
2151
  if (o === 'stdout') {
1500
2152
  log(html)
1501
2153
  } else if (o.includes('.html')) {
1502
- fs.writeFileSync(resolveOutputPath(cwd, o), html)
2154
+ writeOutputFile(cwd, o, html)
1503
2155
  } else if (o.includes('.json')) {
1504
- fs.writeFileSync(resolveOutputPath(cwd, o), JSON.stringify(schema, null, 2))
2156
+ writeOutputFile(cwd, o, JSON.stringify(schema, null, 2))
1505
2157
  } else if (o.includes('.md')) {
1506
- fs.writeFileSync(resolveOutputPath(cwd, o), genMarkdownFromSchema(schema))
2158
+ writeOutputFile(cwd, o, genMarkdownFromSchema(schema))
1507
2159
  } else {
1508
2160
  console.error('Invalid output file:', o)
1509
2161
  }
@@ -1613,6 +2265,16 @@ module.exports.resolveLocalImportFile = resolveLocalImportFile
1613
2265
  module.exports.resolveFetchImport = resolveFetchImport
1614
2266
  module.exports.resolveRuntimeMode = resolveRuntimeMode
1615
2267
  module.exports.resolveOutputPath = resolveOutputPath
2268
+ module.exports.resolveJseePackageInput = resolveJseePackageInput
2269
+ module.exports.readJseePackageInput = readJseePackageInput
2270
+ module.exports.findPackageRoot = findPackageRoot
2271
+ module.exports.runPackage = runPackage
2272
+ module.exports.looksLikeMissingPackageInput = looksLikeMissingPackageInput
2273
+ module.exports.getPackageInputInstallHint = getPackageInputInstallHint
2274
+ module.exports.isPackageSpecifier = isPackageSpecifier
2275
+ module.exports.writeOutputFile = writeOutputFile
1616
2276
  module.exports.needsFullBundle = needsFullBundle
1617
2277
  module.exports.shouldBundleModelCode = shouldBundleModelCode
1618
2278
  module.exports.bundleModelCode = bundleModelCode
2279
+ module.exports.runSchemaOnce = runSchemaOnce
2280
+ module.exports.emitRunOutputs = emitRunOutputs