@jskit-ai/jskit-cli 0.2.67 → 0.2.69

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.67",
3
+ "version": "0.2.69",
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.66",
24
- "@jskit-ai/kernel": "0.1.58",
25
- "@jskit-ai/shell-web": "0.1.57"
23
+ "@jskit-ai/jskit-catalog": "0.1.68",
24
+ "@jskit-ai/kernel": "0.1.60",
25
+ "@jskit-ai/shell-web": "0.1.59"
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;
@@ -30,18 +30,24 @@ const APP_COMMAND_DEFINITIONS = Object.freeze({
30
30
  verify: Object.freeze({
31
31
  name: "verify",
32
32
  summary: "Run the JSKIT baseline app verification flow.",
33
- usage: "jskit app verify",
34
- options: Object.freeze([]),
33
+ usage: "jskit app verify [--against <base-ref>]",
34
+ options: Object.freeze([
35
+ Object.freeze({
36
+ label: "--against <base-ref>",
37
+ description: "Resolve changed-file checks against a branch, tag, or commit in addition to any local dirty UI files."
38
+ })
39
+ ]),
35
40
  defaults: Object.freeze([
36
41
  "Runs npm scripts lint, test, test:client, and build only when those scripts are present.",
37
42
  "Runs jskit doctor after the normal app checks.",
43
+ "Use --against <base-ref> in CI or PR validation so doctor evaluates changed-file checks against the branch delta too.",
38
44
  "The scaffolded npm run verify wrapper can append npm run --if-present verify:app afterwards."
39
45
  ])
40
46
  }),
41
47
  "verify-ui": Object.freeze({
42
48
  name: "verify-ui",
43
49
  summary: "Run a targeted Playwright command and write a UI verification receipt for jskit doctor.",
44
- usage: "jskit app verify-ui --command <shell-command> --feature <label> --auth-mode <mode>",
50
+ usage: "jskit app verify-ui --command <shell-command> --feature <label> --auth-mode <mode> [--against <base-ref>]",
45
51
  options: Object.freeze([
46
52
  Object.freeze({
47
53
  label: "--command <shell-command>",
@@ -54,12 +60,16 @@ const APP_COMMAND_DEFINITIONS = Object.freeze({
54
60
  Object.freeze({
55
61
  label: "--auth-mode <mode>",
56
62
  description: "Auth path used by the Playwright flow: none | dev-auth-login-as | session-bootstrap | custom-local."
63
+ }),
64
+ Object.freeze({
65
+ label: "--against <base-ref>",
66
+ description: "Record changed UI files against a branch, tag, or commit instead of only the current dirty worktree."
57
67
  })
58
68
  ]),
59
69
  defaults: Object.freeze([
60
70
  "Requires a git working tree so the receipt can record the currently changed UI files.",
61
71
  "Writes .jskit/verification/ui.json after the command succeeds.",
62
- "Doctor expects the receipt to match the current dirty UI file set."
72
+ "Doctor expects the receipt to match the current dirty UI file set, or the same --against <base-ref> delta when used."
63
73
  ])
64
74
  }),
65
75
  "update-packages": Object.freeze({
@@ -176,6 +186,9 @@ function buildAppCommandOptionMeta(subcommandName = "") {
176
186
  if (definition.name === "update-packages" || definition.name === "release") {
177
187
  optionMeta.registry = { inputType: "text" };
178
188
  }
189
+ if (definition.name === "verify" || definition.name === "verify-ui") {
190
+ optionMeta.against = { inputType: "text" };
191
+ }
179
192
  if (definition.name === "verify-ui") {
180
193
  optionMeta.command = { inputType: "text" };
181
194
  optionMeta.feature = { inputType: "text" };
@@ -60,6 +60,17 @@ async function verifySymlinkTarget(targetPath = "", sourceDir = "", {
60
60
  }
61
61
  }
62
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
+
63
74
  async function maybeLinkCompanionPackages({
64
75
  appRoot = "",
65
76
  repoRoot = "",
@@ -105,15 +116,19 @@ async function maybeLinkCompanionPackages({
105
116
  );
106
117
  }
107
118
 
108
- const targetPath = path.join(appRoot, "node_modules", companion.packageName);
109
- await mkdir(path.dirname(targetPath), { recursive: true });
110
- await rm(targetPath, { recursive: true, force: true });
111
- await symlink(sourceDir, targetPath, resolveSymlinkType());
112
- await verifySymlinkTarget(targetPath, sourceDir, {
119
+ const appTargetPath = path.join(appRoot, "node_modules", companion.packageName);
120
+ await replaceWithSymlink(appTargetPath, sourceDir, {
113
121
  packageName: companion.packageName
114
122
  });
115
123
  stdout.write(`[link-local] linked ${companion.packageName} -> ${sourceDir}\n`);
116
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;
117
132
  }
118
133
 
119
134
  return linkedCount;
@@ -150,8 +165,9 @@ async function runAppLinkLocalPackagesCommand(ctx = {}, { appRoot = "", options
150
165
 
151
166
  for (const [packageDirName, sourceDir] of [...packageMap.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
152
167
  const targetPath = path.join(scopeDirectory, packageDirName);
153
- await rm(targetPath, { recursive: true, force: true });
154
- await symlink(sourceDir, targetPath, resolveSymlinkType());
168
+ await replaceWithSymlink(targetPath, sourceDir, {
169
+ packageName: `@jskit-ai/${packageDirName}`
170
+ });
155
171
  stdout.write(`[link-local] linked @jskit-ai/${packageDirName} -> ${sourceDir}\n`);
156
172
  await linkPackageBinEntries({
157
173
  appRoot,
@@ -9,6 +9,9 @@ const BASELINE_VERIFY_SCRIPTS = Object.freeze([
9
9
 
10
10
  async function runAppVerifyCommand(ctx = {}, { appRoot = "", options = {}, stdout, stderr }) {
11
11
  const { createCliError } = ctx;
12
+ const inlineOptions =
13
+ options?.inlineOptions && typeof options.inlineOptions === "object" ? options.inlineOptions : {};
14
+ const against = String(inlineOptions.against || "").trim();
12
15
 
13
16
  if (options?.dryRun) {
14
17
  throw createCliError("jskit app verify does not support --dry-run.", { exitCode: 1 });
@@ -23,7 +26,12 @@ async function runAppVerifyCommand(ctx = {}, { appRoot = "", options = {}, stdou
23
26
  });
24
27
  }
25
28
 
26
- await runLocalJskit(appRoot, ["doctor"], {
29
+ const doctorArgs = ["doctor"];
30
+ if (against) {
31
+ doctorArgs.push("--against", against);
32
+ }
33
+
34
+ await runLocalJskit(appRoot, doctorArgs, {
27
35
  stdout,
28
36
  stderr,
29
37
  createCliError
@@ -24,6 +24,7 @@ async function runAppVerifyUiCommand(ctx = {}, { appRoot = "", options = {}, std
24
24
  const command = String(inlineOptions.command || "").trim();
25
25
  const feature = String(inlineOptions.feature || "").trim();
26
26
  const authMode = String(inlineOptions["auth-mode"] || "").trim();
27
+ const against = String(inlineOptions.against || "").trim();
27
28
 
28
29
  if (!command) {
29
30
  throw createCliError("jskit app verify-ui requires --command <shell-command>.", {
@@ -52,16 +53,18 @@ async function runAppVerifyUiCommand(ctx = {}, { appRoot = "", options = {}, std
52
53
  );
53
54
  }
54
55
 
55
- const changedUiState = resolveChangedUiFilesFromGit(appRoot);
56
+ const changedUiState = resolveChangedUiFilesFromGit(appRoot, { against });
56
57
  if (!changedUiState.available) {
57
- throw createCliError("jskit app verify-ui requires a git working tree so it can record changed UI files.", {
58
- exitCode: 1
59
- });
58
+ const message = against
59
+ ? `jskit app verify-ui could not resolve changed UI files against ${JSON.stringify(against)}: ${changedUiState.error || "unknown git error"}.`
60
+ : "jskit app verify-ui requires a git working tree so it can record changed UI files.";
61
+ throw createCliError(message, { exitCode: 1 });
60
62
  }
61
63
  if (changedUiState.paths.length < 1) {
62
- throw createCliError("jskit app verify-ui found no changed UI files in src/ or packages/.", {
63
- exitCode: 1
64
- });
64
+ const message = against
65
+ ? `jskit app verify-ui found no changed UI files in src/ or packages/ against ${JSON.stringify(against)}.`
66
+ : "jskit app verify-ui found no changed UI files in src/ or packages/.";
67
+ throw createCliError(message, { exitCode: 1 });
65
68
  }
66
69
 
67
70
  runExternalShellCommand(command, {
@@ -84,6 +87,7 @@ async function runAppVerifyUiCommand(ctx = {}, { appRoot = "", options = {}, std
84
87
  feature,
85
88
  command,
86
89
  authMode,
90
+ ...(against ? { against } : {}),
87
91
  changedUiFiles: changedUiState.paths
88
92
  },
89
93
  null,
@@ -80,6 +80,34 @@ function createHealthCommands(ctx = {}) {
80
80
  "useCrudView",
81
81
  "useCrudAddEdit"
82
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;
83
111
 
84
112
  function collectDescriptorContainerTokens({ packageId, side, values, issues }) {
85
113
  const declaredTokens = new Set();
@@ -490,6 +518,331 @@ function createHealthCommands(ctx = {}) {
490
518
  return bindings;
491
519
  }
492
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
+
493
846
  function hasTopLevelObjectProperty(sourceText = "", propertyName = "") {
494
847
  const normalizedPropertyName = String(propertyName || "").trim();
495
848
  const normalizedSourceText = String(sourceText || "").trim();
@@ -801,20 +1154,32 @@ function createHealthCommands(ctx = {}) {
801
1154
  }
802
1155
  }
803
1156
 
804
- async function collectUiVerificationDoctorIssues({ appRoot, issues }) {
1157
+ async function collectUiVerificationDoctorIssues({ appRoot, issues, against = "" }) {
805
1158
  if (!(await directoryLooksLikeJskitAppRoot(appRoot))) {
806
1159
  return;
807
1160
  }
808
1161
 
809
- const changedUiState = resolveChangedUiFilesFromGit(appRoot);
810
- if (!changedUiState.available || changedUiState.paths.length < 1) {
1162
+ const normalizedAgainst = String(against || "").trim();
1163
+ const changedUiState = resolveChangedUiFilesFromGit(appRoot, {
1164
+ against: normalizedAgainst
1165
+ });
1166
+ if (!changedUiState.available) {
1167
+ if (normalizedAgainst) {
1168
+ issues.push(
1169
+ `[ui:verification] could not resolve changed UI files against ${JSON.stringify(normalizedAgainst)}: ${changedUiState.error || "unknown git error"}`
1170
+ );
1171
+ }
1172
+ return;
1173
+ }
1174
+ if (changedUiState.paths.length < 1) {
811
1175
  return;
812
1176
  }
813
1177
 
814
1178
  const receiptPath = path.join(appRoot, UI_VERIFICATION_RECEIPT_RELATIVE_PATH);
815
1179
  if (!(await fileExists(receiptPath))) {
1180
+ const againstSegment = normalizedAgainst ? ` --against ${JSON.stringify(normalizedAgainst)}` : "";
816
1181
  issues.push(
817
- `[ui:verification] changed UI files require a matching ${UI_VERIFICATION_RECEIPT_RELATIVE_PATH} receipt. Run jskit app verify-ui --command "<playwright command>" --feature "<label>" --auth-mode <mode>. Current files: ${changedUiState.paths.join(", ")}`
1182
+ `[ui:verification] changed UI files require a matching ${UI_VERIFICATION_RECEIPT_RELATIVE_PATH} receipt. Run jskit app verify-ui${againstSegment} --command "<playwright command>" --feature "<label>" --auth-mode <mode>. Current files: ${changedUiState.paths.join(", ")}`
818
1183
  );
819
1184
  return;
820
1185
  }
@@ -837,6 +1202,13 @@ function createHealthCommands(ctx = {}) {
837
1202
  return;
838
1203
  }
839
1204
 
1205
+ if (normalizedAgainst && receipt.against !== normalizedAgainst) {
1206
+ issues.push(
1207
+ `[ui:verification] ${UI_VERIFICATION_RECEIPT_RELATIVE_PATH} was recorded against ${JSON.stringify(receipt.against || "<dirty-worktree>")} but doctor is checking against ${JSON.stringify(normalizedAgainst)}. Re-run jskit app verify-ui with the same --against value.`
1208
+ );
1209
+ return;
1210
+ }
1211
+
840
1212
  if (JSON.stringify(receipt.changedUiFiles) !== JSON.stringify(changedUiState.paths)) {
841
1213
  issues.push(
842
1214
  `[ui:verification] ${UI_VERIFICATION_RECEIPT_RELATIVE_PATH} does not match the current changed UI file set. Re-run jskit app verify-ui after the latest UI edits. Current files: ${changedUiState.paths.join(", ")}`
@@ -898,11 +1270,13 @@ function createHealthCommands(ctx = {}) {
898
1270
 
899
1271
  async function commandDoctor({ cwd, options, stdout }) {
900
1272
  const appRoot = await resolveAppRootFromCwd(cwd);
1273
+ const against = String(options?.inlineOptions?.against || "").trim();
901
1274
  const { lock } = await loadLockFile(appRoot);
902
1275
  const packageRegistry = await loadPackageRegistry();
903
1276
  const appLocalRegistry = await loadAppLocalPackageRegistry(appRoot);
904
1277
  const combinedPackageRegistry = mergePackageRegistries(packageRegistry, appLocalRegistry);
905
1278
  const issues = [];
1279
+ const warnings = [];
906
1280
  const installed = ensureObject(lock.installedPackages);
907
1281
  await hydratePackageRegistryFromInstalledNodeModules({
908
1282
  appRoot,
@@ -938,14 +1312,22 @@ function createHealthCommands(ctx = {}) {
938
1312
  });
939
1313
  await collectUiVerificationDoctorIssues({
940
1314
  appRoot,
941
- issues
1315
+ issues,
1316
+ against
1317
+ });
1318
+ await collectFeatureLaneDoctorIssues({
1319
+ appRoot,
1320
+ appLocalRegistry,
1321
+ issues,
1322
+ warnings
942
1323
  });
943
1324
 
944
1325
  const payload = {
945
1326
  appRoot,
946
1327
  lockVersion: lock.lockVersion,
947
1328
  installedPackages: sortStrings(Object.keys(installed)),
948
- issues
1329
+ issues,
1330
+ warnings: sortStrings(warnings)
949
1331
  };
950
1332
 
951
1333
  if (options.json) {
@@ -953,13 +1335,21 @@ function createHealthCommands(ctx = {}) {
953
1335
  } else {
954
1336
  stdout.write(`App root: ${appRoot}\n`);
955
1337
  stdout.write(`Installed packages: ${payload.installedPackages.length}\n`);
956
- if (issues.length === 0) {
1338
+ if (issues.length === 0 && payload.warnings.length === 0) {
957
1339
  stdout.write("Doctor status: healthy\n");
1340
+ } else if (issues.length === 0) {
1341
+ stdout.write(`Doctor status: warnings (${payload.warnings.length} warning(s))\n`);
1342
+ for (const warning of payload.warnings) {
1343
+ stdout.write(`! ${warning}\n`);
1344
+ }
958
1345
  } else {
959
1346
  stdout.write(`Doctor status: unhealthy (${issues.length} issue(s))\n`);
960
1347
  for (const issue of issues) {
961
1348
  stdout.write(`- ${issue}\n`);
962
1349
  }
1350
+ for (const warning of payload.warnings) {
1351
+ stdout.write(`! ${warning}\n`);
1352
+ }
963
1353
  }
964
1354
  }
965
1355
 
@@ -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]",
@@ -472,14 +475,15 @@ const COMMAND_DESCRIPTORS = Object.freeze({
472
475
  defaults: Object.freeze([
473
476
  "Validates lock entries, managed files, and registry visibility.",
474
477
  "Reports issues as plain text by default.",
475
- "Use --json for machine-readable diagnostics."
478
+ "Use --json for machine-readable diagnostics.",
479
+ "Use --against <base-ref> when changed-file checks should compare against a branch, tag, or commit."
476
480
  ]),
477
- fullUse: "jskit doctor [--json]",
481
+ fullUse: "jskit doctor [--against <base-ref>] [--json]",
478
482
  showHelpOnBareInvocation: false,
479
483
  handlerName: "commandDoctor",
480
484
  allowedFlagKeys: Object.freeze(["json"]),
481
- inlineOptionMode: "none",
482
- allowedValueOptionNames: Object.freeze([])
485
+ inlineOptionMode: "enumerated",
486
+ allowedValueOptionNames: Object.freeze(["against"])
483
487
  }),
484
488
  "lint-descriptors": Object.freeze({
485
489
  command: "lint-descriptors",
@@ -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
 
@@ -61,17 +61,26 @@ function readGitPathList(appRoot = "", args = []) {
61
61
  if (result?.error || result?.status !== 0) {
62
62
  return {
63
63
  ok: false,
64
- paths: []
64
+ paths: [],
65
+ error: normalizeText(result?.stderr) || normalizeText(result?.error?.message)
65
66
  };
66
67
  }
67
68
 
68
69
  return {
69
70
  ok: true,
70
- paths: sortUniqueStrings(String(result.stdout || "").split(/\r?\n/u))
71
+ paths: sortUniqueStrings(String(result.stdout || "").split(/\r?\n/u)),
72
+ error: ""
71
73
  };
72
74
  }
73
75
 
74
- function resolveChangedUiFilesFromGit(appRoot = "") {
76
+ function resolveChangedPathsFromGit(
77
+ appRoot = "",
78
+ {
79
+ against = "",
80
+ pathspecs = ["src", "packages"]
81
+ } = {}
82
+ ) {
83
+ const normalizedAgainst = normalizeText(against);
75
84
  const gitRepoCheck = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
76
85
  cwd: appRoot || process.cwd(),
77
86
  encoding: "utf8"
@@ -84,26 +93,72 @@ function resolveChangedUiFilesFromGit(appRoot = "") {
84
93
  ) {
85
94
  return {
86
95
  available: false,
87
- paths: []
96
+ paths: [],
97
+ against: normalizedAgainst,
98
+ mode: normalizedAgainst ? "against-base-ref" : "dirty-worktree",
99
+ error: "Current directory is not inside a git work tree."
88
100
  };
89
101
  }
90
102
 
91
- const changedPathSets = [
92
- readGitPathList(appRoot, ["diff", "--name-only", "--relative", "--cached", "--", "src", "packages"]),
93
- readGitPathList(appRoot, ["diff", "--name-only", "--relative", "--", "src", "packages"]),
94
- readGitPathList(appRoot, ["ls-files", "--others", "--exclude-standard", "--", "src", "packages"])
95
- ];
103
+ const normalizedPathspecs = sortUniqueStrings(pathspecs);
104
+ const pathspecArgs = normalizedPathspecs.length > 0 ? ["--", ...normalizedPathspecs] : [];
105
+ const changedPathSets = [];
106
+
107
+ if (normalizedAgainst) {
108
+ const baseDiff = readGitPathList(appRoot, [
109
+ "diff",
110
+ "--name-only",
111
+ "--relative",
112
+ `${normalizedAgainst}...HEAD`,
113
+ ...pathspecArgs
114
+ ]);
115
+ if (!baseDiff.ok) {
116
+ return {
117
+ available: false,
118
+ paths: [],
119
+ against: normalizedAgainst,
120
+ mode: "against-base-ref",
121
+ error:
122
+ baseDiff.error ||
123
+ `Could not resolve git diff against ${JSON.stringify(normalizedAgainst)}.`
124
+ };
125
+ }
126
+ changedPathSets.push(baseDiff);
127
+ }
128
+
129
+ changedPathSets.push(
130
+ readGitPathList(appRoot, ["diff", "--name-only", "--relative", "--cached", ...pathspecArgs]),
131
+ readGitPathList(appRoot, ["diff", "--name-only", "--relative", ...pathspecArgs]),
132
+ readGitPathList(appRoot, ["ls-files", "--others", "--exclude-standard", ...pathspecArgs])
133
+ );
96
134
 
97
135
  const changedPaths = sortUniqueStrings(
98
136
  changedPathSets
99
137
  .filter((entry) => entry.ok)
100
138
  .flatMap((entry) => entry.paths)
101
- .filter((relativePath) => isUiVerificationPath(relativePath))
102
139
  );
103
140
 
104
141
  return {
105
142
  available: true,
106
- paths: changedPaths
143
+ paths: changedPaths,
144
+ against: normalizedAgainst,
145
+ mode: normalizedAgainst ? "against-base-ref" : "dirty-worktree",
146
+ error: ""
147
+ };
148
+ }
149
+
150
+ function resolveChangedUiFilesFromGit(appRoot = "", { against = "" } = {}) {
151
+ const changedPathState = resolveChangedPathsFromGit(appRoot, {
152
+ against,
153
+ pathspecs: ["src", "packages"]
154
+ });
155
+ if (!changedPathState.available) {
156
+ return changedPathState;
157
+ }
158
+
159
+ return {
160
+ ...changedPathState,
161
+ paths: changedPathState.paths.filter((relativePath) => isUiVerificationPath(relativePath))
107
162
  };
108
163
  }
109
164
 
@@ -117,6 +172,7 @@ function normalizeUiVerificationReceipt(rawReceipt = {}) {
117
172
  feature: normalizeText(receipt.feature),
118
173
  command: normalizeText(receipt.command),
119
174
  authMode: normalizeText(receipt.authMode),
175
+ against: normalizeText(receipt.against),
120
176
  changedUiFiles: sortUniqueStrings(receipt.changedUiFiles)
121
177
  });
122
178
  }
@@ -143,6 +199,7 @@ export {
143
199
  isUiVerificationPath,
144
200
  isValidUiVerificationReceipt,
145
201
  normalizeUiVerificationReceipt,
202
+ resolveChangedPathsFromGit,
146
203
  resolveChangedUiFilesFromGit,
147
204
  sortUniqueStrings
148
205
  };