@jskit-ai/jskit-cli 0.2.66 → 0.2.68

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-cli",
3
- "version": "0.2.66",
3
+ "version": "0.2.68",
4
4
  "description": "Bundle and package orchestration CLI for JSKIT apps.",
5
5
  "type": "module",
6
6
  "files": [
@@ -20,9 +20,9 @@
20
20
  "test": "node --test"
21
21
  },
22
22
  "dependencies": {
23
- "@jskit-ai/jskit-catalog": "0.1.65",
24
- "@jskit-ai/kernel": "0.1.57",
25
- "@jskit-ai/shell-web": "0.1.56"
23
+ "@jskit-ai/jskit-catalog": "0.1.67",
24
+ "@jskit-ai/kernel": "0.1.59",
25
+ "@jskit-ai/shell-web": "0.1.58"
26
26
  },
27
27
  "engines": {
28
28
  "node": "20.x"
@@ -69,8 +69,9 @@ function normalizeMutationWhen(value) {
69
69
  const notContains = String(source.notContains || "").trim();
70
70
  const includes = ensureArray(source.in).map((entry) => String(entry || "").trim()).filter(Boolean);
71
71
  const excludes = ensureArray(source.notIn).map((entry) => String(entry || "").trim()).filter(Boolean);
72
+ const hasText = typeof source.hasText === "boolean" ? source.hasText : null;
72
73
 
73
- if (!option && !config && allConditions.length < 1 && anyConditions.length < 1) {
74
+ if (!option && !config && allConditions.length < 1 && anyConditions.length < 1 && hasText == null) {
74
75
  return null;
75
76
  }
76
77
 
@@ -84,7 +85,8 @@ function normalizeMutationWhen(value) {
84
85
  contains,
85
86
  notContains,
86
87
  includes,
87
- excludes
88
+ excludes,
89
+ hasText
88
90
  };
89
91
  }
90
92
 
@@ -270,6 +272,12 @@ function shouldApplyMutationWhen(
270
272
  const notContains = normalizeWhenComparisonValue(when.notContains);
271
273
  const includes = ensureArray(when.includes).map((entry) => normalizeWhenComparisonValue(entry)).filter(Boolean);
272
274
  const excludes = ensureArray(when.excludes).map((entry) => normalizeWhenComparisonValue(entry)).filter(Boolean);
275
+ const hasText = typeof when.hasText === "boolean" ? when.hasText : null;
276
+ const sourceText = normalizeWhenSourceValue(sourceValue);
277
+
278
+ if (hasText != null && (sourceText.length > 0) !== hasText) {
279
+ return false;
280
+ }
273
281
 
274
282
  if (equals && optionValue !== equals) {
275
283
  return false;
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { mkdir, rm, symlink } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, readlink, rm, symlink } from "node:fs/promises";
3
3
  import {
4
4
  discoverLocalPackageMap,
5
5
  fileExists,
@@ -8,6 +8,132 @@ import {
8
8
  resolveSymlinkType
9
9
  } from "./shared.js";
10
10
 
11
+ const COMPANION_PACKAGES = Object.freeze([
12
+ Object.freeze({
13
+ packageName: "json-rest-schema",
14
+ repoDirName: "json-rest-schema"
15
+ }),
16
+ Object.freeze({
17
+ packageName: "json-rest-stores",
18
+ repoDirName: "json-rest-stores"
19
+ })
20
+ ]);
21
+
22
+ function collectDeclaredPackageNames(packageJson = {}) {
23
+ const names = new Set();
24
+ const sections = [
25
+ packageJson?.dependencies,
26
+ packageJson?.devDependencies,
27
+ packageJson?.optionalDependencies,
28
+ packageJson?.peerDependencies
29
+ ];
30
+
31
+ for (const section of sections) {
32
+ if (!section || typeof section !== "object" || Array.isArray(section)) {
33
+ continue;
34
+ }
35
+
36
+ for (const packageName of Object.keys(section)) {
37
+ const normalizedPackageName = String(packageName || "").trim();
38
+ if (normalizedPackageName) {
39
+ names.add(normalizedPackageName);
40
+ }
41
+ }
42
+ }
43
+
44
+ return names;
45
+ }
46
+
47
+ async function verifySymlinkTarget(targetPath = "", sourceDir = "", {
48
+ packageName = ""
49
+ } = {}) {
50
+ const stats = await lstat(targetPath);
51
+ if (!stats.isSymbolicLink()) {
52
+ throw new Error(`[link-local] expected ${packageName || targetPath} to be a symlink after linking.`);
53
+ }
54
+
55
+ const linkedTarget = await readlink(targetPath);
56
+ if (linkedTarget !== sourceDir) {
57
+ throw new Error(
58
+ `[link-local] expected ${packageName || targetPath} to link to ${sourceDir}, got ${linkedTarget}.`
59
+ );
60
+ }
61
+ }
62
+
63
+ async function replaceWithSymlink(targetPath = "", sourceDir = "", {
64
+ packageName = ""
65
+ } = {}) {
66
+ await mkdir(path.dirname(targetPath), { recursive: true });
67
+ await rm(targetPath, { recursive: true, force: true });
68
+ await symlink(sourceDir, targetPath, resolveSymlinkType());
69
+ await verifySymlinkTarget(targetPath, sourceDir, {
70
+ packageName
71
+ });
72
+ }
73
+
74
+ async function maybeLinkCompanionPackages({
75
+ appRoot = "",
76
+ repoRoot = "",
77
+ stdout,
78
+ createCliError
79
+ }) {
80
+ const companionRoot = path.dirname(repoRoot);
81
+ const appPackageJsonPath = path.join(appRoot, "package.json");
82
+ let appPackageJson = {};
83
+ try {
84
+ appPackageJson = JSON.parse(await readFile(appPackageJsonPath, "utf8"));
85
+ } catch {
86
+ appPackageJson = {};
87
+ }
88
+ const declaredPackageNames = collectDeclaredPackageNames(appPackageJson);
89
+ let linkedCount = 0;
90
+
91
+ for (const companion of COMPANION_PACKAGES) {
92
+ if (!declaredPackageNames.has(companion.packageName)) {
93
+ continue;
94
+ }
95
+
96
+ const sourceDir = path.join(companionRoot, companion.repoDirName);
97
+ const packageJsonPath = path.join(sourceDir, "package.json");
98
+ if (!(await fileExists(packageJsonPath))) {
99
+ throw createCliError(
100
+ `[link-local] companion package ${companion.packageName} is declared by the app but local source was not found at ${sourceDir}.`,
101
+ { exitCode: 1 }
102
+ );
103
+ }
104
+
105
+ let packageJson = {};
106
+ try {
107
+ packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ if (String(packageJson?.name || "").trim() !== companion.packageName) {
113
+ throw createCliError(
114
+ `[link-local] companion source at ${sourceDir} does not match expected package ${companion.packageName}.`,
115
+ { exitCode: 1 }
116
+ );
117
+ }
118
+
119
+ const appTargetPath = path.join(appRoot, "node_modules", companion.packageName);
120
+ await replaceWithSymlink(appTargetPath, sourceDir, {
121
+ packageName: companion.packageName
122
+ });
123
+ stdout.write(`[link-local] linked ${companion.packageName} -> ${sourceDir}\n`);
124
+ linkedCount += 1;
125
+
126
+ const repoTargetPath = path.join(repoRoot, "node_modules", companion.packageName);
127
+ await replaceWithSymlink(repoTargetPath, sourceDir, {
128
+ packageName: `${companion.packageName} (repo root)`
129
+ });
130
+ stdout.write(`[link-local] linked repo companion ${companion.packageName} -> ${sourceDir}\n`);
131
+ linkedCount += 1;
132
+ }
133
+
134
+ return linkedCount;
135
+ }
136
+
11
137
  async function runAppLinkLocalPackagesCommand(ctx = {}, { appRoot = "", options = {}, stdout }) {
12
138
  const { createCliError } = ctx;
13
139
  const explicitRepoRoot = String(options?.inlineOptions?.["repo-root"] || "").trim();
@@ -39,8 +165,9 @@ async function runAppLinkLocalPackagesCommand(ctx = {}, { appRoot = "", options
39
165
 
40
166
  for (const [packageDirName, sourceDir] of [...packageMap.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
41
167
  const targetPath = path.join(scopeDirectory, packageDirName);
42
- await rm(targetPath, { recursive: true, force: true });
43
- await symlink(sourceDir, targetPath, resolveSymlinkType());
168
+ await replaceWithSymlink(targetPath, sourceDir, {
169
+ packageName: `@jskit-ai/${packageDirName}`
170
+ });
44
171
  stdout.write(`[link-local] linked @jskit-ai/${packageDirName} -> ${sourceDir}\n`);
45
172
  await linkPackageBinEntries({
46
173
  appRoot,
@@ -51,6 +178,13 @@ async function runAppLinkLocalPackagesCommand(ctx = {}, { appRoot = "", options
51
178
  linkedCount += 1;
52
179
  }
53
180
 
181
+ linkedCount += await maybeLinkCompanionPackages({
182
+ appRoot,
183
+ repoRoot,
184
+ stdout,
185
+ createCliError
186
+ });
187
+
54
188
  if (await fileExists(viteCacheDirectory)) {
55
189
  await rm(viteCacheDirectory, { recursive: true, force: true });
56
190
  stdout.write(`[link-local] cleared Vite cache at ${viteCacheDirectory}\n`);
@@ -75,6 +75,39 @@ function createHealthCommands(ctx = {}) {
75
75
  "createCrudListFilters",
76
76
  "useCrudListFilters"
77
77
  ]);
78
+ const CRUD_TRANSPORT_RUNTIME_CALLEES = Object.freeze([
79
+ "useCrudList",
80
+ "useCrudView",
81
+ "useCrudAddEdit"
82
+ ]);
83
+ const FEATURE_SERVER_SCAFFOLD_SHAPE = "feature-server-v1";
84
+ const FEATURE_SERVER_DEFAULT_LANE = "default";
85
+ const FEATURE_SERVER_JSON_REST_MODE = "json-rest";
86
+ const FEATURE_SERVER_PERSISTENT_MODES = new Set([
87
+ "json-rest",
88
+ "custom-knex"
89
+ ]);
90
+ const FEATURE_SERVER_COMPLEX_MARKER_RELATIVE_PATHS = Object.freeze([
91
+ "src/server/inputSchemas.js",
92
+ "src/server/actions.js",
93
+ "src/server/service.js",
94
+ "src/server/repository.js",
95
+ "src/server/registerRoutes.js"
96
+ ]);
97
+ const FEATURE_SERVER_PERSISTENCE_IMPORT_SOURCE_PATTERNS = Object.freeze([
98
+ /^@jskit-ai\/json-rest-api-core(?:\/|$)/u,
99
+ /^@jskit-ai\/database-runtime(?:\/|$)/u,
100
+ /^@jskit-ai\/database-runtime-mysql(?:\/|$)/u,
101
+ /^knex(?:\/|$)/u,
102
+ /^\.{1,2}\/.*repository(?:\.[A-Za-z0-9]+)?$/u
103
+ ]);
104
+ const MAIN_SERVER_BASELINE_RELATIVE_PATHS = new Set([
105
+ "src/server/index.js",
106
+ "src/server/MainServiceProvider.js",
107
+ "src/server/loadAppConfig.js"
108
+ ]);
109
+ const MAIN_SERVER_DOMAIN_FILE_PATTERN =
110
+ /^src\/server\/(?!(?:index|MainServiceProvider|loadAppConfig)\.[A-Za-z0-9]+$).+/u;
78
111
 
79
112
  function collectDescriptorContainerTokens({ packageId, side, values, issues }) {
80
113
  const declaredTokens = new Set();
@@ -485,6 +518,471 @@ function createHealthCommands(ctx = {}) {
485
518
  return bindings;
486
519
  }
487
520
 
521
+ function collectStaticImportSummaries(sourceText = "") {
522
+ const imports = [];
523
+ const importPattern = /^\s*import\s+[\s\S]*?\s+from\s+["']([^"']+)["'];?/gmu;
524
+
525
+ for (const match of sourceText.matchAll(importPattern)) {
526
+ const sourcePath = String(match[1] || "").trim();
527
+ if (!sourcePath) {
528
+ continue;
529
+ }
530
+ imports.push({
531
+ sourcePath,
532
+ lineNumber: resolveLineNumberFromIndex(sourceText, match.index || 0)
533
+ });
534
+ }
535
+
536
+ return imports;
537
+ }
538
+
539
+ function normalizeFeatureLaneMetadata(descriptor = {}) {
540
+ const metadata = ensureObject(ensureObject(ensureObject(descriptor).metadata).jskit);
541
+ return {
542
+ scaffoldShape: String(metadata.scaffoldShape || "").trim(),
543
+ scaffoldMode: String(metadata.scaffoldMode || "").trim(),
544
+ lane: String(metadata.lane || "").trim()
545
+ };
546
+ }
547
+
548
+ function isFeatureLanePersistenceImportSource(sourcePath = "") {
549
+ const normalizedSourcePath = String(sourcePath || "").trim();
550
+ if (!normalizedSourcePath) {
551
+ return false;
552
+ }
553
+ return FEATURE_SERVER_PERSISTENCE_IMPORT_SOURCE_PATTERNS.some((pattern) => pattern.test(normalizedSourcePath));
554
+ }
555
+
556
+ function findFirstPatternMatch(sourceText = "", patternEntries = []) {
557
+ let firstMatch = null;
558
+
559
+ for (const rawEntry of ensureArray(patternEntries)) {
560
+ const entry = ensureObject(rawEntry);
561
+ const pattern = entry.pattern instanceof RegExp ? entry.pattern : null;
562
+ if (!pattern) {
563
+ continue;
564
+ }
565
+ const match = pattern.exec(sourceText);
566
+ if (!match) {
567
+ continue;
568
+ }
569
+ if (!firstMatch || (match.index || 0) < firstMatch.index) {
570
+ firstMatch = {
571
+ index: match.index || 0,
572
+ label: String(entry.label || pattern.source).trim() || pattern.source
573
+ };
574
+ }
575
+ }
576
+
577
+ if (!firstMatch) {
578
+ return null;
579
+ }
580
+
581
+ return {
582
+ ...firstMatch,
583
+ lineNumber: resolveLineNumberFromIndex(sourceText, firstMatch.index)
584
+ };
585
+ }
586
+
587
+ function resolvePrimaryServerProviderPath(packageEntry) {
588
+ const rootDir = String(packageEntry?.rootDir || "").trim();
589
+ if (!rootDir) {
590
+ return "";
591
+ }
592
+
593
+ const providers = ensureArray(ensureObject(ensureObject(packageEntry?.descriptor).runtime).server?.providers);
594
+ for (const rawProvider of providers) {
595
+ const provider = ensureObject(rawProvider);
596
+ const entrypoint = String(provider.entrypoint || "").trim();
597
+ if (!entrypoint || entrypoint.includes("*")) {
598
+ continue;
599
+ }
600
+ return path.resolve(rootDir, entrypoint);
601
+ }
602
+
603
+ return "";
604
+ }
605
+
606
+ function resolvePackageDisplayPath(packageEntry) {
607
+ const relativeDir = String(packageEntry?.relativeDir || "").trim();
608
+ if (relativeDir) {
609
+ return relativeDir;
610
+ }
611
+ return String(packageEntry?.packageId || "").trim() || "package";
612
+ }
613
+
614
+ async function collectFeatureLaneRuleIssuesForPackage({ appRoot, packageEntry, issues }) {
615
+ const metadata = normalizeFeatureLaneMetadata(packageEntry?.descriptor);
616
+ if (metadata.scaffoldShape !== FEATURE_SERVER_SCAFFOLD_SHAPE) {
617
+ return;
618
+ }
619
+
620
+ const rootDir = String(packageEntry?.rootDir || "").trim();
621
+ if (!rootDir) {
622
+ return;
623
+ }
624
+
625
+ const packageDisplayPath = resolvePackageDisplayPath(packageEntry);
626
+ const isDefaultLane = metadata.lane === FEATURE_SERVER_DEFAULT_LANE;
627
+ const isPersistentMode = FEATURE_SERVER_PERSISTENT_MODES.has(metadata.scaffoldMode);
628
+
629
+ const servicePath = path.join(rootDir, "src", "server", "service.js");
630
+ if (isDefaultLane && (await fileExists(servicePath))) {
631
+ const serviceSource = await readFile(servicePath, "utf8");
632
+ const serviceRelativePath = normalizeRelativePath(appRoot, servicePath);
633
+ const knexMatch = findFirstPatternMatch(serviceSource, [
634
+ { pattern: /\bjskit\.database\.knex\b/u, label: "jskit.database.knex" },
635
+ { pattern: /\bcreateWithTransaction\s*\(/u, label: "createWithTransaction(...)" },
636
+ { pattern: /\bknex\b/u, label: "knex" }
637
+ ]);
638
+ if (knexMatch) {
639
+ issues.push(
640
+ `${serviceRelativePath}:${knexMatch.lineNumber}: [feature-lane:service-knex] default-lane service code must not use knex directly (${knexMatch.label}). Move persistence into src/server/repository.js or switch the package to an explicit weird-custom lane.`
641
+ );
642
+ }
643
+
644
+ const offendingServiceImports = collectStaticImportSummaries(serviceSource)
645
+ .filter((entry) => isFeatureLanePersistenceImportSource(entry.sourcePath));
646
+ if (offendingServiceImports.length > 0) {
647
+ const firstImport = offendingServiceImports[0];
648
+ const importSources = sortStrings(
649
+ [...new Set(offendingServiceImports.map((entry) => String(entry.sourcePath || "").trim()).filter(Boolean))]
650
+ );
651
+ issues.push(
652
+ `${serviceRelativePath}:${firstImport.lineNumber}: [feature-lane:service-persistence-import] default-lane service code must not import persistence helpers directly (${importSources.join(", ")}). Use the injected repository seam instead.`
653
+ );
654
+ }
655
+ }
656
+
657
+ const providerPath = resolvePrimaryServerProviderPath(packageEntry);
658
+ if (isDefaultLane && providerPath && (await fileExists(providerPath))) {
659
+ const providerSource = await readFile(providerPath, "utf8");
660
+ const providerRelativePath = normalizeRelativePath(appRoot, providerPath);
661
+ const providerPersistenceMatch = findFirstPatternMatch(providerSource, [
662
+ { pattern: /\bcreateJsonRestContext\s*\(/u, label: "createJsonRestContext(...)" },
663
+ { pattern: /\.resources\b/u, label: ".resources" },
664
+ { pattern: /\bjskit\.database\.knex\b/u, label: "jskit.database.knex" },
665
+ { pattern: /\bcreateWithTransaction\s*\(/u, label: "createWithTransaction(...)" },
666
+ { pattern: /\bknex\s*\./u, label: "knex." }
667
+ ]);
668
+ if (providerPersistenceMatch) {
669
+ issues.push(
670
+ `${providerRelativePath}:${providerPersistenceMatch.lineNumber}: [feature-lane:provider-persistence-direct] default-lane providers may wire repositories but must not perform persistence work directly (${providerPersistenceMatch.label}). Move json-rest/database calls into src/server/repository.js.`
671
+ );
672
+ }
673
+ }
674
+
675
+ const repositoryPath = path.join(rootDir, "src", "server", "repository.js");
676
+ if (isPersistentMode && !(await fileExists(repositoryPath))) {
677
+ issues.push(
678
+ `${packageDisplayPath}/src/server/repository.js: [feature-lane:repository-required] generated persistent feature packages require src/server/repository.js for scaffoldMode "${metadata.scaffoldMode}".`
679
+ );
680
+ return;
681
+ }
682
+
683
+ if (
684
+ isDefaultLane &&
685
+ metadata.scaffoldMode === FEATURE_SERVER_JSON_REST_MODE &&
686
+ (await fileExists(repositoryPath))
687
+ ) {
688
+ const repositorySource = await readFile(repositoryPath, "utf8");
689
+ const repositoryRelativePath = normalizeRelativePath(appRoot, repositoryPath);
690
+ const directDatabaseMatch = findFirstPatternMatch(repositorySource, [
691
+ { pattern: /\bjskit\.database\.knex\b/u, label: "jskit.database.knex" },
692
+ { pattern: /\bcreateWithTransaction\s*\(/u, label: "createWithTransaction(...)" },
693
+ { pattern: /\bknex\b/u, label: "knex" }
694
+ ]);
695
+ const usesJsonRest = /@jskit-ai\/json-rest-api-core|createJsonRestContext\s*\(|\.resources\b|json-rest-api/iu.test(repositorySource);
696
+ if (directDatabaseMatch || !usesJsonRest) {
697
+ const locationSuffix = directDatabaseMatch ? `:${directDatabaseMatch.lineNumber}` : "";
698
+ issues.push(
699
+ `${repositoryRelativePath}${locationSuffix}: [feature-lane:repository-default-path] default-lane persistent repositories must stay on internal json-rest-api. Use the injected json-rest-api seam or mark the package as an explicit weird-custom lane.`
700
+ );
701
+ }
702
+ }
703
+ }
704
+
705
+ async function collectMainPackageFeatureLaneWarnings({ appRoot, appLocalRegistry, warnings }) {
706
+ const mainPackageEntry = [...appLocalRegistry.values()].find((packageEntry) => {
707
+ const packageId = String(packageEntry?.packageId || "").trim();
708
+ const relativeDir = String(packageEntry?.relativeDir || "").trim();
709
+ return packageId === "@local/main" || relativeDir === "packages/main";
710
+ });
711
+ if (!mainPackageEntry) {
712
+ return;
713
+ }
714
+
715
+ const rootDir = String(mainPackageEntry.rootDir || "").trim();
716
+ if (!rootDir) {
717
+ return;
718
+ }
719
+
720
+ const reasons = [];
721
+ const serverFilePaths = [];
722
+ await collectAppSourceFiles(path.join(rootDir, "src", "server"), undefined, serverFilePaths);
723
+ const relativeServerFiles = sortStrings(serverFilePaths.map((absolutePath) => normalizeRelativePath(rootDir, absolutePath)));
724
+ const extraDomainFiles = relativeServerFiles.filter(
725
+ (relativePath) =>
726
+ !MAIN_SERVER_BASELINE_RELATIVE_PATHS.has(relativePath) &&
727
+ MAIN_SERVER_DOMAIN_FILE_PATTERN.test(relativePath)
728
+ );
729
+ if (extraDomainFiles.length > 0) {
730
+ reasons.push(`extra server domain files: ${extraDomainFiles.join(", ")}`);
731
+ }
732
+
733
+ const providerPath = path.join(rootDir, "src", "server", "MainServiceProvider.js");
734
+ if (await fileExists(providerPath)) {
735
+ const providerSource = await readFile(providerPath, "utf8");
736
+ const actionBindingCount = (providerSource.match(/\bapp\.actions\s*\(/gu) || []).length;
737
+ const serviceBindingCount = (providerSource.match(/\bapp\.(?:service|singleton)\s*\(/gu) || []).length;
738
+ const routeRegistrationCount = (providerSource.match(/\brouter\.register\s*\(/gu) || []).length;
739
+
740
+ if (actionBindingCount > 0) {
741
+ reasons.push("MainServiceProvider registers actions directly");
742
+ }
743
+ if (serviceBindingCount > 1) {
744
+ reasons.push(`MainServiceProvider registers ${serviceBindingCount} service/singleton bindings directly`);
745
+ }
746
+ if (routeRegistrationCount > 0) {
747
+ reasons.push("MainServiceProvider registers HTTP routes directly");
748
+ }
749
+ }
750
+
751
+ if (reasons.length > 0) {
752
+ warnings.push(
753
+ `packages/main: [feature-lane:main-glue-only] packages/main should stay composition/glue. Found ${reasons.join("; ")}. Move substantial server feature logic into a dedicated package generated with feature-server-generator scaffold.`
754
+ );
755
+ }
756
+ }
757
+
758
+ async function collectHandmadeFeatureLaneWarnings({ appLocalRegistry, warnings }) {
759
+ const packageEntries = sortStrings([...appLocalRegistry.keys()])
760
+ .map((packageId) => appLocalRegistry.get(packageId))
761
+ .filter(Boolean);
762
+
763
+ for (const packageEntry of packageEntries) {
764
+ const relativeDir = String(packageEntry?.relativeDir || "").trim();
765
+ if (relativeDir === "packages/main") {
766
+ continue;
767
+ }
768
+
769
+ const rootDir = String(packageEntry?.rootDir || "").trim();
770
+ if (!rootDir) {
771
+ continue;
772
+ }
773
+
774
+ const metadata = normalizeFeatureLaneMetadata(packageEntry?.descriptor);
775
+ if (metadata.scaffoldShape === FEATURE_SERVER_SCAFFOLD_SHAPE) {
776
+ continue;
777
+ }
778
+
779
+ const descriptor = ensureObject(packageEntry?.descriptor);
780
+ const capabilities = ensureObject(descriptor.capabilities);
781
+ const providedCapabilities = ensureArray(capabilities.provides)
782
+ .map((value) => String(value || "").trim())
783
+ .filter(Boolean);
784
+ const dependsOn = ensureArray(descriptor.dependsOn)
785
+ .map((value) => String(value || "").trim())
786
+ .filter(Boolean);
787
+ if (
788
+ providedCapabilities.some((value) => value.startsWith("crud.")) ||
789
+ dependsOn.includes("@jskit-ai/crud-core")
790
+ ) {
791
+ continue;
792
+ }
793
+
794
+ const serverProviders = ensureArray(ensureObject(ensureObject(descriptor.runtime).server).providers);
795
+ const hasDirectServerProvider = serverProviders.some((rawProvider) => {
796
+ const provider = ensureObject(rawProvider);
797
+ const entrypoint = String(provider.entrypoint || "").trim();
798
+ return entrypoint.startsWith("src/server/") && /Provider\.[A-Za-z0-9]+$/u.test(entrypoint);
799
+ });
800
+ if (!hasDirectServerProvider) {
801
+ continue;
802
+ }
803
+
804
+ const markerPaths = [];
805
+ for (const relativePath of FEATURE_SERVER_COMPLEX_MARKER_RELATIVE_PATHS) {
806
+ if (await fileExists(path.join(rootDir, relativePath))) {
807
+ markerPaths.push(relativePath);
808
+ }
809
+ }
810
+
811
+ const looksLikeFeatureCapability = providedCapabilities.some((value) => value.startsWith("feature."));
812
+ if (!(markerPaths.length >= 3 || (looksLikeFeatureCapability && markerPaths.length >= 2))) {
813
+ continue;
814
+ }
815
+
816
+ warnings.push(
817
+ `${relativeDir || String(packageEntry?.packageId || "").trim()}: [feature-lane:handmade-feature] package looks like a substantial non-CRUD server feature (${markerPaths.join(", ")}) but is missing metadata.jskit.scaffoldShape="${FEATURE_SERVER_SCAFFOLD_SHAPE}". Start from jskit generate feature-server-generator scaffold <feature-name> for this lane instead of hand-making the topology.`
818
+ );
819
+ }
820
+ }
821
+
822
+ async function collectFeatureLaneDoctorIssues({ appRoot, appLocalRegistry, issues, warnings }) {
823
+ const packageEntries = sortStrings([...appLocalRegistry.keys()])
824
+ .map((packageId) => appLocalRegistry.get(packageId))
825
+ .filter(Boolean);
826
+
827
+ for (const packageEntry of packageEntries) {
828
+ await collectFeatureLaneRuleIssuesForPackage({
829
+ appRoot,
830
+ packageEntry,
831
+ issues
832
+ });
833
+ }
834
+
835
+ await collectMainPackageFeatureLaneWarnings({
836
+ appRoot,
837
+ appLocalRegistry,
838
+ warnings
839
+ });
840
+ await collectHandmadeFeatureLaneWarnings({
841
+ appLocalRegistry,
842
+ warnings
843
+ });
844
+ }
845
+
846
+ function hasTopLevelObjectProperty(sourceText = "", propertyName = "") {
847
+ const normalizedPropertyName = String(propertyName || "").trim();
848
+ const normalizedSourceText = String(sourceText || "").trim();
849
+ if (!normalizedPropertyName || !normalizedSourceText.startsWith("{")) {
850
+ return false;
851
+ }
852
+
853
+ let parenDepth = 0;
854
+ let braceDepth = 0;
855
+ let bracketDepth = 0;
856
+ let inLineComment = false;
857
+ let inBlockComment = false;
858
+
859
+ for (let index = 0; index < normalizedSourceText.length; index += 1) {
860
+ const character = normalizedSourceText[index];
861
+ const nextCharacter = normalizedSourceText[index + 1] || "";
862
+
863
+ if (inLineComment) {
864
+ if (character === "\n") {
865
+ inLineComment = false;
866
+ }
867
+ continue;
868
+ }
869
+
870
+ if (inBlockComment) {
871
+ if (character === "*" && nextCharacter === "/") {
872
+ inBlockComment = false;
873
+ index += 1;
874
+ }
875
+ continue;
876
+ }
877
+
878
+ if (character === "/" && nextCharacter === "/") {
879
+ inLineComment = true;
880
+ index += 1;
881
+ continue;
882
+ }
883
+
884
+ if (character === "/" && nextCharacter === "*") {
885
+ inBlockComment = true;
886
+ index += 1;
887
+ continue;
888
+ }
889
+
890
+ if (character === "'" || character === "\"") {
891
+ const quote = character;
892
+ const stringStart = index + 1;
893
+ let stringEnd = stringStart;
894
+ for (; stringEnd < normalizedSourceText.length; stringEnd += 1) {
895
+ if (
896
+ normalizedSourceText[stringEnd] === quote &&
897
+ !isEscapedCharacter(normalizedSourceText, stringEnd)
898
+ ) {
899
+ break;
900
+ }
901
+ }
902
+
903
+ const stringValue = normalizedSourceText.slice(stringStart, stringEnd);
904
+ index = stringEnd;
905
+ if (braceDepth === 1 && parenDepth === 0 && bracketDepth === 0 && stringValue === normalizedPropertyName) {
906
+ let cursor = index + 1;
907
+ while (/\s/u.test(normalizedSourceText[cursor] || "")) {
908
+ cursor += 1;
909
+ }
910
+ if (normalizedSourceText[cursor] === ":") {
911
+ return true;
912
+ }
913
+ }
914
+ continue;
915
+ }
916
+
917
+ if (character === "`") {
918
+ for (index += 1; index < normalizedSourceText.length; index += 1) {
919
+ if (
920
+ normalizedSourceText[index] === "`" &&
921
+ !isEscapedCharacter(normalizedSourceText, index)
922
+ ) {
923
+ break;
924
+ }
925
+ }
926
+ continue;
927
+ }
928
+
929
+ if (character === "(") {
930
+ parenDepth += 1;
931
+ continue;
932
+ }
933
+ if (character === ")") {
934
+ parenDepth -= 1;
935
+ continue;
936
+ }
937
+ if (character === "{") {
938
+ braceDepth += 1;
939
+ continue;
940
+ }
941
+ if (character === "}") {
942
+ braceDepth -= 1;
943
+ continue;
944
+ }
945
+ if (character === "[") {
946
+ bracketDepth += 1;
947
+ continue;
948
+ }
949
+ if (character === "]") {
950
+ bracketDepth -= 1;
951
+ continue;
952
+ }
953
+
954
+ if (
955
+ braceDepth === 1 &&
956
+ parenDepth === 0 &&
957
+ bracketDepth === 0 &&
958
+ /[A-Za-z_$]/u.test(character)
959
+ ) {
960
+ const identifierStart = index;
961
+ for (index += 1; index < normalizedSourceText.length; index += 1) {
962
+ if (!/[\w$]/u.test(normalizedSourceText[index] || "")) {
963
+ break;
964
+ }
965
+ }
966
+
967
+ const identifier = normalizedSourceText.slice(identifierStart, index);
968
+ index -= 1;
969
+ if (identifier !== normalizedPropertyName) {
970
+ continue;
971
+ }
972
+
973
+ let cursor = index + 1;
974
+ while (/\s/u.test(normalizedSourceText[cursor] || "")) {
975
+ cursor += 1;
976
+ }
977
+ if (normalizedSourceText[cursor] === ":") {
978
+ return true;
979
+ }
980
+ }
981
+ }
982
+
983
+ return false;
984
+ }
985
+
488
986
  function isSharedListFiltersImportSource(sourcePath = "") {
489
987
  return /(^|\/)shared\/[^/'"]*ListFilters(?:\.[A-Za-z0-9]+)?$/u.test(String(sourcePath || "").trim());
490
988
  }
@@ -570,6 +1068,26 @@ function createHealthCommands(ctx = {}) {
570
1068
  }
571
1069
  }
572
1070
 
1071
+ function collectCrudTransportOwnershipIssues({
1072
+ sourceText = "",
1073
+ relativePath = "",
1074
+ issues = []
1075
+ }) {
1076
+ for (const calleeName of CRUD_TRANSPORT_RUNTIME_CALLEES) {
1077
+ for (const callSite of findCallSites(sourceText, calleeName)) {
1078
+ const firstArgument = extractFirstArgumentText(callSite.argsText).trim();
1079
+ if (!hasTopLevelObjectProperty(firstArgument, "transport")) {
1080
+ continue;
1081
+ }
1082
+
1083
+ const lineNumber = resolveLineNumberFromIndex(sourceText, callSite.index);
1084
+ issues.push(
1085
+ `${relativePath}:${lineNumber}: [crud:transport-derived] do not pass explicit transport to ${calleeName}(...). Let the shared CRUD resource derive JSON:API transport automatically, or drop to useList/useView/useAddEdit/usersWebHttpClient.request(...) for custom transport behavior.`
1086
+ );
1087
+ }
1088
+ }
1089
+ }
1090
+
573
1091
  async function collectMdiSvgDoctorIssues({ appRoot, issues }) {
574
1092
  if (!(await appUsesVuetifyMdiSvg(appRoot))) {
575
1093
  return;
@@ -609,7 +1127,10 @@ function createHealthCommands(ctx = {}) {
609
1127
  if (
610
1128
  !sourceText.includes("useCrudListFilters") &&
611
1129
  !sourceText.includes("createCrudListFilters") &&
612
- !sourceText.includes("createQueryValidator")
1130
+ !sourceText.includes("createQueryValidator") &&
1131
+ !sourceText.includes("useCrudList") &&
1132
+ !sourceText.includes("useCrudView") &&
1133
+ !sourceText.includes("useCrudAddEdit")
613
1134
  ) {
614
1135
  continue;
615
1136
  }
@@ -625,6 +1146,11 @@ function createHealthCommands(ctx = {}) {
625
1146
  relativePath,
626
1147
  issues
627
1148
  });
1149
+ collectCrudTransportOwnershipIssues({
1150
+ sourceText,
1151
+ relativePath,
1152
+ issues
1153
+ });
628
1154
  }
629
1155
  }
630
1156
 
@@ -730,6 +1256,7 @@ function createHealthCommands(ctx = {}) {
730
1256
  const appLocalRegistry = await loadAppLocalPackageRegistry(appRoot);
731
1257
  const combinedPackageRegistry = mergePackageRegistries(packageRegistry, appLocalRegistry);
732
1258
  const issues = [];
1259
+ const warnings = [];
733
1260
  const installed = ensureObject(lock.installedPackages);
734
1261
  await hydratePackageRegistryFromInstalledNodeModules({
735
1262
  appRoot,
@@ -767,12 +1294,19 @@ function createHealthCommands(ctx = {}) {
767
1294
  appRoot,
768
1295
  issues
769
1296
  });
1297
+ await collectFeatureLaneDoctorIssues({
1298
+ appRoot,
1299
+ appLocalRegistry,
1300
+ issues,
1301
+ warnings
1302
+ });
770
1303
 
771
1304
  const payload = {
772
1305
  appRoot,
773
1306
  lockVersion: lock.lockVersion,
774
1307
  installedPackages: sortStrings(Object.keys(installed)),
775
- issues
1308
+ issues,
1309
+ warnings: sortStrings(warnings)
776
1310
  };
777
1311
 
778
1312
  if (options.json) {
@@ -780,13 +1314,21 @@ function createHealthCommands(ctx = {}) {
780
1314
  } else {
781
1315
  stdout.write(`App root: ${appRoot}\n`);
782
1316
  stdout.write(`Installed packages: ${payload.installedPackages.length}\n`);
783
- if (issues.length === 0) {
1317
+ if (issues.length === 0 && payload.warnings.length === 0) {
784
1318
  stdout.write("Doctor status: healthy\n");
1319
+ } else if (issues.length === 0) {
1320
+ stdout.write(`Doctor status: warnings (${payload.warnings.length} warning(s))\n`);
1321
+ for (const warning of payload.warnings) {
1322
+ stdout.write(`! ${warning}\n`);
1323
+ }
785
1324
  } else {
786
1325
  stdout.write(`Doctor status: unhealthy (${issues.length} issue(s))\n`);
787
1326
  for (const issue of issues) {
788
1327
  stdout.write(`- ${issue}\n`);
789
1328
  }
1329
+ for (const warning of payload.warnings) {
1330
+ stdout.write(`! ${warning}\n`);
1331
+ }
790
1332
  }
791
1333
  }
792
1334
 
@@ -12,6 +12,8 @@ const MAIN_CLIENT_PROVIDERS_RELATIVE_PATH = "packages/main/src/client/providers"
12
12
  const COMPONENT_TOKEN_PATTERN = /\bcomponentToken\s*:\s*["']([^"']+)["']/g;
13
13
  const REGISTER_MAIN_CLIENT_COMPONENT_PATTERN = /registerMainClientComponent\(\s*["']([^"']+)["']\s*,/g;
14
14
  const LINK_ITEM_TOKEN_SUFFIX = "link-item";
15
+ const JSKIT_SCOPE_PREFIX = "@jskit-ai/";
16
+ const FEATURE_SERVER_GENERATOR_PACKAGE_ID = "@jskit-ai/feature-server-generator";
15
17
  const PROVIDER_SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx"]);
16
18
  const READ_FILE_IGNORE_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EISDIR", "EACCES", "EPERM"]);
17
19
  const READ_DIRECTORY_IGNORE_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
@@ -41,6 +43,41 @@ function appendTokenSource(map, token = "", source = "") {
41
43
  map.set(normalizedToken, existingSources);
42
44
  }
43
45
 
46
+ function toShortPackageId(packageId = "") {
47
+ const normalizedPackageId = String(packageId || "").trim();
48
+ if (!normalizedPackageId.startsWith(JSKIT_SCOPE_PREFIX)) {
49
+ return normalizedPackageId;
50
+ }
51
+ return normalizedPackageId.slice(JSKIT_SCOPE_PREFIX.length);
52
+ }
53
+
54
+ function resolveGeneratorPrimarySubcommand(packageEntry = {}) {
55
+ const descriptor = ensureObject(packageEntry?.descriptor);
56
+ const metadata = ensureObject(descriptor.metadata);
57
+ return String(metadata.generatorPrimarySubcommand || descriptor.generatorPrimarySubcommand || "").trim();
58
+ }
59
+
60
+ function resolveGeneratorDescription(packageEntry = {}) {
61
+ const descriptor = ensureObject(packageEntry?.descriptor);
62
+ return String(descriptor.description || "").trim();
63
+ }
64
+
65
+ function resolveGeneratorQuickStartRows(packageEntry = {}, { limit = 3 } = {}) {
66
+ const descriptor = ensureObject(packageEntry?.descriptor);
67
+ const metadata = ensureObject(descriptor.metadata);
68
+ const subcommands = ensureObject(metadata.generatorSubcommands || descriptor.generatorSubcommands);
69
+ const primarySubcommandName = resolveGeneratorPrimarySubcommand(packageEntry);
70
+ const primarySubcommand = ensureObject(subcommands[primarySubcommandName]);
71
+ const examples = ensureArray(primarySubcommand.examples);
72
+ return examples
73
+ .slice(0, Math.max(0, Number(limit) || 0))
74
+ .map((example) => ({
75
+ label: String(ensureObject(example).label || "").trim(),
76
+ lines: ensureArray(ensureObject(example).lines).map((value) => String(value || "").trim()).filter(Boolean)
77
+ }))
78
+ .filter((example) => example.lines.length > 0);
79
+ }
80
+
44
81
  function isLinkItemToken(token = "") {
45
82
  return String(token || "").trim().toLowerCase().endsWith(LINK_ITEM_TOKEN_SUFFIX);
46
83
  }
@@ -265,6 +302,21 @@ function createListCommands(ctx = {}) {
265
302
  if (lines.length > 0) {
266
303
  lines.push("");
267
304
  }
305
+ const featureServerEntry = packageRegistry.get(FEATURE_SERVER_GENERATOR_PACKAGE_ID);
306
+ const recommendedQuickStarts = resolveGeneratorQuickStartRows(featureServerEntry, { limit: 3 });
307
+ if (recommendedQuickStarts.length > 0) {
308
+ lines.push(color.heading(`Recommended non-CRUD server starts (${recommendedQuickStarts.length}):`));
309
+ for (const example of recommendedQuickStarts) {
310
+ const label = String(example.label || "").trim();
311
+ if (label) {
312
+ lines.push(`- ${color.item(label)}`);
313
+ }
314
+ for (const commandLine of ensureArray(example.lines)) {
315
+ lines.push(` ${commandLine}`);
316
+ }
317
+ }
318
+ lines.push("");
319
+ }
268
320
  lines.push(color.heading("Available generators:"));
269
321
  const packageIds = sortStrings([...packageRegistry.keys()].filter((packageId) => {
270
322
  const packageEntry = packageRegistry.get(packageId);
@@ -273,8 +325,14 @@ function createListCommands(ctx = {}) {
273
325
  for (const packageId of packageIds) {
274
326
  const packageEntry = packageRegistry.get(packageId);
275
327
  const installedLabel = installedPackages.has(packageId) ? " (installed)" : "";
328
+ const shortId = toShortPackageId(packageId);
329
+ const shortIdPrefix = shortId && shortId !== packageId ? `${color.item(shortId)} ` : "";
330
+ const primarySubcommand = resolveGeneratorPrimarySubcommand(packageEntry);
331
+ const primarySuffix = primarySubcommand ? ` ${color.dim(`[primary:${primarySubcommand}]`)}` : "";
332
+ const description = resolveGeneratorDescription(packageEntry);
333
+ const descriptionSuffix = description ? `: ${description}` : "";
276
334
  lines.push(
277
- `- ${color.item(packageId)} ${color.version(`(${packageEntry.version})`)}${installedLabel ? color.installed(installedLabel) : ""}`
335
+ `- ${shortIdPrefix}${color.item(packageId)} ${color.version(`(${packageEntry.version})`)}${primarySuffix}${installedLabel ? color.installed(installedLabel) : ""}${descriptionSuffix}`.trim()
278
336
  );
279
337
  }
280
338
  }
@@ -328,8 +386,12 @@ function createListCommands(ctx = {}) {
328
386
  }
329
387
  return {
330
388
  packageId,
389
+ shortId: toShortPackageId(packageId),
331
390
  version: packageEntry.version,
332
- installed: installedPackages.has(packageId)
391
+ installed: installedPackages.has(packageId),
392
+ description: resolveGeneratorDescription(packageEntry),
393
+ primarySubcommand: resolveGeneratorPrimarySubcommand(packageEntry),
394
+ quickStarts: resolveGeneratorQuickStartRows(packageEntry, { limit: 3 })
333
395
  };
334
396
  }).filter(Boolean)
335
397
  : [],
@@ -10,6 +10,7 @@ import {
10
10
  } from "../../shared/outputFormatting.js";
11
11
 
12
12
  const JSKIT_SCOPE_PREFIX = "@jskit-ai/";
13
+ const FEATURE_SERVER_GENERATOR_PACKAGE_ID = "@jskit-ai/feature-server-generator";
13
14
  const HELP_TEXT_BY_KEY = Object.freeze({
14
15
  "page-target-file":
15
16
  "Vue page file relative to src/pages/. It must resolve to a configured surface.",
@@ -223,7 +224,8 @@ function appendHelpExamples(lines = [], exampleRows = [], { color = null } = {})
223
224
  }
224
225
 
225
226
  lines.push("");
226
- lines.push(color ? color.heading(`Examples (${examples.length}):`) : `Examples (${examples.length}):`);
227
+ const headingLabel = color ? color.heading(`Examples (${examples.length}):`) : `Examples (${examples.length}):`;
228
+ lines.push(headingLabel);
227
229
  appendSeparatedBlocks(
228
230
  lines,
229
231
  examples.map((example) => {
@@ -250,6 +252,31 @@ function appendHelpExamples(lines = [], exampleRows = [], { color = null } = {})
250
252
  );
251
253
  }
252
254
 
255
+ function appendHelpExamplesWithHeading(lines = [], exampleRows = [], heading = "", { color = null } = {}) {
256
+ const examples = ensureArray(exampleRows);
257
+ const headingText = String(heading || "").trim();
258
+ if (examples.length < 1 || !headingText) {
259
+ return;
260
+ }
261
+
262
+ lines.push("");
263
+ lines.push(color ? color.heading(headingText) : headingText);
264
+ appendSeparatedBlocks(
265
+ lines,
266
+ examples.map((example) => {
267
+ const block = [];
268
+ const label = String(example?.label || "").trim();
269
+ if (label) {
270
+ block.push(`- ${color ? color.item(label) : label}`);
271
+ }
272
+ for (const commandLine of ensureArray(example?.lines)) {
273
+ block.push(` ${commandLine}`);
274
+ }
275
+ return block;
276
+ }).filter((block) => block.length > 0)
277
+ );
278
+ }
279
+
253
280
  function appendHelpNotes(lines = [], rawNotes = [], { color = null } = {}) {
254
281
  const notes = normalizeHelpNoteRows(rawNotes);
255
282
  if (notes.length < 1) {
@@ -420,6 +447,23 @@ function buildSubcommandOptionRows(optionRows = [], subcommandRow = {}) {
420
447
  return rows;
421
448
  }
422
449
 
450
+ function resolvePrimaryGeneratorQuickStartRows(packageEntry = {}, { limit = 3 } = {}) {
451
+ const metadata = resolveGeneratorSubcommandMetadata(packageEntry);
452
+ const subcommands = ensureArray(metadata.subcommands);
453
+ const primarySubcommand = subcommands.find((row) => row.primary) || subcommands[0] || null;
454
+ if (!primarySubcommand) {
455
+ return [];
456
+ }
457
+
458
+ return ensureArray(primarySubcommand.examples)
459
+ .slice(0, Math.max(0, Number(limit) || 0))
460
+ .map((example) => Object.freeze({
461
+ label: String(example?.label || "").trim(),
462
+ lines: ensureArray(example?.lines).map((value) => String(value || "").trim()).filter(Boolean)
463
+ }))
464
+ .filter((example) => example.lines.length > 0);
465
+ }
466
+
423
467
  function renderGenerateCatalogHelp({
424
468
  io,
425
469
  packageRegistry,
@@ -438,11 +482,14 @@ function renderGenerateCatalogHelp({
438
482
  description: resolvePackageSummary(entry)
439
483
  });
440
484
  });
485
+ const featureServerPackageEntry = packageRegistry.get(FEATURE_SERVER_GENERATOR_PACKAGE_ID);
486
+ const recommendedQuickStarts = resolvePrimaryGeneratorQuickStartRows(featureServerPackageEntry, { limit: 3 });
441
487
 
442
488
  if (json) {
443
489
  io.stdout.write(`${JSON.stringify({
444
490
  command: "generate",
445
491
  generators,
492
+ recommendedQuickStarts,
446
493
  usage: [
447
494
  "jskit generate <generatorId> help",
448
495
  "jskit generate <generatorId> [subcommand] [subcommand args...] [--<option> <value>...]",
@@ -471,6 +518,12 @@ function renderGenerateCatalogHelp({
471
518
  lines.push("- jskit generate <generatorId> help");
472
519
  lines.push("- jskit generate <generatorId> [subcommand] [subcommand args...] [--<option> <value>...]");
473
520
  lines.push("- jskit list generators");
521
+ appendHelpExamplesWithHeading(
522
+ lines,
523
+ recommendedQuickStarts,
524
+ `Recommended non-CRUD server starts (${recommendedQuickStarts.length}):`,
525
+ { color }
526
+ );
474
527
  writeWrappedLines({
475
528
  stdout: io.stdout,
476
529
  lines
@@ -619,6 +672,12 @@ function renderGeneratePackageHelp({
619
672
  const descriptionSuffix = subcommand.description ? `: ${subcommand.description}` : "";
620
673
  lines.push(`- ${color.item(subcommand.name)}${primarySuffix}${descriptionSuffix}`);
621
674
  }
675
+ appendHelpExamplesWithHeading(
676
+ lines,
677
+ resolvePrimaryGeneratorQuickStartRows(packageEntry, { limit: 3 }),
678
+ `Primary quick starts (${resolvePrimaryGeneratorQuickStartRows(packageEntry, { limit: 3 }).length}):`,
679
+ { color }
680
+ );
622
681
  lines.push("");
623
682
  lines.push("Use subcommand help for positional args, options, notes, and examples:");
624
683
  lines.push(` ${color.item("jskit generate <generatorId> <subcommand> help")}`);
@@ -240,7 +240,7 @@ async function runPackageGenerateCommand(
240
240
  });
241
241
  if (!resolvedPackageId) {
242
242
  throw createCliError(
243
- `Unknown package: ${packageIdInput}. Install it first (npm install ${packageIdInput}) if you want to run generator subcommands from node_modules.`
243
+ `Unknown package: ${packageIdInput}. Use jskit list generators to discover available generators. If you want to run generator subcommands from node_modules, install the generator first (npm install ${packageIdInput}). For a substantial non-CRUD server feature, start with: jskit generate feature-server-generator scaffold <feature-name>.`
244
244
  );
245
245
  }
246
246
 
@@ -255,7 +255,7 @@ async function runPackageGenerateCommand(
255
255
  }
256
256
  if (resolvePackageKind(packageEntry) !== "generator") {
257
257
  throw createCliError(
258
- `Package ${resolvedPackageId} is a runtime package. Use: jskit add package ${resolvedPackageId}`
258
+ `Package ${resolvedPackageId} is a runtime package. Use: jskit add package ${resolvedPackageId}. For a new substantial non-CRUD server feature, start with: jskit generate feature-server-generator scaffold <feature-name>.`
259
259
  );
260
260
  }
261
261
 
@@ -23,7 +23,9 @@ async function runPackageUpdateCommand(
23
23
  const installedPackages = ensureObject(lock.installedPackages);
24
24
  const resolvedTargetId = resolveInstalledPackageIdInput(targetId, installedPackages);
25
25
  if (!resolvedTargetId) {
26
- throw createCliError(`Package is not installed: ${targetId}`);
26
+ throw createCliError(
27
+ `Package is not installed: ${targetId}. update package only reapplies packages already recorded in .jskit/lock.json. If you meant to install it, run: jskit add package ${targetId}`
28
+ );
27
29
  }
28
30
 
29
31
  return runCommandAdd({
@@ -6,6 +6,35 @@ import { createShowRenderHelpers } from "./renderHelpers.js";
6
6
  import { writePackageExportsSection } from "./renderPackageExports.js";
7
7
  import { writeCapabilitiesSections } from "./renderPackageCapabilities.js";
8
8
 
9
+ function resolveGeneratorSubcommandRows(payload = {}) {
10
+ const metadata = ensureObject(payload.metadata);
11
+ const generatorSubcommands = ensureObject(metadata.generatorSubcommands);
12
+ const primarySubcommand = String(metadata.generatorPrimarySubcommand || "").trim();
13
+ return Object.keys(generatorSubcommands)
14
+ .sort((left, right) => left.localeCompare(right))
15
+ .map((subcommandName) => {
16
+ const definition = ensureObject(generatorSubcommands[subcommandName]);
17
+ return {
18
+ name: subcommandName,
19
+ primary: primarySubcommand === subcommandName,
20
+ description: String(definition.description || "").trim(),
21
+ examples: ensureArray(definition.examples)
22
+ .map((example) => {
23
+ const record = ensureObject(example);
24
+ return {
25
+ label: String(record.label || "").trim(),
26
+ lines: ensureArray(record.lines).map((value) => String(value || "").trim()).filter(Boolean)
27
+ };
28
+ })
29
+ .filter((example) => example.lines.length > 0)
30
+ };
31
+ });
32
+ }
33
+
34
+ function resolveOwnershipGuidance(payload = {}) {
35
+ return ensureObject(ensureObject(ensureObject(payload.metadata).jskit).ownershipGuidance);
36
+ }
37
+
9
38
  function renderPackagePayloadText({
10
39
  payload,
11
40
  provides,
@@ -70,6 +99,8 @@ function renderPackagePayloadText({
70
99
  const bindingSections = ensureObject(payload.containerBindings);
71
100
  const serverBindings = ensureArray(bindingSections.server);
72
101
  const clientBindings = ensureArray(bindingSections.client);
102
+ const generatorSubcommands = resolveGeneratorSubcommandRows(payload);
103
+ const ownershipGuidance = resolveOwnershipGuidance(payload);
73
104
 
74
105
  stdout.write(`${color.heading("Information")}\n`);
75
106
  writeField("Package", payload.packageId, color.item);
@@ -98,6 +129,50 @@ function renderPackagePayloadText({
98
129
  }
99
130
  }
100
131
 
132
+ if (generatorSubcommands.length > 0) {
133
+ stdout.write(`${color.heading(`Generator commands (${generatorSubcommands.length}):`)}\n`);
134
+ for (const subcommand of generatorSubcommands) {
135
+ const primarySuffix = subcommand.primary ? ` ${color.installed("[primary]")}` : "";
136
+ const descriptionSuffix = subcommand.description ? `: ${subcommand.description}` : "";
137
+ stdout.write(`- ${color.item(subcommand.name)}${primarySuffix}${descriptionSuffix}\n`);
138
+ if (options.details && subcommand.examples.length > 0) {
139
+ for (const example of subcommand.examples.slice(0, 2)) {
140
+ const exampleLabel = String(example.label || "").trim();
141
+ if (exampleLabel) {
142
+ stdout.write(` ${color.dim(`example: ${exampleLabel}`)}\n`);
143
+ }
144
+ for (const commandLine of ensureArray(example.lines)) {
145
+ stdout.write(` ${commandLine}\n`);
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ if (options.details && Object.keys(ownershipGuidance).length > 0) {
153
+ const title = String(ownershipGuidance.title || "Ownership guidance").trim() || "Ownership guidance";
154
+ const summary = String(ownershipGuidance.summary || "").trim();
155
+ const responsibilities = ensureArray(ownershipGuidance.responsibilities)
156
+ .map((value) => String(value || "").trim())
157
+ .filter(Boolean);
158
+ const examples = ensureArray(ownershipGuidance.examples)
159
+ .map((value) => String(value || "").trim())
160
+ .filter(Boolean);
161
+ stdout.write(`${color.heading(title)}\n`);
162
+ if (summary) {
163
+ stdout.write(`- ${summary}\n`);
164
+ }
165
+ for (const responsibility of responsibilities) {
166
+ stdout.write(`- ${responsibility}\n`);
167
+ }
168
+ if (examples.length > 0) {
169
+ stdout.write(`- ${color.dim("quick starts:")}\n`);
170
+ for (const example of examples) {
171
+ stdout.write(` ${example}\n`);
172
+ }
173
+ }
174
+ }
175
+
101
176
  if (placementOutlets.length > 0) {
102
177
  stdout.write(`${color.heading(`Placement outlets (${placementOutlets.length}):`)}\n`);
103
178
  for (const outlet of placementOutlets) {
@@ -223,22 +223,23 @@ const COMMAND_DESCRIPTORS = Object.freeze({
223
223
  "Short ids resolve to @jskit-ai/<id> when available.",
224
224
  "Running without args lists available generators.",
225
225
  "Running with only <generatorId> shows generator help.",
226
+ "For substantial non-CRUD server features, start with feature-server-generator scaffold.",
226
227
  "Use jskit generate <generatorId> <subcommand> help for subcommand-specific usage."
227
228
  ]),
228
229
  examples: Object.freeze([
229
230
  Object.freeze({
230
231
  label: "Common usage",
231
232
  lines: Object.freeze([
232
- "npx jskit generate ui-generator page \\",
233
- " admin/reports/index.vue"
233
+ "npx jskit generate feature-server-generator scaffold \\",
234
+ " booking-engine"
234
235
  ])
235
236
  }),
236
237
  Object.freeze({
237
238
  label: "More advanced usage",
238
239
  lines: Object.freeze([
239
- "npx jskit generate crud-ui-generator crud \\",
240
- " admin/catalog/index/products \\",
241
- " --resource-file packages/products/src/shared/productResource.js"
240
+ "npx jskit generate feature-server-generator scaffold \\",
241
+ " availability-engine \\",
242
+ " --mode orchestrator"
242
243
  ])
243
244
  })
244
245
  ]),
@@ -265,6 +266,7 @@ const COMMAND_DESCRIPTORS = Object.freeze({
265
266
  ]),
266
267
  defaults: Object.freeze([
267
268
  "Without mode, list prints bundles + runtime packages + generators.",
269
+ "Use list generators when you want the standard non-CRUD server lane command examples.",
268
270
  "placements are listed by the dedicated list-placements command.",
269
271
  "--full and --expanded only affect bundle/package listing views."
270
272
  ]),
@@ -338,6 +340,7 @@ const COMMAND_DESCRIPTORS = Object.freeze({
338
340
  ]),
339
341
  defaults: Object.freeze([
340
342
  "Basic output is compact; --details expands capability and runtime sections.",
343
+ "Generator packages may include lane ownership guidance and subcommand examples under --details.",
341
344
  "--debug-exports implies --details."
342
345
  ]),
343
346
  fullUse: "jskit show <id> [--details] [--debug-exports] [--json]",
@@ -39,6 +39,12 @@ function printTopLevelHelp(stream = process.stderr) {
39
39
  for (const entry of listOverviewCommandDescriptors()) {
40
40
  lines.push(` ${color.item(entry.command.padEnd(16, " "))} ${entry.summary}`);
41
41
  }
42
+ lines.push("");
43
+ lines.push(color.heading("Generator quick starts:"));
44
+ lines.push(" substantial non-CRUD server feature:");
45
+ lines.push(` ${color.item("jskit generate feature-server-generator scaffold booking-engine")}`);
46
+ lines.push(" non-persistent workflow/orchestrator:");
47
+ lines.push(` ${color.item("jskit generate feature-server-generator scaffold availability-engine --mode orchestrator")}`);
42
48
  writeHelpLines(stream, lines);
43
49
  }
44
50