@jay-framework/jay-stack-cli 0.9.0 → 0.11.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/dist/index.js CHANGED
@@ -4,16 +4,22 @@ import { mkDevServer } from "@jay-framework/dev-server";
4
4
  import { createEditorServer } from "@jay-framework/editor-server";
5
5
  import getPort from "get-port";
6
6
  import path from "path";
7
- import fs from "fs";
7
+ import fs, { promises } from "fs";
8
8
  import YAML from "yaml";
9
- import { parseJayFile, JAY_IMPORT_RESOLVER, generateElementDefinitionFile } from "@jay-framework/compiler-jay-html";
10
- import { JAY_CONTRACT_EXTENSION, JAY_EXTENSION } from "@jay-framework/compiler-shared";
9
+ import { parse } from "node-html-parser";
10
+ import { createRequire } from "module";
11
+ import { parseJayFile, JAY_IMPORT_RESOLVER, generateElementDefinitionFile, parseContract, ContractTagType, generateElementFile } from "@jay-framework/compiler-jay-html";
12
+ import { JAY_CONTRACT_EXTENSION, JAY_EXTENSION, JayAtomicType, JayEnumType, loadPluginManifest, RuntimeMode, GenerateTarget } from "@jay-framework/compiler-shared";
13
+ import { Command } from "commander";
14
+ import chalk from "chalk";
15
+ import { glob } from "glob";
11
16
  const DEFAULT_CONFIG = {
12
17
  devServer: {
13
18
  portRange: [3e3, 3100],
14
19
  pagesBase: "./src/pages",
15
20
  componentsBase: "./src/components",
16
- publicFolder: "./public"
21
+ publicFolder: "./public",
22
+ configBase: "./config"
17
23
  },
18
24
  editorServer: {
19
25
  portRange: [3101, 3200]
@@ -42,17 +48,18 @@ function loadConfig() {
42
48
  return DEFAULT_CONFIG;
43
49
  }
44
50
  }
45
- function getConfigWithDefaults(config2) {
51
+ function getConfigWithDefaults(config) {
46
52
  return {
47
53
  devServer: {
48
- portRange: config2.devServer?.portRange || DEFAULT_CONFIG.devServer.portRange,
49
- pagesBase: config2.devServer?.pagesBase || DEFAULT_CONFIG.devServer.pagesBase,
50
- componentsBase: config2.devServer?.componentsBase || DEFAULT_CONFIG.devServer.componentsBase,
51
- publicFolder: config2.devServer?.publicFolder || DEFAULT_CONFIG.devServer.publicFolder
54
+ portRange: config.devServer?.portRange || DEFAULT_CONFIG.devServer.portRange,
55
+ pagesBase: config.devServer?.pagesBase || DEFAULT_CONFIG.devServer.pagesBase,
56
+ componentsBase: config.devServer?.componentsBase || DEFAULT_CONFIG.devServer.componentsBase,
57
+ publicFolder: config.devServer?.publicFolder || DEFAULT_CONFIG.devServer.publicFolder,
58
+ configBase: config.devServer?.configBase || DEFAULT_CONFIG.devServer.configBase
52
59
  },
53
60
  editorServer: {
54
- portRange: config2.editorServer?.portRange || DEFAULT_CONFIG.editorServer.portRange,
55
- editorId: config2.editorServer?.editorId
61
+ portRange: config.editorServer?.portRange || DEFAULT_CONFIG.editorServer.portRange,
62
+ editorId: config.editorServer?.editorId
56
63
  }
57
64
  };
58
65
  }
@@ -79,9 +86,643 @@ function updateConfig(updates) {
79
86
  }
80
87
  }
81
88
  const PAGE_FILENAME = `page${JAY_EXTENSION}`;
82
- async function handlePagePublish(resolvedConfig2, page) {
89
+ const PAGE_CONTRACT_FILENAME = `page${JAY_CONTRACT_EXTENSION}`;
90
+ const PAGE_CONFIG_FILENAME = "page.conf.yaml";
91
+ function jayTypeToString(jayType) {
92
+ if (!jayType)
93
+ return void 0;
94
+ if (jayType instanceof JayAtomicType) {
95
+ return jayType.name;
96
+ } else if (jayType instanceof JayEnumType) {
97
+ return `enum (${jayType.values.join(" | ")})`;
98
+ } else {
99
+ return jayType.name || "unknown";
100
+ }
101
+ }
102
+ function convertContractTagToProtocol(tag) {
103
+ const protocolTag = {
104
+ tag: tag.tag,
105
+ type: tag.type.length === 1 ? ContractTagType[tag.type[0]] : tag.type.map((t) => ContractTagType[t])
106
+ };
107
+ if (tag.dataType) {
108
+ protocolTag.dataType = jayTypeToString(tag.dataType);
109
+ }
110
+ if (tag.elementType) {
111
+ protocolTag.elementType = tag.elementType.join(" | ");
112
+ }
113
+ if (tag.required !== void 0) {
114
+ protocolTag.required = tag.required;
115
+ }
116
+ if (tag.repeated !== void 0) {
117
+ protocolTag.repeated = tag.repeated;
118
+ }
119
+ if (tag.link) {
120
+ protocolTag.link = tag.link;
121
+ }
122
+ if (tag.tags) {
123
+ protocolTag.tags = tag.tags.map(convertContractTagToProtocol);
124
+ }
125
+ return protocolTag;
126
+ }
127
+ function isPageDirectory(entries) {
128
+ const hasPageHtml = entries.some((e) => e.name === PAGE_FILENAME);
129
+ const hasPageContract = entries.some((e) => e.name === PAGE_CONTRACT_FILENAME);
130
+ const hasPageConfig = entries.some((e) => e.name === PAGE_CONFIG_FILENAME);
131
+ const isPage = hasPageHtml || hasPageContract || hasPageConfig;
132
+ return { isPage, hasPageHtml, hasPageContract, hasPageConfig };
133
+ }
134
+ async function scanPageDirectories(pagesBasePath, onPageFound) {
135
+ async function scanDirectory(dirPath, urlPath = "") {
136
+ try {
137
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
138
+ const { isPage, hasPageHtml, hasPageContract, hasPageConfig } = isPageDirectory(entries);
139
+ if (isPage) {
140
+ const pageUrl = urlPath || "/";
141
+ const pageName = dirPath === pagesBasePath ? "Home" : path.basename(dirPath);
142
+ await onPageFound({
143
+ dirPath,
144
+ pageUrl,
145
+ pageName,
146
+ hasPageHtml,
147
+ hasPageContract,
148
+ hasPageConfig
149
+ });
150
+ }
151
+ for (const entry of entries) {
152
+ const fullPath = path.join(dirPath, entry.name);
153
+ if (entry.isDirectory()) {
154
+ const isParam = entry.name.startsWith("[") && entry.name.endsWith("]");
155
+ const segmentUrl = isParam ? `:${entry.name.slice(1, -1)}` : entry.name;
156
+ const newUrlPath = urlPath + "/" + segmentUrl;
157
+ await scanDirectory(fullPath, newUrlPath);
158
+ }
159
+ }
160
+ } catch (error) {
161
+ console.warn(`Failed to scan directory ${dirPath}:`, error);
162
+ }
163
+ }
164
+ await scanDirectory(pagesBasePath);
165
+ }
166
+ async function parseContractFile(contractFilePath) {
167
+ try {
168
+ const contractYaml = await fs.promises.readFile(contractFilePath, "utf-8");
169
+ const parsedContract = parseContract(contractYaml, contractFilePath);
170
+ if (parsedContract.validations.length > 0) {
171
+ console.warn(
172
+ `Contract validation errors in ${contractFilePath}:`,
173
+ parsedContract.validations
174
+ );
175
+ }
176
+ if (parsedContract.val) {
177
+ const resolvedTags = await resolveLinkedTags(
178
+ parsedContract.val.tags,
179
+ path.dirname(contractFilePath)
180
+ );
181
+ return {
182
+ name: parsedContract.val.name,
183
+ tags: resolvedTags
184
+ };
185
+ }
186
+ } catch (error) {
187
+ console.warn(`Failed to parse contract file ${contractFilePath}:`, error);
188
+ }
189
+ return null;
190
+ }
191
+ async function resolveLinkedTags(tags, baseDir) {
192
+ const resolvedTags = [];
193
+ for (const tag of tags) {
194
+ if (tag.link) {
195
+ try {
196
+ const linkedPath = path.resolve(baseDir, tag.link);
197
+ const linkedContract = await parseContractFile(linkedPath);
198
+ if (linkedContract) {
199
+ const resolvedTag = {
200
+ tag: tag.tag,
201
+ type: tag.type.length === 1 ? ContractTagType[tag.type[0]] : tag.type.map((t) => ContractTagType[t]),
202
+ tags: linkedContract.tags
203
+ // Use tags from linked contract
204
+ };
205
+ if (tag.required !== void 0) {
206
+ resolvedTag.required = tag.required;
207
+ }
208
+ if (tag.repeated !== void 0) {
209
+ resolvedTag.repeated = tag.repeated;
210
+ }
211
+ resolvedTags.push(resolvedTag);
212
+ } else {
213
+ console.warn(`Failed to load linked contract: ${tag.link} from ${baseDir}`);
214
+ resolvedTags.push(convertContractTagToProtocol(tag));
215
+ }
216
+ } catch (error) {
217
+ console.warn(`Error resolving linked contract ${tag.link}:`, error);
218
+ resolvedTags.push(convertContractTagToProtocol(tag));
219
+ }
220
+ } else if (tag.tags) {
221
+ const resolvedSubTags = await resolveLinkedTags(tag.tags, baseDir);
222
+ const protocolTag = convertContractTagToProtocol(tag);
223
+ protocolTag.tags = resolvedSubTags;
224
+ resolvedTags.push(protocolTag);
225
+ } else {
226
+ resolvedTags.push(convertContractTagToProtocol(tag));
227
+ }
228
+ }
229
+ return resolvedTags;
230
+ }
231
+ function resolveAppContractPath(appModule, contractFileName, projectRootPath) {
83
232
  try {
84
- const pagesBasePath = path.resolve(resolvedConfig2.devServer.pagesBase);
233
+ const require2 = createRequire(path.join(projectRootPath, "package.json"));
234
+ const modulePath = `${appModule}/${contractFileName}`;
235
+ const resolvedPath = require2.resolve(modulePath);
236
+ return resolvedPath;
237
+ } catch (error) {
238
+ console.warn(
239
+ `Failed to resolve contract: ${appModule}/${contractFileName}`,
240
+ error instanceof Error ? error.message : error
241
+ );
242
+ return null;
243
+ }
244
+ }
245
+ async function scanInstalledAppContracts(configBasePath, projectRootPath) {
246
+ const installedAppContracts = {};
247
+ const installedAppsPath = path.join(configBasePath, "installedApps");
248
+ try {
249
+ if (!fs.existsSync(installedAppsPath)) {
250
+ return installedAppContracts;
251
+ }
252
+ const appDirs = await fs.promises.readdir(installedAppsPath, { withFileTypes: true });
253
+ for (const appDir of appDirs) {
254
+ if (appDir.isDirectory()) {
255
+ const appConfigPath = path.join(installedAppsPath, appDir.name, "app.conf.yaml");
256
+ try {
257
+ if (fs.existsSync(appConfigPath)) {
258
+ const configContent = await fs.promises.readFile(appConfigPath, "utf-8");
259
+ const appConfig = YAML.parse(configContent);
260
+ const appName = appConfig.name || appDir.name;
261
+ const appModule = appConfig.module || appDir.name;
262
+ const appContracts = {
263
+ appName,
264
+ module: appModule,
265
+ pages: [],
266
+ components: []
267
+ };
268
+ if (appConfig.pages && Array.isArray(appConfig.pages)) {
269
+ for (const page of appConfig.pages) {
270
+ if (page.headless_components && Array.isArray(page.headless_components)) {
271
+ for (const component of page.headless_components) {
272
+ if (component.contract) {
273
+ const contractPath = resolveAppContractPath(
274
+ appModule,
275
+ component.contract,
276
+ projectRootPath
277
+ );
278
+ if (contractPath) {
279
+ const contractSchema = await parseContractFile(contractPath);
280
+ if (contractSchema) {
281
+ appContracts.pages.push({
282
+ pageName: page.name,
283
+ contractSchema
284
+ });
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ if (appConfig.components && Array.isArray(appConfig.components)) {
293
+ for (const component of appConfig.components) {
294
+ if (component.headless_components && Array.isArray(component.headless_components)) {
295
+ for (const headlessComp of component.headless_components) {
296
+ if (headlessComp.contract) {
297
+ const contractPath = resolveAppContractPath(
298
+ appModule,
299
+ headlessComp.contract,
300
+ projectRootPath
301
+ );
302
+ if (contractPath) {
303
+ const contractSchema = await parseContractFile(contractPath);
304
+ if (contractSchema) {
305
+ appContracts.components.push({
306
+ componentName: component.name,
307
+ contractSchema
308
+ });
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+ }
316
+ installedAppContracts[appName] = appContracts;
317
+ }
318
+ } catch (error) {
319
+ console.warn(`Failed to parse app config ${appConfigPath}:`, error);
320
+ }
321
+ }
322
+ }
323
+ } catch (error) {
324
+ console.warn(`Failed to scan installed apps directory ${installedAppsPath}:`, error);
325
+ }
326
+ return installedAppContracts;
327
+ }
328
+ function extractHeadlessComponents(jayHtmlContent, installedApps, installedAppContracts) {
329
+ const root = parse(jayHtmlContent);
330
+ const headlessScripts = root.querySelectorAll('script[type="application/jay-headless"]');
331
+ const resolvedComponents = [];
332
+ for (const script of headlessScripts) {
333
+ const src = script.getAttribute("src") || "";
334
+ const name = script.getAttribute("name") || "";
335
+ const key = script.getAttribute("key") || "";
336
+ let resolved = false;
337
+ for (const app of installedApps) {
338
+ if (app.module !== src && app.name !== src) {
339
+ continue;
340
+ }
341
+ for (const appPage of app.pages) {
342
+ for (const headlessComp of appPage.headless_components) {
343
+ if (headlessComp.name === name && headlessComp.key === key) {
344
+ const appContracts = installedAppContracts[app.name];
345
+ if (appContracts) {
346
+ const matchingPageContract = appContracts.pages.find(
347
+ (pc) => pc.pageName === appPage.name
348
+ );
349
+ if (matchingPageContract) {
350
+ resolvedComponents.push({
351
+ appName: app.name,
352
+ componentName: appPage.name,
353
+ key
354
+ });
355
+ resolved = true;
356
+ break;
357
+ }
358
+ }
359
+ }
360
+ }
361
+ if (resolved)
362
+ break;
363
+ }
364
+ if (resolved)
365
+ break;
366
+ for (const appComponent of app.components) {
367
+ for (const headlessComp of appComponent.headless_components) {
368
+ if (headlessComp.name === name && headlessComp.key === key) {
369
+ const appContracts = installedAppContracts[app.name];
370
+ if (appContracts) {
371
+ const matchingComponentContract = appContracts.components.find(
372
+ (cc) => cc.componentName === appComponent.name
373
+ );
374
+ if (matchingComponentContract) {
375
+ resolvedComponents.push({
376
+ appName: app.name,
377
+ componentName: appComponent.name,
378
+ key
379
+ });
380
+ resolved = true;
381
+ break;
382
+ }
383
+ }
384
+ }
385
+ }
386
+ if (resolved)
387
+ break;
388
+ }
389
+ if (resolved)
390
+ break;
391
+ }
392
+ if (!resolved) {
393
+ resolvedComponents.push({
394
+ appName: src,
395
+ componentName: name,
396
+ key
397
+ });
398
+ }
399
+ }
400
+ return resolvedComponents;
401
+ }
402
+ async function scanProjectComponents(componentsBasePath) {
403
+ const components = [];
404
+ try {
405
+ const entries = await fs.promises.readdir(componentsBasePath, { withFileTypes: true });
406
+ for (const entry of entries) {
407
+ if (entry.isFile() && entry.name.endsWith(JAY_EXTENSION)) {
408
+ const componentName = path.basename(entry.name, JAY_EXTENSION);
409
+ const componentPath = path.join(componentsBasePath, entry.name);
410
+ const contractPath = path.join(
411
+ componentsBasePath,
412
+ `${componentName}${JAY_CONTRACT_EXTENSION}`
413
+ );
414
+ const hasContract = fs.existsSync(contractPath);
415
+ components.push({
416
+ name: componentName,
417
+ filePath: componentPath,
418
+ contractPath: hasContract ? contractPath : void 0
419
+ });
420
+ }
421
+ }
422
+ } catch (error) {
423
+ console.warn(`Failed to scan components directory ${componentsBasePath}:`, error);
424
+ }
425
+ return components;
426
+ }
427
+ async function scanInstalledApps(configBasePath) {
428
+ const installedApps = [];
429
+ const installedAppsPath = path.join(configBasePath, "installedApps");
430
+ try {
431
+ if (!fs.existsSync(installedAppsPath)) {
432
+ return installedApps;
433
+ }
434
+ const appDirs = await fs.promises.readdir(installedAppsPath, { withFileTypes: true });
435
+ for (const appDir of appDirs) {
436
+ if (appDir.isDirectory()) {
437
+ const appConfigPath = path.join(installedAppsPath, appDir.name, "app.conf.yaml");
438
+ try {
439
+ if (fs.existsSync(appConfigPath)) {
440
+ const configContent = await fs.promises.readFile(appConfigPath, "utf-8");
441
+ const appConfig = YAML.parse(configContent);
442
+ installedApps.push({
443
+ name: appConfig.name || appDir.name,
444
+ module: appConfig.module || appDir.name,
445
+ pages: appConfig.pages || [],
446
+ components: appConfig.components || [],
447
+ config_map: appConfig.config_map || []
448
+ });
449
+ }
450
+ } catch (error) {
451
+ console.warn(`Failed to parse app config ${appConfigPath}:`, error);
452
+ }
453
+ }
454
+ }
455
+ } catch (error) {
456
+ console.warn(`Failed to scan installed apps directory ${installedAppsPath}:`, error);
457
+ }
458
+ return installedApps;
459
+ }
460
+ async function getProjectName(configBasePath) {
461
+ const projectConfigPath = path.join(configBasePath, "project.conf.yaml");
462
+ try {
463
+ if (fs.existsSync(projectConfigPath)) {
464
+ const configContent = await fs.promises.readFile(projectConfigPath, "utf-8");
465
+ const projectConfig = YAML.parse(configContent);
466
+ return projectConfig.name || "Unnamed Project";
467
+ }
468
+ } catch (error) {
469
+ console.warn(`Failed to read project config ${projectConfigPath}:`, error);
470
+ }
471
+ return "Unnamed Project";
472
+ }
473
+ async function scanPlugins(projectRootPath) {
474
+ const plugins = [];
475
+ const localPluginsPath = path.join(projectRootPath, "src/plugins");
476
+ if (fs.existsSync(localPluginsPath)) {
477
+ try {
478
+ const pluginDirs = await fs.promises.readdir(localPluginsPath, { withFileTypes: true });
479
+ for (const dir of pluginDirs) {
480
+ if (!dir.isDirectory())
481
+ continue;
482
+ const pluginPath = path.join(localPluginsPath, dir.name);
483
+ const pluginYamlPath = path.join(pluginPath, "plugin.yaml");
484
+ if (fs.existsSync(pluginYamlPath)) {
485
+ try {
486
+ const yamlContent = await fs.promises.readFile(pluginYamlPath, "utf-8");
487
+ const manifest = YAML.parse(yamlContent);
488
+ plugins.push({
489
+ manifest,
490
+ location: {
491
+ type: "local",
492
+ path: pluginPath
493
+ }
494
+ });
495
+ } catch (error) {
496
+ console.warn(`Failed to parse plugin.yaml for ${dir.name}:`, error);
497
+ }
498
+ }
499
+ }
500
+ } catch (error) {
501
+ console.warn(`Failed to scan local plugins directory ${localPluginsPath}:`, error);
502
+ }
503
+ }
504
+ const nodeModulesPath = path.join(projectRootPath, "node_modules");
505
+ if (fs.existsSync(nodeModulesPath)) {
506
+ try {
507
+ const topLevelDirs = await fs.promises.readdir(nodeModulesPath, {
508
+ withFileTypes: true
509
+ });
510
+ for (const entry of topLevelDirs) {
511
+ if (!entry.isDirectory())
512
+ continue;
513
+ const packageDirs = [];
514
+ if (entry.name.startsWith("@")) {
515
+ const scopePath = path.join(nodeModulesPath, entry.name);
516
+ const scopedPackages = await fs.promises.readdir(scopePath, {
517
+ withFileTypes: true
518
+ });
519
+ for (const scopedPkg of scopedPackages) {
520
+ if (scopedPkg.isDirectory()) {
521
+ packageDirs.push(path.join(scopePath, scopedPkg.name));
522
+ }
523
+ }
524
+ } else {
525
+ packageDirs.push(path.join(nodeModulesPath, entry.name));
526
+ }
527
+ for (const pkgPath of packageDirs) {
528
+ const pluginYamlPath = path.join(pkgPath, "plugin.yaml");
529
+ if (fs.existsSync(pluginYamlPath)) {
530
+ try {
531
+ const yamlContent = await fs.promises.readFile(pluginYamlPath, "utf-8");
532
+ const manifest = YAML.parse(yamlContent);
533
+ const packageJsonPath = path.join(pkgPath, "package.json");
534
+ let moduleName = manifest.module;
535
+ if (fs.existsSync(packageJsonPath)) {
536
+ const packageJson = JSON.parse(
537
+ await fs.promises.readFile(packageJsonPath, "utf-8")
538
+ );
539
+ moduleName = packageJson.name;
540
+ }
541
+ plugins.push({
542
+ manifest: {
543
+ ...manifest,
544
+ module: moduleName
545
+ },
546
+ location: {
547
+ type: "npm",
548
+ module: moduleName || manifest.name
549
+ }
550
+ });
551
+ } catch (error) {
552
+ console.warn(
553
+ `Failed to parse plugin.yaml for package ${pkgPath}:`,
554
+ error
555
+ );
556
+ }
557
+ }
558
+ }
559
+ }
560
+ } catch (error) {
561
+ console.warn(`Failed to scan node_modules for plugins:`, error);
562
+ }
563
+ }
564
+ return plugins;
565
+ }
566
+ async function scanProjectInfo(pagesBasePath, componentsBasePath, configBasePath, projectRootPath) {
567
+ const [projectName, components, installedApps, plugins] = await Promise.all([
568
+ getProjectName(configBasePath),
569
+ scanProjectComponents(componentsBasePath),
570
+ scanInstalledApps(configBasePath),
571
+ scanPlugins(projectRootPath)
572
+ ]);
573
+ const installedAppContracts = await scanInstalledAppContracts(configBasePath, projectRootPath);
574
+ const pages = [];
575
+ await scanPageDirectories(pagesBasePath, async (context) => {
576
+ const { dirPath, pageUrl, pageName, hasPageHtml, hasPageContract, hasPageConfig } = context;
577
+ const pageFilePath = path.join(dirPath, PAGE_FILENAME);
578
+ const pageConfigPath = path.join(dirPath, PAGE_CONFIG_FILENAME);
579
+ const contractPath = path.join(dirPath, PAGE_CONTRACT_FILENAME);
580
+ let usedComponents = [];
581
+ let contractSchema;
582
+ if (hasPageContract) {
583
+ try {
584
+ const parsedContract = await parseContractFile(contractPath);
585
+ if (parsedContract) {
586
+ contractSchema = parsedContract;
587
+ }
588
+ } catch (error) {
589
+ console.warn(`Failed to parse contract file ${contractPath}:`, error);
590
+ }
591
+ }
592
+ if (hasPageHtml) {
593
+ try {
594
+ const jayHtmlContent = await fs.promises.readFile(pageFilePath, "utf-8");
595
+ usedComponents = extractHeadlessComponents(
596
+ jayHtmlContent,
597
+ installedApps,
598
+ installedAppContracts
599
+ );
600
+ } catch (error) {
601
+ console.warn(`Failed to read page file ${pageFilePath}:`, error);
602
+ }
603
+ } else if (hasPageConfig) {
604
+ try {
605
+ const configContent = await fs.promises.readFile(pageConfigPath, "utf-8");
606
+ const pageConfig = YAML.parse(configContent);
607
+ if (pageConfig.used_components && Array.isArray(pageConfig.used_components)) {
608
+ for (const comp of pageConfig.used_components) {
609
+ const key = comp.key || "";
610
+ let src = "";
611
+ let name = "";
612
+ if (comp.plugin && comp.contract) {
613
+ const plugin = plugins.find((p) => p.manifest.name === comp.plugin);
614
+ if (plugin && plugin.manifest.contracts) {
615
+ const contract = plugin.manifest.contracts.find(
616
+ (c) => c.name === comp.contract
617
+ );
618
+ if (contract) {
619
+ usedComponents.push({
620
+ appName: comp.plugin,
621
+ componentName: comp.contract,
622
+ key
623
+ });
624
+ continue;
625
+ }
626
+ }
627
+ usedComponents.push({
628
+ appName: comp.plugin,
629
+ componentName: comp.contract,
630
+ key
631
+ });
632
+ continue;
633
+ }
634
+ src = comp.src || "";
635
+ name = comp.name || "";
636
+ let resolved = false;
637
+ for (const app of installedApps) {
638
+ if (app.module !== src && app.name !== src) {
639
+ continue;
640
+ }
641
+ for (const appPage of app.pages) {
642
+ for (const headlessComp of appPage.headless_components) {
643
+ if (headlessComp.name === name && headlessComp.key === key) {
644
+ const appContracts = installedAppContracts[app.name];
645
+ if (appContracts) {
646
+ const matchingPageContract = appContracts.pages.find(
647
+ (pc) => pc.pageName === appPage.name
648
+ );
649
+ if (matchingPageContract) {
650
+ usedComponents.push({
651
+ appName: app.name,
652
+ componentName: appPage.name,
653
+ key
654
+ });
655
+ resolved = true;
656
+ break;
657
+ }
658
+ }
659
+ }
660
+ }
661
+ if (resolved)
662
+ break;
663
+ }
664
+ if (resolved)
665
+ break;
666
+ for (const appComponent of app.components) {
667
+ for (const headlessComp of appComponent.headless_components) {
668
+ if (headlessComp.name === name && headlessComp.key === key) {
669
+ const appContracts = installedAppContracts[app.name];
670
+ if (appContracts) {
671
+ const matchingComponentContract = appContracts.components.find(
672
+ (cc) => cc.componentName === appComponent.name
673
+ );
674
+ if (matchingComponentContract) {
675
+ usedComponents.push({
676
+ appName: app.name,
677
+ componentName: appComponent.name,
678
+ key
679
+ });
680
+ resolved = true;
681
+ break;
682
+ }
683
+ }
684
+ }
685
+ }
686
+ if (resolved)
687
+ break;
688
+ }
689
+ if (resolved)
690
+ break;
691
+ }
692
+ if (!resolved) {
693
+ usedComponents.push({
694
+ appName: src,
695
+ componentName: name,
696
+ key
697
+ });
698
+ }
699
+ }
700
+ }
701
+ } catch (error) {
702
+ console.warn(`Failed to parse page config ${pageConfigPath}:`, error);
703
+ }
704
+ }
705
+ pages.push({
706
+ name: pageName,
707
+ url: pageUrl,
708
+ filePath: pageFilePath,
709
+ contractSchema,
710
+ usedComponents
711
+ });
712
+ });
713
+ return {
714
+ name: projectName,
715
+ localPath: projectRootPath,
716
+ pages,
717
+ components,
718
+ installedApps,
719
+ installedAppContracts,
720
+ plugins
721
+ };
722
+ }
723
+ async function handlePagePublish(resolvedConfig, page) {
724
+ try {
725
+ const pagesBasePath = path.resolve(resolvedConfig.devServer.pagesBase);
85
726
  const routePath = page.route === "/" ? "" : page.route;
86
727
  const dirname = path.join(pagesBasePath, routePath);
87
728
  const fullPath = path.join(dirname, PAGE_FILENAME);
@@ -119,9 +760,9 @@ async function handlePagePublish(resolvedConfig2, page) {
119
760
  ];
120
761
  }
121
762
  }
122
- async function handleComponentPublish(resolvedConfig2, component) {
763
+ async function handleComponentPublish(resolvedConfig, component) {
123
764
  try {
124
- const dirname = path.resolve(resolvedConfig2.devServer.componentsBase);
765
+ const dirname = path.resolve(resolvedConfig.devServer.componentsBase);
125
766
  const filename = `${component.name}${JAY_EXTENSION}`;
126
767
  const fullPath = path.join(dirname, filename);
127
768
  await fs.promises.mkdir(dirname, { recursive: true });
@@ -157,13 +798,13 @@ async function handleComponentPublish(resolvedConfig2, component) {
157
798
  ];
158
799
  }
159
800
  }
160
- function createEditorHandlers(config2, tsConfigPath) {
801
+ function createEditorHandlers(config, tsConfigPath, projectRoot) {
161
802
  const onPublish = async (params) => {
162
803
  const status = [];
163
804
  const createdJayHtmls = [];
164
805
  if (params.pages) {
165
806
  for (const page of params.pages) {
166
- const [pageStatus, createdJayHtml] = await handlePagePublish(config2, page);
807
+ const [pageStatus, createdJayHtml] = await handlePagePublish(config, page);
167
808
  status.push(pageStatus);
168
809
  if (pageStatus.success)
169
810
  createdJayHtmls.push(createdJayHtml);
@@ -172,7 +813,7 @@ function createEditorHandlers(config2, tsConfigPath) {
172
813
  if (params.components) {
173
814
  for (const component of params.components) {
174
815
  const [compStatus, createdJayHtml] = await handleComponentPublish(
175
- config2,
816
+ config,
176
817
  component
177
818
  );
178
819
  status.push(compStatus);
@@ -186,7 +827,8 @@ function createEditorHandlers(config2, tsConfigPath) {
186
827
  filename,
187
828
  dirname,
188
829
  { relativePath: tsConfigPath },
189
- JAY_IMPORT_RESOLVER
830
+ JAY_IMPORT_RESOLVER,
831
+ projectRoot
190
832
  );
191
833
  const definitionFile = generateElementDefinitionFile(parsedJayHtml);
192
834
  if (definitionFile.validations.length > 0)
@@ -204,7 +846,7 @@ function createEditorHandlers(config2, tsConfigPath) {
204
846
  };
205
847
  const onSaveImage = async (params) => {
206
848
  try {
207
- const imagesDir = path.join(path.resolve(config2.devServer.publicFolder), "images");
849
+ const imagesDir = path.join(path.resolve(config.devServer.publicFolder), "images");
208
850
  await fs.promises.mkdir(imagesDir, { recursive: true });
209
851
  const filename = `${params.imageId}.png`;
210
852
  const imagePath = path.join(imagesDir, filename);
@@ -228,7 +870,7 @@ function createEditorHandlers(config2, tsConfigPath) {
228
870
  try {
229
871
  const filename = `${params.imageId}.png`;
230
872
  const imagePath = path.join(
231
- path.resolve(config2.devServer.publicFolder),
873
+ path.resolve(config.devServer.publicFolder),
232
874
  "images",
233
875
  filename
234
876
  );
@@ -249,13 +891,54 @@ function createEditorHandlers(config2, tsConfigPath) {
249
891
  };
250
892
  }
251
893
  };
894
+ const onGetProjectInfo = async (params) => {
895
+ try {
896
+ const pagesBasePath = path.resolve(config.devServer.pagesBase);
897
+ const componentsBasePath = path.resolve(config.devServer.componentsBase);
898
+ const configBasePath = path.resolve(config.devServer.configBase);
899
+ const projectRootPath = process.cwd();
900
+ const info = await scanProjectInfo(
901
+ pagesBasePath,
902
+ componentsBasePath,
903
+ configBasePath,
904
+ projectRootPath
905
+ );
906
+ console.log(`📋 Retrieved project info: ${info.name}`);
907
+ console.log(` Pages: ${info.pages.length}`);
908
+ console.log(` Components: ${info.components.length}`);
909
+ console.log(` Installed Apps: ${info.installedApps.length}`);
910
+ console.log(` App Contracts: ${Object.keys(info.installedAppContracts).length}`);
911
+ return {
912
+ type: "getProjectInfo",
913
+ success: true,
914
+ info
915
+ };
916
+ } catch (error) {
917
+ console.error("Failed to get project info:", error);
918
+ return {
919
+ type: "getProjectInfo",
920
+ success: false,
921
+ error: error instanceof Error ? error.message : "Unknown error",
922
+ info: {
923
+ name: "Error",
924
+ localPath: process.cwd(),
925
+ pages: [],
926
+ components: [],
927
+ installedApps: [],
928
+ installedAppContracts: {},
929
+ plugins: []
930
+ }
931
+ };
932
+ }
933
+ };
252
934
  return {
253
935
  onPublish,
254
936
  onSaveImage,
255
- onHasImage
937
+ onHasImage,
938
+ onGetProjectInfo
256
939
  };
257
940
  }
258
- async function generatePageDefinitionFiles(routes, tsConfigPath) {
941
+ async function generatePageDefinitionFiles(routes, tsConfigPath, projectRoot) {
259
942
  for (const route of routes) {
260
943
  const jayHtmlPath = route.fsRoute.jayHtmlPath;
261
944
  if (!fs.existsSync(jayHtmlPath)) {
@@ -281,7 +964,8 @@ async function generatePageDefinitionFiles(routes, tsConfigPath) {
281
964
  filename,
282
965
  dirname,
283
966
  { relativePath: tsConfigPath },
284
- JAY_IMPORT_RESOLVER
967
+ JAY_IMPORT_RESOLVER,
968
+ projectRoot
285
969
  );
286
970
  const definitionFile = generateElementDefinitionFile(parsedJayHtml);
287
971
  if (definitionFile.validations.length > 0) {
@@ -298,14 +982,18 @@ async function generatePageDefinitionFiles(routes, tsConfigPath) {
298
982
  }
299
983
  }
300
984
  }
301
- const config = loadConfig();
302
- const resolvedConfig = getConfigWithDefaults(config);
303
- const jayOptions = {
304
- tsConfigFilePath: "./tsconfig.json",
305
- outputDir: "build/jay-runtime"
306
- };
307
- const app = express();
308
- async function initApp() {
985
+ async function startDevServer(options = {}) {
986
+ const projectPath = options.projectPath || process.cwd();
987
+ if (projectPath !== process.cwd()) {
988
+ process.chdir(projectPath);
989
+ }
990
+ const config = loadConfig();
991
+ const resolvedConfig = getConfigWithDefaults(config);
992
+ const jayOptions = {
993
+ tsConfigFilePath: "./tsconfig.json",
994
+ outputDir: "build/jay-runtime"
995
+ };
996
+ const app = express();
309
997
  const devServerPort = await getPort({ port: resolvedConfig.devServer.portRange });
310
998
  const editorServer = createEditorServer({
311
999
  portRange: resolvedConfig.editorServer.portRange,
@@ -320,10 +1008,15 @@ async function initApp() {
320
1008
  }
321
1009
  });
322
1010
  const { port: editorPort, editorId } = await editorServer.start();
323
- const handlers = createEditorHandlers(resolvedConfig, jayOptions.tsConfigFilePath);
1011
+ const handlers = createEditorHandlers(
1012
+ resolvedConfig,
1013
+ jayOptions.tsConfigFilePath,
1014
+ process.cwd()
1015
+ );
324
1016
  editorServer.onPublish(handlers.onPublish);
325
1017
  editorServer.onSaveImage(handlers.onSaveImage);
326
1018
  editorServer.onHasImage(handlers.onHasImage);
1019
+ editorServer.onGetProjectInfo(handlers.onGetProjectInfo);
327
1020
  const { server, viteServer, routes } = await mkDevServer({
328
1021
  pagesRootFolder: path.resolve(resolvedConfig.devServer.pagesBase),
329
1022
  projectRootFolder: process.cwd(),
@@ -341,7 +1034,7 @@ async function initApp() {
341
1034
  routes.forEach((route) => {
342
1035
  app.get(route.path, route.handler);
343
1036
  });
344
- generatePageDefinitionFiles(routes, jayOptions.tsConfigFilePath);
1037
+ generatePageDefinitionFiles(routes, jayOptions.tsConfigFilePath, process.cwd());
345
1038
  const expressServer = app.listen(devServerPort, () => {
346
1039
  console.log(`🚀 Jay Stack dev server started successfully!`);
347
1040
  console.log(`📱 Dev Server: http://localhost:${devServerPort}`);
@@ -361,13 +1054,658 @@ async function initApp() {
361
1054
  process.on("SIGTERM", shutdown);
362
1055
  process.on("SIGINT", shutdown);
363
1056
  }
364
- initApp().catch((error) => {
365
- console.error("Failed to start servers:", error);
366
- process.exit(1);
1057
+ async function validatePlugin(options = {}) {
1058
+ const pluginPath = options.pluginPath || process.cwd();
1059
+ if (options.local) {
1060
+ return validateLocalPlugins(pluginPath, options);
1061
+ } else {
1062
+ return validatePluginPackage(pluginPath, options);
1063
+ }
1064
+ }
1065
+ async function validatePluginPackage(pluginPath, options) {
1066
+ const result = {
1067
+ valid: true,
1068
+ errors: [],
1069
+ warnings: [],
1070
+ contractsChecked: 0,
1071
+ componentsChecked: 0
1072
+ };
1073
+ const pluginYamlPath = path.join(pluginPath, "plugin.yaml");
1074
+ const pluginManifest = loadPluginManifest(pluginPath);
1075
+ if (!pluginManifest) {
1076
+ if (!fs.existsSync(pluginYamlPath)) {
1077
+ result.errors.push({
1078
+ type: "file-missing",
1079
+ message: "plugin.yaml not found",
1080
+ location: pluginPath,
1081
+ suggestion: "Create a plugin.yaml file in the plugin root directory"
1082
+ });
1083
+ } else {
1084
+ result.errors.push({
1085
+ type: "schema",
1086
+ message: "Invalid YAML syntax or format",
1087
+ location: pluginYamlPath
1088
+ });
1089
+ }
1090
+ result.valid = false;
1091
+ return result;
1092
+ }
1093
+ result.pluginName = pluginManifest.name;
1094
+ const context = {
1095
+ manifest: pluginManifest,
1096
+ pluginPath,
1097
+ isNpmPackage: fs.existsSync(path.join(pluginPath, "package.json"))
1098
+ };
1099
+ await validateSchema(context, result);
1100
+ if (pluginManifest.contracts) {
1101
+ for (let i = 0; i < pluginManifest.contracts.length; i++) {
1102
+ await validateContract(
1103
+ pluginManifest.contracts[i],
1104
+ i,
1105
+ context,
1106
+ options.generateTypes || false,
1107
+ result
1108
+ );
1109
+ }
1110
+ }
1111
+ if (pluginManifest.contracts) {
1112
+ for (let i = 0; i < pluginManifest.contracts.length; i++) {
1113
+ await validateComponent(pluginManifest.contracts[i], i, context, result);
1114
+ }
1115
+ }
1116
+ if (context.isNpmPackage) {
1117
+ await validatePackageJson(context, result);
1118
+ result.packageJsonChecked = true;
1119
+ }
1120
+ if (pluginManifest.dynamic_contracts) {
1121
+ await validateDynamicContracts(context, result);
1122
+ }
1123
+ result.valid = result.errors.length === 0;
1124
+ return result;
1125
+ }
1126
+ async function validateLocalPlugins(projectPath, options) {
1127
+ const pluginsPath = path.join(projectPath, "src/plugins");
1128
+ if (!fs.existsSync(pluginsPath)) {
1129
+ return {
1130
+ valid: false,
1131
+ errors: [
1132
+ {
1133
+ type: "file-missing",
1134
+ message: "src/plugins/ directory not found",
1135
+ location: projectPath,
1136
+ suggestion: "Create src/plugins/ directory for local plugins"
1137
+ }
1138
+ ],
1139
+ warnings: []
1140
+ };
1141
+ }
1142
+ const pluginDirs = fs.readdirSync(pluginsPath, { withFileTypes: true }).filter((d) => d.isDirectory());
1143
+ const allResults = [];
1144
+ for (const pluginDir of pluginDirs) {
1145
+ const pluginPath = path.join(pluginsPath, pluginDir.name);
1146
+ const result = await validatePluginPackage(pluginPath, options);
1147
+ allResults.push(result);
1148
+ }
1149
+ return {
1150
+ valid: allResults.every((r) => r.valid),
1151
+ errors: allResults.flatMap((r) => r.errors),
1152
+ warnings: allResults.flatMap((r) => r.warnings),
1153
+ contractsChecked: allResults.reduce((sum, r) => sum + (r.contractsChecked || 0), 0),
1154
+ componentsChecked: allResults.reduce((sum, r) => sum + (r.componentsChecked || 0), 0),
1155
+ typesGenerated: allResults.reduce((sum, r) => sum + (r.typesGenerated || 0), 0)
1156
+ };
1157
+ }
1158
+ async function validateSchema(context, result) {
1159
+ const { manifest } = context;
1160
+ if (!manifest.name) {
1161
+ result.errors.push({
1162
+ type: "schema",
1163
+ message: "Missing required field: name",
1164
+ location: "plugin.yaml",
1165
+ suggestion: 'Add a "name" field with a kebab-case plugin name'
1166
+ });
1167
+ } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(manifest.name)) {
1168
+ result.errors.push({
1169
+ type: "schema",
1170
+ message: `Invalid plugin name: "${manifest.name}". Must be kebab-case.`,
1171
+ location: "plugin.yaml",
1172
+ suggestion: 'Use lowercase letters, numbers, and hyphens only (e.g., "my-plugin")'
1173
+ });
1174
+ }
1175
+ if (manifest.contracts) {
1176
+ if (!Array.isArray(manifest.contracts)) {
1177
+ result.errors.push({
1178
+ type: "schema",
1179
+ message: 'Field "contracts" must be an array',
1180
+ location: "plugin.yaml"
1181
+ });
1182
+ } else {
1183
+ manifest.contracts.forEach((contract, index) => {
1184
+ if (!contract.name) {
1185
+ result.errors.push({
1186
+ type: "schema",
1187
+ message: `Contract at index ${index} is missing "name" field`,
1188
+ location: "plugin.yaml"
1189
+ });
1190
+ }
1191
+ if (!contract.contract) {
1192
+ result.errors.push({
1193
+ type: "schema",
1194
+ message: `Contract "${contract.name || index}" is missing "contract" field`,
1195
+ location: "plugin.yaml",
1196
+ suggestion: "Specify path to .jay-contract file"
1197
+ });
1198
+ }
1199
+ if (!contract.component) {
1200
+ result.errors.push({
1201
+ type: "schema",
1202
+ message: `Contract "${contract.name || index}" is missing "component" field`,
1203
+ location: "plugin.yaml",
1204
+ suggestion: 'Specify the exported member name from the module (e.g., "moodTracker")'
1205
+ });
1206
+ }
1207
+ });
1208
+ }
1209
+ }
1210
+ if (manifest.dynamic_contracts) {
1211
+ if (!manifest.dynamic_contracts.component) {
1212
+ result.errors.push({
1213
+ type: "schema",
1214
+ message: 'dynamic_contracts is missing "component" field',
1215
+ location: "plugin.yaml",
1216
+ suggestion: "Specify path to shared component for dynamic contracts"
1217
+ });
1218
+ }
1219
+ if (!manifest.dynamic_contracts.generator) {
1220
+ result.errors.push({
1221
+ type: "schema",
1222
+ message: 'dynamic_contracts is missing "generator" field',
1223
+ location: "plugin.yaml",
1224
+ suggestion: "Specify path to generator file"
1225
+ });
1226
+ }
1227
+ if (!manifest.dynamic_contracts.prefix) {
1228
+ result.errors.push({
1229
+ type: "schema",
1230
+ message: 'dynamic_contracts is missing "prefix" field',
1231
+ location: "plugin.yaml",
1232
+ suggestion: 'Specify prefix for dynamic contract names (e.g., "cms")'
1233
+ });
1234
+ }
1235
+ }
1236
+ if (!manifest.contracts && !manifest.dynamic_contracts) {
1237
+ result.warnings.push({
1238
+ type: "schema",
1239
+ message: "Plugin has no contracts or dynamic_contracts defined",
1240
+ location: "plugin.yaml",
1241
+ suggestion: 'Add either "contracts" or "dynamic_contracts" to expose functionality'
1242
+ });
1243
+ }
1244
+ }
1245
+ async function validateContract(contract, index, context, generateTypes, result) {
1246
+ result.contractsChecked = (result.contractsChecked || 0) + 1;
1247
+ let contractPath;
1248
+ if (context.isNpmPackage) {
1249
+ const contractSpec = contract.contract;
1250
+ const possiblePaths = [
1251
+ path.join(context.pluginPath, "dist", contractSpec),
1252
+ path.join(context.pluginPath, "lib", contractSpec),
1253
+ path.join(context.pluginPath, contractSpec)
1254
+ ];
1255
+ let found = false;
1256
+ for (const possiblePath of possiblePaths) {
1257
+ if (fs.existsSync(possiblePath)) {
1258
+ contractPath = possiblePath;
1259
+ found = true;
1260
+ break;
1261
+ }
1262
+ }
1263
+ if (!found) {
1264
+ result.errors.push({
1265
+ type: "file-missing",
1266
+ message: `Contract file not found: ${contractSpec}`,
1267
+ location: `plugin.yaml contracts[${index}]`,
1268
+ suggestion: `Ensure the contract file exists (looked in dist/, lib/, and root)`
1269
+ });
1270
+ return;
1271
+ }
1272
+ } else {
1273
+ contractPath = path.join(context.pluginPath, contract.contract);
1274
+ if (!fs.existsSync(contractPath)) {
1275
+ result.errors.push({
1276
+ type: "file-missing",
1277
+ message: `Contract file not found: ${contract.contract}`,
1278
+ location: `plugin.yaml contracts[${index}]`,
1279
+ suggestion: `Create the contract file at ${contractPath}`
1280
+ });
1281
+ return;
1282
+ }
1283
+ }
1284
+ try {
1285
+ const contractContent = await fs.promises.readFile(contractPath, "utf-8");
1286
+ const parsedContract = YAML.parse(contractContent);
1287
+ if (!parsedContract.name) {
1288
+ result.errors.push({
1289
+ type: "contract-invalid",
1290
+ message: `Contract file ${contract.contract} is missing "name" field`,
1291
+ location: contractPath
1292
+ });
1293
+ }
1294
+ if (!parsedContract.tags || !Array.isArray(parsedContract.tags)) {
1295
+ result.errors.push({
1296
+ type: "contract-invalid",
1297
+ message: `Contract file ${contract.contract} is missing "tags" array`,
1298
+ location: contractPath
1299
+ });
1300
+ }
1301
+ } catch (error) {
1302
+ result.errors.push({
1303
+ type: "contract-invalid",
1304
+ message: `Invalid contract YAML: ${error.message}`,
1305
+ location: contractPath,
1306
+ suggestion: "Check YAML syntax and ensure it follows Jay contract format"
1307
+ });
1308
+ return;
1309
+ }
1310
+ if (generateTypes) {
1311
+ try {
1312
+ const { compileContractFile } = await import("@jay-framework/compiler-jay-html");
1313
+ const dtsPath = contractPath + ".d.ts";
1314
+ await compileContractFile(contractPath, dtsPath);
1315
+ result.typesGenerated = (result.typesGenerated || 0) + 1;
1316
+ } catch (error) {
1317
+ result.errors.push({
1318
+ type: "type-generation-failed",
1319
+ message: `Failed to generate types for ${contract.contract}: ${error.message}`,
1320
+ location: contractPath
1321
+ });
1322
+ }
1323
+ }
1324
+ }
1325
+ async function validateComponent(contract, index, context, result) {
1326
+ result.componentsChecked = (result.componentsChecked || 0) + 1;
1327
+ if (typeof contract.component !== "string" || contract.component.length === 0) {
1328
+ result.errors.push({
1329
+ type: "schema",
1330
+ message: `Invalid component name: ${contract.component}`,
1331
+ location: `plugin.yaml contracts[${index}]`,
1332
+ suggestion: 'Component should be the exported member name (e.g., "moodTracker")'
1333
+ });
1334
+ }
1335
+ if (contract.component.includes("/") || contract.component.includes(".")) {
1336
+ result.warnings.push({
1337
+ type: "schema",
1338
+ message: `Component "${contract.component}" looks like a path. Should it be an export name?`,
1339
+ location: `plugin.yaml contracts[${index}]`,
1340
+ suggestion: 'Component should be the exported member name (e.g., "moodTracker"), not a file path'
1341
+ });
1342
+ }
1343
+ }
1344
+ async function validatePackageJson(context, result) {
1345
+ const packageJsonPath = path.join(context.pluginPath, "package.json");
1346
+ if (!fs.existsSync(packageJsonPath)) {
1347
+ result.warnings.push({
1348
+ type: "file-missing",
1349
+ message: "package.json not found",
1350
+ location: context.pluginPath,
1351
+ suggestion: "Create a package.json file for NPM package distribution"
1352
+ });
1353
+ return;
1354
+ }
1355
+ try {
1356
+ const packageJson = JSON.parse(await fs.promises.readFile(packageJsonPath, "utf-8"));
1357
+ if (!packageJson.exports) {
1358
+ result.warnings.push({
1359
+ type: "export-mismatch",
1360
+ message: 'package.json missing "exports" field',
1361
+ location: packageJsonPath,
1362
+ suggestion: "Add exports field to define entry points for server/client builds"
1363
+ });
1364
+ } else {
1365
+ if (!packageJson.exports["."]) {
1366
+ result.warnings.push({
1367
+ type: "export-mismatch",
1368
+ message: 'package.json exports missing "." entry point',
1369
+ location: packageJsonPath,
1370
+ suggestion: 'Add "." export for the main module entry'
1371
+ });
1372
+ }
1373
+ if (context.manifest.contracts) {
1374
+ for (const contract of context.manifest.contracts) {
1375
+ const contractExport = "./" + contract.contract;
1376
+ if (!packageJson.exports[contractExport]) {
1377
+ result.errors.push({
1378
+ type: "export-mismatch",
1379
+ message: `Contract "${contract.name}" not exported in package.json`,
1380
+ location: packageJsonPath,
1381
+ suggestion: `Add "${contractExport}": "./dist/${contract.contract}" to exports field`
1382
+ });
1383
+ }
1384
+ }
1385
+ }
1386
+ if (!packageJson.exports["."]) {
1387
+ result.errors.push({
1388
+ type: "export-mismatch",
1389
+ message: 'NPM package missing "." export in package.json',
1390
+ location: packageJsonPath,
1391
+ suggestion: 'Add ".": "./dist/index.js" (or your main file) to exports field'
1392
+ });
1393
+ }
1394
+ if (context.manifest.module) {
1395
+ const moduleName = context.manifest.module;
1396
+ result.warnings.push({
1397
+ type: "schema",
1398
+ message: 'NPM packages should omit the "module" field - the package main export will be used',
1399
+ location: "plugin.yaml",
1400
+ suggestion: 'Remove the "module" field from plugin.yaml'
1401
+ });
1402
+ }
1403
+ }
1404
+ if (!packageJson.exports || !packageJson.exports["./plugin.yaml"]) {
1405
+ result.errors.push({
1406
+ type: "export-mismatch",
1407
+ message: "plugin.yaml not exported in package.json (required for plugin resolution)",
1408
+ location: packageJsonPath,
1409
+ suggestion: 'Add "./plugin.yaml": "./plugin.yaml" to exports field'
1410
+ });
1411
+ }
1412
+ } catch (error) {
1413
+ result.errors.push({
1414
+ type: "schema",
1415
+ message: `Invalid package.json: ${error.message}`,
1416
+ location: packageJsonPath
1417
+ });
1418
+ }
1419
+ }
1420
+ async function validateDynamicContracts(context, result) {
1421
+ const { dynamic_contracts } = context.manifest;
1422
+ if (!dynamic_contracts)
1423
+ return;
1424
+ if (dynamic_contracts.generator) {
1425
+ const generatorPath = path.join(context.pluginPath, dynamic_contracts.generator);
1426
+ const possibleExtensions = [".ts", ".js", "/index.ts", "/index.js"];
1427
+ let found = false;
1428
+ for (const ext of possibleExtensions) {
1429
+ if (fs.existsSync(generatorPath + ext)) {
1430
+ found = true;
1431
+ break;
1432
+ }
1433
+ }
1434
+ if (!found && !context.isNpmPackage) {
1435
+ result.errors.push({
1436
+ type: "file-missing",
1437
+ message: `Generator file not found: ${dynamic_contracts.generator}`,
1438
+ location: "plugin.yaml dynamic_contracts",
1439
+ suggestion: `Create generator file at ${generatorPath}.ts`
1440
+ });
1441
+ }
1442
+ }
1443
+ if (dynamic_contracts.component) {
1444
+ const componentPath = path.join(context.pluginPath, dynamic_contracts.component);
1445
+ const possibleExtensions = [".ts", ".js", "/index.ts", "/index.js"];
1446
+ let found = false;
1447
+ for (const ext of possibleExtensions) {
1448
+ if (fs.existsSync(componentPath + ext)) {
1449
+ found = true;
1450
+ break;
1451
+ }
1452
+ }
1453
+ if (!found && !context.isNpmPackage) {
1454
+ result.errors.push({
1455
+ type: "file-missing",
1456
+ message: `Dynamic contracts component not found: ${dynamic_contracts.component}`,
1457
+ location: "plugin.yaml dynamic_contracts",
1458
+ suggestion: `Create component file at ${componentPath}.ts`
1459
+ });
1460
+ }
1461
+ }
1462
+ }
1463
+ async function findJayFiles(dir) {
1464
+ return await glob(`${dir}/**/*${JAY_EXTENSION}`);
1465
+ }
1466
+ async function findContractFiles(dir) {
1467
+ return await glob(`${dir}/**/*${JAY_CONTRACT_EXTENSION}`);
1468
+ }
1469
+ async function validateJayFiles(options = {}) {
1470
+ const config = loadConfig();
1471
+ const resolvedConfig = getConfigWithDefaults(config);
1472
+ const projectRoot = process.cwd();
1473
+ const scanDir = options.path ? path.resolve(options.path) : path.resolve(resolvedConfig.devServer.pagesBase);
1474
+ const errors = [];
1475
+ const warnings = [];
1476
+ const jayHtmlFiles = await findJayFiles(scanDir);
1477
+ const contractFiles = await findContractFiles(scanDir);
1478
+ if (options.verbose) {
1479
+ console.log(chalk.gray(`Scanning directory: ${scanDir}`));
1480
+ console.log(chalk.gray(`Found ${jayHtmlFiles.length} .jay-html files`));
1481
+ console.log(chalk.gray(`Found ${contractFiles.length} .jay-contract files
1482
+ `));
1483
+ }
1484
+ for (const contractFile of contractFiles) {
1485
+ const relativePath = path.relative(projectRoot, contractFile);
1486
+ try {
1487
+ const content = await promises.readFile(contractFile, "utf-8");
1488
+ const result = parseContract(content, path.basename(contractFile));
1489
+ if (result.validations.length > 0) {
1490
+ for (const validation of result.validations) {
1491
+ errors.push({
1492
+ file: relativePath,
1493
+ message: validation,
1494
+ stage: "parse"
1495
+ });
1496
+ }
1497
+ if (options.verbose) {
1498
+ console.log(chalk.red(`❌ ${relativePath}`));
1499
+ }
1500
+ } else if (options.verbose) {
1501
+ console.log(chalk.green(`✓ ${relativePath}`));
1502
+ }
1503
+ } catch (error) {
1504
+ errors.push({
1505
+ file: relativePath,
1506
+ message: error.message,
1507
+ stage: "parse"
1508
+ });
1509
+ if (options.verbose) {
1510
+ console.log(chalk.red(`❌ ${relativePath}`));
1511
+ }
1512
+ }
1513
+ }
1514
+ for (const jayFile of jayHtmlFiles) {
1515
+ const relativePath = path.relative(projectRoot, jayFile);
1516
+ const filename = path.basename(jayFile.replace(JAY_EXTENSION, ""));
1517
+ const dirname = path.dirname(jayFile);
1518
+ try {
1519
+ const content = await promises.readFile(jayFile, "utf-8");
1520
+ const parsedFile = await parseJayFile(
1521
+ content,
1522
+ filename,
1523
+ dirname,
1524
+ {},
1525
+ JAY_IMPORT_RESOLVER,
1526
+ projectRoot
1527
+ );
1528
+ if (parsedFile.validations.length > 0) {
1529
+ for (const validation of parsedFile.validations) {
1530
+ errors.push({
1531
+ file: relativePath,
1532
+ message: validation,
1533
+ stage: "parse"
1534
+ });
1535
+ }
1536
+ if (options.verbose) {
1537
+ console.log(chalk.red(`❌ ${relativePath}`));
1538
+ }
1539
+ continue;
1540
+ }
1541
+ const generatedFile = generateElementFile(
1542
+ parsedFile.val,
1543
+ RuntimeMode.MainTrusted,
1544
+ GenerateTarget.jay
1545
+ );
1546
+ if (generatedFile.validations.length > 0) {
1547
+ for (const validation of generatedFile.validations) {
1548
+ errors.push({
1549
+ file: relativePath,
1550
+ message: validation,
1551
+ stage: "generate"
1552
+ });
1553
+ }
1554
+ if (options.verbose) {
1555
+ console.log(chalk.red(`❌ ${relativePath}`));
1556
+ }
1557
+ } else if (options.verbose) {
1558
+ console.log(chalk.green(`✓ ${relativePath}`));
1559
+ }
1560
+ } catch (error) {
1561
+ errors.push({
1562
+ file: relativePath,
1563
+ message: error.message,
1564
+ stage: "parse"
1565
+ });
1566
+ if (options.verbose) {
1567
+ console.log(chalk.red(`❌ ${relativePath}`));
1568
+ }
1569
+ }
1570
+ }
1571
+ return {
1572
+ valid: errors.length === 0,
1573
+ jayHtmlFilesScanned: jayHtmlFiles.length,
1574
+ contractFilesScanned: contractFiles.length,
1575
+ errors,
1576
+ warnings
1577
+ };
1578
+ }
1579
+ function printJayValidationResult(result, options) {
1580
+ if (options.json) {
1581
+ console.log(JSON.stringify(result, null, 2));
1582
+ return;
1583
+ }
1584
+ console.log("");
1585
+ if (result.valid) {
1586
+ console.log(chalk.green("✅ Jay Stack validation successful!\n"));
1587
+ console.log(
1588
+ `Scanned ${result.jayHtmlFilesScanned} .jay-html files, ${result.contractFilesScanned} .jay-contract files`
1589
+ );
1590
+ console.log("No errors found.");
1591
+ } else {
1592
+ console.log(chalk.red("❌ Jay Stack validation failed\n"));
1593
+ console.log("Errors:");
1594
+ for (const error of result.errors) {
1595
+ console.log(chalk.red(` ❌ ${error.file}`));
1596
+ console.log(chalk.gray(` ${error.message}`));
1597
+ console.log("");
1598
+ }
1599
+ const validFiles = result.jayHtmlFilesScanned + result.contractFilesScanned - result.errors.length;
1600
+ console.log(
1601
+ chalk.red(`${result.errors.length} error(s) found, ${validFiles} file(s) valid.`)
1602
+ );
1603
+ }
1604
+ }
1605
+ const program = new Command();
1606
+ program.name("jay-stack").description("Jay Stack CLI - Development server and plugin validation").version("0.9.0");
1607
+ program.command("dev [path]").description("Start the Jay Stack development server").action(async (path2, options) => {
1608
+ try {
1609
+ await startDevServer({
1610
+ projectPath: path2 || process.cwd()
1611
+ });
1612
+ } catch (error) {
1613
+ console.error(chalk.red("Error starting dev server:"), error.message);
1614
+ process.exit(1);
1615
+ }
1616
+ });
1617
+ program.command("validate [path]").description("Validate all .jay-html and .jay-contract files in the project").option("-v, --verbose", "Show per-file validation status").option("--json", "Output results as JSON").action(async (scanPath, options) => {
1618
+ try {
1619
+ const result = await validateJayFiles({
1620
+ path: scanPath,
1621
+ verbose: options.verbose,
1622
+ json: options.json
1623
+ });
1624
+ printJayValidationResult(result, options);
1625
+ if (!result.valid) {
1626
+ process.exit(1);
1627
+ }
1628
+ } catch (error) {
1629
+ if (options.json) {
1630
+ console.log(JSON.stringify({ valid: false, error: error.message }, null, 2));
1631
+ } else {
1632
+ console.error(chalk.red("Validation error:"), error.message);
1633
+ }
1634
+ process.exit(1);
1635
+ }
367
1636
  });
1637
+ program.command("validate-plugin [path]").description("Validate a Jay Stack plugin package").option("--local", "Validate local plugins in src/plugins/").option("-v, --verbose", "Show detailed validation output").option("--strict", "Treat warnings as errors (for CI)").option("--generate-types", "Generate .d.ts files for contracts").action(async (pluginPath, options) => {
1638
+ try {
1639
+ const result = await validatePlugin({
1640
+ pluginPath: pluginPath || process.cwd(),
1641
+ local: options.local,
1642
+ verbose: options.verbose,
1643
+ strict: options.strict,
1644
+ generateTypes: options.generateTypes
1645
+ });
1646
+ printValidationResult(result, options.verbose);
1647
+ if (!result.valid || options.strict && result.warnings.length > 0) {
1648
+ process.exit(1);
1649
+ }
1650
+ } catch (error) {
1651
+ console.error(chalk.red("Validation error:"), error.message);
1652
+ process.exit(1);
1653
+ }
1654
+ });
1655
+ program.parse(process.argv);
1656
+ if (!process.argv.slice(2).length) {
1657
+ program.outputHelp();
1658
+ }
1659
+ function printValidationResult(result, verbose) {
1660
+ if (result.valid && result.warnings.length === 0) {
1661
+ console.log(chalk.green("✅ Plugin validation successful!\n"));
1662
+ if (verbose) {
1663
+ console.log("Plugin:", result.pluginName);
1664
+ console.log(" ✅ plugin.yaml valid");
1665
+ console.log(` ✅ ${result.contractsChecked} contracts validated`);
1666
+ if (result.typesGenerated) {
1667
+ console.log(` ✅ ${result.typesGenerated} type definitions generated`);
1668
+ }
1669
+ console.log(` ✅ ${result.componentsChecked} components validated`);
1670
+ if (result.packageJsonChecked) {
1671
+ console.log(" ✅ package.json valid");
1672
+ }
1673
+ console.log("\nNo errors found.");
1674
+ }
1675
+ } else if (result.valid && result.warnings.length > 0) {
1676
+ console.log(chalk.yellow("⚠️ Plugin validation passed with warnings\n"));
1677
+ console.log("Warnings:");
1678
+ result.warnings.forEach((warning) => {
1679
+ console.log(chalk.yellow(` ⚠️ ${warning.message}`));
1680
+ if (warning.location) {
1681
+ console.log(chalk.gray(` Location: ${warning.location}`));
1682
+ }
1683
+ if (warning.suggestion) {
1684
+ console.log(chalk.gray(` → ${warning.suggestion}`));
1685
+ }
1686
+ console.log();
1687
+ });
1688
+ console.log(chalk.gray("Use --strict to treat warnings as errors."));
1689
+ } else {
1690
+ console.log(chalk.red("❌ Plugin validation failed\n"));
1691
+ console.log("Errors:");
1692
+ result.errors.forEach((error) => {
1693
+ console.log(chalk.red(` ❌ ${error.message}`));
1694
+ if (error.location) {
1695
+ console.log(chalk.gray(` Location: ${error.location}`));
1696
+ }
1697
+ if (error.suggestion) {
1698
+ console.log(chalk.gray(` → ${error.suggestion}`));
1699
+ }
1700
+ console.log();
1701
+ });
1702
+ console.log(chalk.red(`${result.errors.length} errors found.`));
1703
+ }
1704
+ }
368
1705
  export {
369
1706
  createEditorHandlers,
370
1707
  getConfigWithDefaults,
371
1708
  loadConfig,
1709
+ startDevServer,
372
1710
  updateConfig
373
1711
  };