@jskit-ai/jskit-cli 0.2.75 → 0.2.76

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.75",
3
+ "version": "0.2.76",
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.74",
24
- "@jskit-ai/kernel": "0.1.66",
25
- "@jskit-ai/shell-web": "0.1.65"
23
+ "@jskit-ai/jskit-catalog": "0.1.75",
24
+ "@jskit-ai/kernel": "0.1.67",
25
+ "@jskit-ai/shell-web": "0.1.66"
26
26
  },
27
27
  "engines": {
28
28
  "node": "20.x"
@@ -113,6 +113,13 @@ async function applyTextMutations(
113
113
  const previous = await readFileBufferIfExists(absoluteFile);
114
114
  const previousContent = previous.exists ? previous.buffer.toString("utf8") : "";
115
115
  const mutationId = String(mutation?.id || "").trim() || "append-text";
116
+ const interpolatedSkipChecks = normalizeSkipChecks(mutation?.skipIfContains)
117
+ .map((entry) => interpolateOptionValue(entry, options, packageEntry.packageId, `${mutationId}.skipIfContains`))
118
+ .filter((entry) => String(entry || "").trim().length > 0);
119
+ if (interpolatedSkipChecks.some((pattern) => previousContent.includes(String(pattern)))) {
120
+ continue;
121
+ }
122
+
116
123
  const resolvedSnippet = interpolateOptionValue(snippet, options, packageEntry.packageId, mutationId);
117
124
  const templateContextReplacements = await resolveTemplateContextReplacementsForMutation({
118
125
  packageEntry,
@@ -126,8 +133,7 @@ async function applyTextMutations(
126
133
  const renderedSnippet = templateContextReplacements
127
134
  ? applyTemplateContextReplacements(resolvedSnippet, templateContextReplacements)
128
135
  : resolvedSnippet;
129
- const skipChecks = normalizeSkipChecks(mutation?.skipIfContains)
130
- .map((entry) => interpolateOptionValue(entry, options, packageEntry.packageId, `${mutationId}.skipIfContains`))
136
+ const skipChecks = interpolatedSkipChecks
131
137
  .map((entry) => {
132
138
  if (!templateContextReplacements) {
133
139
  return entry;
@@ -1825,8 +1825,12 @@ function createHealthCommands(ctx = {}) {
1825
1825
  return false;
1826
1826
  }
1827
1827
 
1828
- function isSharedListFiltersImportSource(sourcePath = "") {
1829
- return /(^|\/)shared\/[^/'"]*ListFilters(?:\.[A-Za-z0-9]+)?$/u.test(String(sourcePath || "").trim());
1828
+ function isListFiltersImportSource(sourcePath = "") {
1829
+ const normalizedSource = String(sourcePath || "").trim();
1830
+ return (
1831
+ /(^|\/)shared\/[^/'"]*ListFilters(?:\.[A-Za-z0-9]+)?$/u.test(normalizedSource) ||
1832
+ /^\.\/listFilters\.[A-Za-z0-9]+$/u.test(normalizedSource)
1833
+ );
1830
1834
  }
1831
1835
 
1832
1836
  function findCallSites(sourceText = "", calleeName = "") {
@@ -1872,22 +1876,22 @@ function createHealthCommands(ctx = {}) {
1872
1876
 
1873
1877
  if (!firstArgument || firstArgument.startsWith("{")) {
1874
1878
  issues.push(
1875
- `${relativePath}:${lineNumber}: [filters:shared-definition] do not inline structured filter definitions in ${calleeName}(...). Put them in packages/<crud>/src/shared/<crud>ListFilters.js and import that shared module.`
1879
+ `${relativePath}:${lineNumber}: [filters:shared-definition] do not inline structured filter definitions in ${calleeName}(...). Put them in listFilters.js or packages/<crud>/src/shared/<crud>ListFilters.js and import that module.`
1876
1880
  );
1877
1881
  continue;
1878
1882
  }
1879
1883
 
1880
1884
  if (!/^[A-Za-z_$][\w$]*$/u.test(firstArgument)) {
1881
1885
  issues.push(
1882
- `${relativePath}:${lineNumber}: [filters:shared-definition] ${calleeName}(...) must receive a definitions symbol imported from a CRUD shared *ListFilters module, not an ad-hoc expression.`
1886
+ `${relativePath}:${lineNumber}: [filters:shared-definition] ${calleeName}(...) must receive a definitions symbol imported from listFilters.js or a CRUD shared *ListFilters module, not an ad-hoc expression.`
1883
1887
  );
1884
1888
  continue;
1885
1889
  }
1886
1890
 
1887
1891
  const importSource = importBindings.get(firstArgument) || "";
1888
- if (!isSharedListFiltersImportSource(importSource)) {
1892
+ if (!isListFiltersImportSource(importSource)) {
1889
1893
  issues.push(
1890
- `${relativePath}:${lineNumber}: [filters:shared-definition] ${calleeName}(${firstArgument}, ...) must use definitions imported from a CRUD shared *ListFilters module. Found ${importSource ? `import source "${importSource}"` : "a local symbol"} instead.`
1894
+ `${relativePath}:${lineNumber}: [filters:shared-definition] ${calleeName}(${firstArgument}, ...) must use definitions imported from listFilters.js or a CRUD shared *ListFilters module. Found ${importSource ? `import source "${importSource}"` : "a local symbol"} instead.`
1891
1895
  );
1892
1896
  }
1893
1897
  }
@@ -14,6 +14,9 @@ const REGISTER_MAIN_CLIENT_COMPONENT_PATTERN = /registerMainClientComponent\(\s*
14
14
  const LINK_ITEM_TOKEN_SUFFIX = "link-item";
15
15
  const JSKIT_SCOPE_PREFIX = "@jskit-ai/";
16
16
  const FEATURE_SERVER_GENERATOR_PACKAGE_ID = "@jskit-ai/feature-server-generator";
17
+ const PLACEMENT_LAYOUT_CLASSES = Object.freeze(["compact", "medium", "expanded"]);
18
+ const PLACEMENT_KIND_COMPONENT = "component";
19
+ const PLACEMENT_KIND_LINK = "link";
17
20
  const PROVIDER_SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx"]);
18
21
  const READ_FILE_IGNORE_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EISDIR", "EACCES", "EPERM"]);
19
22
  const READ_DIRECTORY_IGNORE_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
@@ -82,6 +85,331 @@ function isLinkItemToken(token = "") {
82
85
  return String(token || "").trim().toLowerCase().endsWith(LINK_ITEM_TOKEN_SUFFIX);
83
86
  }
84
87
 
88
+ function collectPlacementRendererKinds(placementTarget = {}) {
89
+ const kinds = new Set();
90
+ const variants = ensureObject(placementTarget.variants);
91
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
92
+ const variant = ensureObject(variants[layoutClass]);
93
+ const renderers = ensureObject(variant.renderers);
94
+ for (const kind of Object.keys(renderers)) {
95
+ const normalizedKind = String(kind || "").trim();
96
+ if (normalizedKind) {
97
+ kinds.add(normalizedKind);
98
+ }
99
+ }
100
+ }
101
+ if (kinds.size < 1) {
102
+ kinds.add(PLACEMENT_KIND_COMPONENT);
103
+ }
104
+ return sortStrings([...kinds]);
105
+ }
106
+
107
+ function collectPlacementConcreteOutlets(placementTarget = {}) {
108
+ const outlets = new Set();
109
+ const variants = ensureObject(placementTarget.variants);
110
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
111
+ const variant = ensureObject(variants[layoutClass]);
112
+ const outlet = String(variant.outlet || "").trim();
113
+ if (outlet) {
114
+ outlets.add(outlet);
115
+ }
116
+ }
117
+ return sortStrings([...outlets]);
118
+ }
119
+
120
+ function classifyPlacementTarget(placementTarget = {}) {
121
+ const owner = String(placementTarget.owner || "").trim();
122
+ const kinds = collectPlacementRendererKinds(placementTarget);
123
+ const acceptsLinks = kinds.includes(PLACEMENT_KIND_LINK);
124
+ const acceptsComponents = kinds.includes(PLACEMENT_KIND_COMPONENT);
125
+
126
+ if (acceptsLinks && acceptsComponents) {
127
+ return owner ? "ownerMixed" : "mixed";
128
+ }
129
+ if (acceptsLinks) {
130
+ return owner ? "ownerLinks" : "links";
131
+ }
132
+ return owner ? "ownerComponents" : "components";
133
+ }
134
+
135
+ function createConcreteTargetSourcePathMap(concreteTargets = []) {
136
+ const sourcePathByTarget = new Map();
137
+ for (const concreteTarget of ensureArray(concreteTargets)) {
138
+ const target = String(ensureObject(concreteTarget).id || "").trim();
139
+ const sourcePath = String(ensureObject(concreteTarget).sourcePath || "").trim();
140
+ if (target && sourcePath && !sourcePathByTarget.has(target)) {
141
+ sourcePathByTarget.set(target, sourcePath);
142
+ }
143
+ }
144
+ return sourcePathByTarget;
145
+ }
146
+
147
+ function resolveChildPagePatternFromHostSourcePath(sourcePath = "") {
148
+ const normalizedPath = String(sourcePath || "").replaceAll("\\", "/").trim();
149
+ if (!normalizedPath.startsWith("src/pages/")) {
150
+ return "";
151
+ }
152
+ const pagePath = normalizedPath.slice("src/pages/".length);
153
+ if (pagePath.endsWith("/index.vue")) {
154
+ const hostPath = pagePath.slice(0, -"/index.vue".length);
155
+ return hostPath
156
+ ? `${hostPath}/<page>.vue or ${hostPath}/<page>/index.vue`
157
+ : "<page>.vue or <page>/index.vue";
158
+ }
159
+ if (pagePath.endsWith(".vue")) {
160
+ const hostPath = pagePath.slice(0, -".vue".length);
161
+ return hostPath
162
+ ? `${hostPath}/<page>.vue or ${hostPath}/<page>/index.vue`
163
+ : "<page>.vue or <page>/index.vue";
164
+ }
165
+ return "";
166
+ }
167
+
168
+ function resolveOwnerScopedChildPagePattern(placementTarget = {}, concreteSourcePathByTarget = new Map()) {
169
+ const outlets = collectPlacementConcreteOutlets(placementTarget);
170
+ for (const outlet of outlets) {
171
+ const childPagePattern = resolveChildPagePatternFromHostSourcePath(
172
+ concreteSourcePathByTarget.get(outlet) || ""
173
+ );
174
+ if (childPagePattern) {
175
+ return childPagePattern;
176
+ }
177
+ }
178
+ return "";
179
+ }
180
+
181
+ function createPlacementTargetSummary(placementTarget = {}, color, { concreteSourcePathByTarget = new Map() } = {}) {
182
+ const placementId = String(placementTarget.id || "").trim();
183
+ const owner = String(placementTarget.owner || "").trim();
184
+ const ownerLabel = owner ? color.dim(` [owner:${owner}]`) : "";
185
+ const defaultLabel = placementTarget.default === true ? color.installed(" (default)") : "";
186
+ const description = String(placementTarget.description || "").trim();
187
+ const childPagePattern = owner
188
+ ? resolveOwnerScopedChildPagePattern(placementTarget, concreteSourcePathByTarget)
189
+ : "";
190
+ const childPagePatternLabel = childPagePattern ? ` -> ${color.dim(childPagePattern)}` : "";
191
+ const descriptionSuffix = description ? `: ${description}` : "";
192
+ return `- ${color.item(placementId)}${ownerLabel}${defaultLabel}${childPagePatternLabel}${descriptionSuffix}`;
193
+ }
194
+
195
+ function appendPlacementLayoutDetails(lines, placementTarget = {}, color) {
196
+ const variants = ensureObject(placementTarget.variants);
197
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
198
+ const variant = ensureObject(variants[layoutClass]);
199
+ const outlet = String(variant.outlet || "").trim();
200
+ if (outlet) {
201
+ lines.push(` ${layoutClass} -> ${color.dim(outlet)}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ function collectMappedConcreteOutletIds(semanticPlacements = []) {
207
+ const mapped = new Set();
208
+ for (const placementTarget of ensureArray(semanticPlacements)) {
209
+ const variants = ensureObject(placementTarget.variants);
210
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
211
+ const variant = ensureObject(variants[layoutClass]);
212
+ const outlet = String(variant.outlet || "").trim();
213
+ if (outlet) {
214
+ mapped.add(outlet);
215
+ }
216
+ }
217
+ }
218
+ return mapped;
219
+ }
220
+
221
+ function resolveUnmappedConcreteTargets({
222
+ semanticPlacements = [],
223
+ concreteTargets = []
224
+ } = {}) {
225
+ const mappedConcreteOutletIds = collectMappedConcreteOutletIds(semanticPlacements);
226
+ return ensureArray(concreteTargets)
227
+ .map((entry) => ensureObject(entry))
228
+ .filter((entry) => {
229
+ const id = String(entry.id || "").trim();
230
+ return id && !mappedConcreteOutletIds.has(id);
231
+ })
232
+ .sort((left, right) => String(left.id || "").localeCompare(String(right.id || "")));
233
+ }
234
+
235
+ function formatPlacementGuidanceLine(line = "", color) {
236
+ const guidanceLine = String(line || "").trim();
237
+ const labelMatch = /^([^:]+):\s*(.*)$/u.exec(guidanceLine);
238
+ if (!labelMatch) {
239
+ return ` ${color.dim(guidanceLine)}`;
240
+ }
241
+ const label = String(labelMatch[1] || "").trim();
242
+ const value = String(labelMatch[2] || "").trim();
243
+ const renderedLabel = color.installed(`${label}:`);
244
+ const renderedValue = value ? ` ${color.provider(value)}` : "";
245
+ return ` ${renderedLabel}${renderedValue}`;
246
+ }
247
+
248
+ function appendPlacementGroup(lines, {
249
+ color,
250
+ title = "",
251
+ description = "",
252
+ guidance = [],
253
+ targets = [],
254
+ concreteSourcePathByTarget = new Map(),
255
+ showLayoutDetails = false
256
+ } = {}) {
257
+ const placementTargets = ensureArray(targets);
258
+ if (placementTargets.length < 1) {
259
+ return;
260
+ }
261
+ if (lines.length > 1) {
262
+ lines.push("");
263
+ }
264
+ lines.push(color.heading(title));
265
+ if (description) {
266
+ lines.push(description);
267
+ }
268
+ const guidanceLines = ensureArray(guidance).map((value) => String(value || "").trim()).filter(Boolean);
269
+ for (const guidanceLine of guidanceLines) {
270
+ lines.push(formatPlacementGuidanceLine(guidanceLine, color));
271
+ }
272
+ for (const placementTarget of placementTargets) {
273
+ lines.push(createPlacementTargetSummary(placementTarget, color, { concreteSourcePathByTarget }));
274
+ if (showLayoutDetails) {
275
+ appendPlacementLayoutDetails(lines, placementTarget, color);
276
+ }
277
+ }
278
+ }
279
+
280
+ function appendUnmappedConcreteTargetWarnings(lines, {
281
+ color,
282
+ concreteTargets = []
283
+ } = {}) {
284
+ const targets = ensureArray(concreteTargets);
285
+ if (targets.length < 1) {
286
+ return;
287
+ }
288
+
289
+ if (lines.length > 1) {
290
+ lines.push("");
291
+ }
292
+ lines.push(color.heading("Unmapped concrete outlets:"));
293
+ lines.push(
294
+ "These concrete ShellOutlet recipients are discoverable but are not reached by semantic topology. Add a semantic mapping or keep them private/internal."
295
+ );
296
+ lines.push(
297
+ color.dim(
298
+ "Format: npx jskit generate ui-generator topology --placement <area.slot> --kind <link|component> --compact-target <host:position> --medium-target <host:position> --expanded-target <host:position>"
299
+ )
300
+ );
301
+ for (const placementTarget of targets) {
302
+ const placementId = String(placementTarget.id || "").trim();
303
+ const sourcePath = String(placementTarget.sourcePath || "").trim();
304
+ const sourceLabel = sourcePath ? ` ${color.dim(`[${sourcePath}]`)}` : "";
305
+ lines.push(`- ${color.item(placementId)}${sourceLabel}`);
306
+ }
307
+ }
308
+
309
+ function appendSemanticPlacementGroups(lines, {
310
+ color,
311
+ semanticPlacements = [],
312
+ concreteTargets = [],
313
+ showLayoutDetails = false
314
+ } = {}) {
315
+ const concreteSourcePathByTarget = createConcreteTargetSourcePathMap(concreteTargets);
316
+ const groupedTargets = {
317
+ links: [],
318
+ ownerLinks: [],
319
+ components: [],
320
+ ownerComponents: [],
321
+ mixed: [],
322
+ ownerMixed: []
323
+ };
324
+
325
+ for (const placementTarget of ensureArray(semanticPlacements)) {
326
+ const groupKey = classifyPlacementTarget(placementTarget);
327
+ if (groupedTargets[groupKey]) {
328
+ groupedTargets[groupKey].push(placementTarget);
329
+ }
330
+ }
331
+
332
+ appendPlacementGroup(lines, {
333
+ color,
334
+ title: "Navigation link placements",
335
+ guidance: [
336
+ 'Format: npx jskit generate ui-generator page <page-file> --name "Label" --link-placement <placement>',
337
+ 'Example: npx jskit generate ui-generator page admin/reports/index.vue --name "Reports" --link-placement admin.tools-menu'
338
+ ],
339
+ targets: groupedTargets.links,
340
+ concreteSourcePathByTarget,
341
+ showLayoutDetails
342
+ });
343
+
344
+ appendPlacementGroup(lines, {
345
+ color,
346
+ title: "Owner-scoped navigation link placements",
347
+ guidance: [
348
+ 'Format: npx jskit generate ui-generator page <host-path>/<page>.vue --name "Label"',
349
+ 'Alternative: npx jskit generate ui-generator page <host-path>/<page>/index.vue --name "Label"',
350
+ 'Example: npx jskit generate ui-generator page home/settings/profile.vue --name "Profile"'
351
+ ],
352
+ targets: groupedTargets.ownerLinks,
353
+ concreteSourcePathByTarget,
354
+ showLayoutDetails
355
+ });
356
+
357
+ appendPlacementGroup(lines, {
358
+ color,
359
+ title: "Component, widget, and section placements",
360
+ guidance: [
361
+ 'Format: npx jskit generate ui-generator placed-element --name "Widget Name" --placement <placement>',
362
+ 'Example: npx jskit generate ui-generator placed-element --name "Connection Status" --placement shell.status'
363
+ ],
364
+ targets: groupedTargets.components,
365
+ concreteSourcePathByTarget,
366
+ showLayoutDetails
367
+ });
368
+
369
+ appendPlacementGroup(lines, {
370
+ color,
371
+ title: "Owner-scoped component and section placements",
372
+ guidance: [
373
+ 'Format: npx jskit generate ui-generator placed-element --name "Section Name" --placement <placement> --owner <owner>',
374
+ 'Example: npx jskit generate ui-generator placed-element --name "Security Section" --placement settings.sections --owner account-settings'
375
+ ],
376
+ targets: groupedTargets.ownerComponents,
377
+ concreteSourcePathByTarget,
378
+ showLayoutDetails
379
+ });
380
+
381
+ appendPlacementGroup(lines, {
382
+ color,
383
+ title: "Mixed-kind placements",
384
+ description: "These topology entries accept more than one semantic kind. Choose the generator by what you are adding.",
385
+ guidance: [
386
+ 'Link format: npx jskit generate ui-generator page <page-file> --name "Label" --link-placement <placement>',
387
+ 'Component format: npx jskit generate ui-generator placed-element --name "Widget Name" --placement <placement>',
388
+ 'Link example: npx jskit generate ui-generator page admin/reports/index.vue --name "Reports" --link-placement <placement>',
389
+ 'Component example: npx jskit generate ui-generator placed-element --name "Ops Panel" --placement <placement>'
390
+ ],
391
+ targets: groupedTargets.mixed,
392
+ concreteSourcePathByTarget,
393
+ showLayoutDetails
394
+ });
395
+
396
+ appendPlacementGroup(lines, {
397
+ color,
398
+ title: "Owner-scoped mixed-kind placements",
399
+ description: "These owner-scoped entries accept more than one semantic kind. Include the owner when adding manual placement entries.",
400
+ guidance: [
401
+ 'Link format: npx jskit generate ui-generator page <host-path>/<page>.vue --name "Label"',
402
+ 'Link alternative: npx jskit generate ui-generator page <host-path>/<page>/index.vue --name "Label"',
403
+ 'Component format: npx jskit generate ui-generator placed-element --name "Widget Name" --placement <placement> --owner <owner>',
404
+ 'Link example: npx jskit generate ui-generator page home/settings/profile.vue --name "Profile"',
405
+ 'Component example: npx jskit generate ui-generator placed-element --name "Security Section" --placement <placement> --owner <owner>'
406
+ ],
407
+ targets: groupedTargets.ownerMixed,
408
+ concreteSourcePathByTarget,
409
+ showLayoutDetails
410
+ });
411
+ }
412
+
85
413
  async function readFileIfExists(filePath = "") {
86
414
  try {
87
415
  return await readFile(filePath, "utf8");
@@ -178,6 +506,7 @@ function createListCommands(ctx = {}) {
178
506
  loadAppLocalPackageRegistry,
179
507
  resolveInstalledNodeModulePackageEntry,
180
508
  discoverPlacementTopologyFromApp,
509
+ discoverShellOutletSourcePathsFromApp,
181
510
  discoverShellOutletTargetsFromApp,
182
511
  normalizePlacementContributions,
183
512
  resolvePackageKind
@@ -439,6 +768,8 @@ function createListCommands(ctx = {}) {
439
768
  const showConcreteOnly = options.concrete === true && options.all !== true;
440
769
  const showConcrete = options.concrete === true || options.all === true;
441
770
  const showSemantic = showConcreteOnly !== true;
771
+ const showLayoutDetails = options.details === true;
772
+ const shouldLookupHostPaths = showSemantic;
442
773
  const discoveredTopology = await discoverPlacementTopologyFromApp({ appRoot });
443
774
  const semanticPlacements = ensureArray(discoveredTopology.placements)
444
775
  .map((entry) => ensureObject(entry))
@@ -450,16 +781,28 @@ function createListCommands(ctx = {}) {
450
781
  }
451
782
  return String(left.owner || "").localeCompare(String(right.owner || ""));
452
783
  });
453
- const discoveredConcrete = showConcrete
454
- ? await discoverShellOutletTargetsFromApp({
784
+ let discoveredConcrete = { targets: [] };
785
+ if (showConcrete) {
786
+ discoveredConcrete = await discoverShellOutletTargetsFromApp({
455
787
  appRoot,
456
788
  sourceRoot: "src"
457
- })
458
- : { targets: [] };
789
+ });
790
+ } else if (shouldLookupHostPaths) {
791
+ discoveredConcrete = await discoverShellOutletSourcePathsFromApp({
792
+ appRoot,
793
+ sourceRoot: "src"
794
+ });
795
+ }
459
796
  const concreteTargets = ensureArray(discoveredConcrete.targets)
460
797
  .map((entry) => ensureObject(entry))
461
798
  .filter((entry) => String(entry.id || "").trim())
462
799
  .sort((left, right) => String(left.id || "").localeCompare(String(right.id || "")));
800
+ const unmappedConcreteTargets = showSemantic
801
+ ? resolveUnmappedConcreteTargets({
802
+ semanticPlacements,
803
+ concreteTargets
804
+ })
805
+ : [];
463
806
 
464
807
  if (options.json) {
465
808
  const payload = {
@@ -480,7 +823,12 @@ function createListCommands(ctx = {}) {
480
823
  default: placementTarget.default === true,
481
824
  sourcePath: String(placementTarget.sourcePath || "").trim()
482
825
  }))
483
- : []
826
+ : [],
827
+ unmappedConcretePlacements: unmappedConcreteTargets.map((placementTarget) => ({
828
+ target: String(placementTarget.id || "").trim(),
829
+ default: placementTarget.default === true,
830
+ sourcePath: String(placementTarget.sourcePath || "").trim()
831
+ }))
484
832
  };
485
833
  stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
486
834
  return 0;
@@ -490,27 +838,21 @@ function createListCommands(ctx = {}) {
490
838
  const lines = [];
491
839
  if (showSemantic) {
492
840
  lines.push(color.heading("Available placements:"));
841
+ lines.push("Semantic placement targets are the stable IDs you should author against. Use --concrete only for low-level ShellOutlet recipients.");
493
842
  if (semanticPlacements.length < 1) {
494
843
  lines.push("- none");
495
844
  } else {
496
- for (const placementTarget of semanticPlacements) {
497
- const placementId = String(placementTarget.id || "").trim();
498
- const owner = String(placementTarget.owner || "").trim();
499
- const ownerLabel = owner ? color.dim(` [owner:${owner}]`) : "";
500
- const defaultLabel = placementTarget.default === true ? color.installed(" (default)") : "";
501
- const description = String(placementTarget.description || "").trim();
502
- const descriptionSuffix = description ? `: ${description}` : "";
503
- lines.push(`- ${color.item(placementId)}${ownerLabel}${defaultLabel}${descriptionSuffix}`);
504
- const variants = ensureObject(placementTarget.variants);
505
- for (const layoutClass of ["compact", "medium", "expanded"]) {
506
- const variant = ensureObject(variants[layoutClass]);
507
- const outlet = String(variant.outlet || "").trim();
508
- if (outlet) {
509
- lines.push(` ${layoutClass} -> ${color.dim(outlet)}`);
510
- }
511
- }
512
- }
845
+ appendSemanticPlacementGroups(lines, {
846
+ color,
847
+ semanticPlacements,
848
+ concreteTargets,
849
+ showLayoutDetails
850
+ });
513
851
  }
852
+ appendUnmappedConcreteTargetWarnings(lines, {
853
+ color,
854
+ concreteTargets: unmappedConcreteTargets
855
+ });
514
856
  }
515
857
 
516
858
  if (showConcrete) {
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { spawn } from "node:child_process";
3
- import { readFile } from "node:fs/promises";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
4
  import {
5
5
  createColorFormatter,
6
6
  writeWrappedLines
@@ -23,6 +23,10 @@ import {
23
23
  } from "./mobileShellSupport.js";
24
24
  const CAPACITOR_RUNTIME_PACKAGE_ID = "@jskit-ai/mobile-capacitor";
25
25
  const MOBILE_NOTES_RELATIVE_PATH = path.join(".jskit", "mobile-capacitor.md");
26
+ const MANAGED_MOBILE_FILE_RELATIVE_PATHS = Object.freeze([
27
+ "capacitor.config.json",
28
+ MOBILE_NOTES_RELATIVE_PATH
29
+ ]);
26
30
 
27
31
  async function collectManagedMobileFileDriftIssues({
28
32
  ctx,
@@ -34,12 +38,7 @@ async function collectManagedMobileFileDriftIssues({
34
38
  path: pathModule,
35
39
  normalizeRelativePath
36
40
  } = ctx;
37
- const managedRelativePaths = [
38
- "capacitor.config.json",
39
- MOBILE_NOTES_RELATIVE_PATH
40
- ];
41
-
42
- for (const relativePath of managedRelativePaths) {
41
+ for (const relativePath of MANAGED_MOBILE_FILE_RELATIVE_PATHS) {
43
42
  const absolutePath = pathModule.join(appRoot, relativePath);
44
43
  if (!(await fileExists(absolutePath))) {
45
44
  continue;
@@ -66,46 +65,6 @@ async function collectManagedMobileFileDriftIssues({
66
65
  }
67
66
  }
68
67
 
69
- async function collectMissingInstalledDependencyNames(ctx, appRoot = "", packageJson = {}) {
70
- const {
71
- fileExists,
72
- path: pathModule
73
- } = ctx;
74
- const sections = [
75
- packageJson?.dependencies,
76
- packageJson?.devDependencies,
77
- packageJson?.optionalDependencies
78
- ];
79
- const missing = [];
80
- const seen = new Set();
81
-
82
- for (const section of sections) {
83
- if (!section || typeof section !== "object" || Array.isArray(section)) {
84
- continue;
85
- }
86
-
87
- for (const packageName of Object.keys(section).sort((left, right) => left.localeCompare(right))) {
88
- const normalizedPackageName = String(packageName || "").trim();
89
- if (!normalizedPackageName || seen.has(normalizedPackageName)) {
90
- continue;
91
- }
92
- seen.add(normalizedPackageName);
93
-
94
- const packageJsonPath = pathModule.join(
95
- appRoot,
96
- "node_modules",
97
- ...normalizedPackageName.split("/"),
98
- "package.json"
99
- );
100
- if (!(await fileExists(packageJsonPath))) {
101
- missing.push(normalizedPackageName);
102
- }
103
- }
104
- }
105
-
106
- return missing;
107
- }
108
-
109
68
  function renderAndroidMobileCommandList(lines, color) {
110
69
  for (const entry of listMobileCommandDefinitions()) {
111
70
  if (entry.name === "dev") {
@@ -429,9 +388,13 @@ async function runLocalBinary(binaryName, args = [], {
429
388
 
430
389
  child.on("error", (error) => {
431
390
  if (error?.code === "ENOENT") {
391
+ const installHint =
392
+ binaryName === "cap"
393
+ ? ` Run npm install after adding ${CAPACITOR_RUNTIME_PACKAGE_ID}, then rerun this command.`
394
+ : "";
432
395
  reject(
433
396
  createCliError(
434
- `Could not find local "${binaryName}" in node_modules/.bin. Re-run jskit add package @jskit-ai/mobile-capacitor after npm install succeeds.`
397
+ `Could not find local "${binaryName}" in node_modules/.bin.${installHint}`
435
398
  )
436
399
  );
437
400
  return;
@@ -451,100 +414,93 @@ async function runLocalBinary(binaryName, args = [], {
451
414
  });
452
415
  }
453
416
 
454
- async function runMobileAppInstall({
417
+ function hasPackageDependency(packageJson = {}, packageId = "") {
418
+ const sections = [
419
+ packageJson?.dependencies,
420
+ packageJson?.devDependencies,
421
+ packageJson?.optionalDependencies
422
+ ];
423
+ return sections.some((section) => (
424
+ section &&
425
+ typeof section === "object" &&
426
+ !Array.isArray(section) &&
427
+ Object.prototype.hasOwnProperty.call(section, packageId)
428
+ ));
429
+ }
430
+
431
+ async function readJsonFileForMobileCommand(filePath = "", label = "", createCliError) {
432
+ try {
433
+ return JSON.parse(await readFile(filePath, "utf8"));
434
+ } catch (error) {
435
+ const message = String(error?.message || error || "unknown error");
436
+ throw createCliError(`Could not read ${label}: ${message}`);
437
+ }
438
+ }
439
+
440
+ async function assertMobileRuntimePackageInstalled({
455
441
  ctx,
456
- appRoot,
457
- stdout,
458
- stderr,
459
- dryRun = false,
460
- devlinks = false
442
+ appRoot
461
443
  } = {}) {
462
444
  const {
463
445
  path: pathModule,
464
- loadAppPackageJson
446
+ createCliError
465
447
  } = ctx;
466
- const { packageJson } = await loadAppPackageJson(appRoot);
467
- const packageScripts = packageJson?.scripts && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
468
-
469
- await runLocalBinary("npm", ["install"], {
470
- appRoot,
471
- stderr,
472
- stdout,
473
- pathModule,
474
- createCliError: ctx.createCliError,
475
- dryRun
476
- });
448
+ const packageJsonPath = pathModule.join(appRoot, "package.json");
449
+ const lockPath = pathModule.join(appRoot, ".jskit", "lock.json");
450
+ const packageJson = await readJsonFileForMobileCommand(packageJsonPath, "package.json", createCliError);
451
+ const lock = await readJsonFileForMobileCommand(lockPath, ".jskit/lock.json", createCliError);
452
+ const hasDependency = hasPackageDependency(packageJson, CAPACITOR_RUNTIME_PACKAGE_ID);
453
+ const hasLockRecord = Boolean(lock?.installedPackages?.[CAPACITOR_RUNTIME_PACKAGE_ID]);
477
454
 
478
- if (devlinks === true && Object.prototype.hasOwnProperty.call(packageScripts, "devlinks")) {
479
- await runLocalBinary("npm", ["run", "--if-present", "devlinks"], {
480
- appRoot,
481
- stderr,
482
- stdout,
483
- pathModule,
484
- createCliError: ctx.createCliError,
485
- dryRun
486
- });
455
+ if (!hasDependency || !hasLockRecord) {
456
+ throw createCliError(
457
+ `Mobile Capacitor runtime package is not installed for this app. Run jskit add package ${CAPACITOR_RUNTIME_PACKAGE_ID} first.`
458
+ );
487
459
  }
488
460
  }
489
461
 
490
462
  async function refreshManagedMobileFiles({
491
463
  ctx,
492
- commandAdd,
493
464
  appRoot,
494
465
  options = {},
495
- stdout,
496
- stderr
466
+ stdout
497
467
  } = {}) {
498
468
  const {
499
- path: pathModule
469
+ fileExists,
470
+ path: pathModule,
471
+ normalizeRelativePath,
472
+ createCliError
500
473
  } = ctx;
501
- const packageJsonPath = pathModule.join(appRoot, "package.json");
502
- const packageJsonBefore = await readFile(packageJsonPath, "utf8");
503
- let capturedStdout = "";
504
- await commandAdd({
505
- positional: ["package", CAPACITOR_RUNTIME_PACKAGE_ID],
506
- options: {
507
- ...options,
508
- forceReapplyTarget: true,
509
- runNpmInstall: false,
510
- inlineOptions: {}
511
- },
512
- cwd: appRoot,
513
- io: {
514
- stdout: {
515
- write(chunk) {
516
- capturedStdout += String(chunk || "");
517
- }
518
- },
519
- stderr
520
- }
521
- });
522
- const packageJsonAfter = await readFile(packageJsonPath, "utf8");
523
- const parsedPackageJsonAfter = JSON.parse(packageJsonAfter);
524
- const missingInstalledDependencies = await collectMissingInstalledDependencyNames(ctx, appRoot, parsedPackageJsonAfter);
525
474
 
526
- if (!/Touched files \(0\):/u.test(capturedStdout)) {
527
- stdout.write(capturedStdout);
528
- }
475
+ for (const relativePath of MANAGED_MOBILE_FILE_RELATIVE_PATHS) {
476
+ const absolutePath = pathModule.join(appRoot, relativePath);
477
+ if (!(await fileExists(absolutePath))) {
478
+ throw createCliError(
479
+ `Managed mobile file is missing: ${normalizeRelativePath(appRoot, absolutePath)}. Run jskit add package ${CAPACITOR_RUNTIME_PACKAGE_ID} first.`
480
+ );
481
+ }
529
482
 
530
- if (
531
- options?.dryRun !== true &&
532
- (packageJsonAfter !== packageJsonBefore || missingInstalledDependencies.length > 0)
533
- ) {
534
- await runMobileAppInstall({
535
- ctx,
483
+ const currentSource = await readFile(absolutePath, "utf8");
484
+ const nextSource = await renderManagedMobileFile({
536
485
  appRoot,
537
- stdout,
538
- stderr,
539
- dryRun: false,
540
- devlinks: options?.devlinks === true
486
+ relativeTargetPath: relativePath
541
487
  });
488
+ if (nextSource === currentSource) {
489
+ continue;
490
+ }
491
+
492
+ if (options?.dryRun === true) {
493
+ stdout?.write(`[dry-run] refresh ${normalizeRelativePath(appRoot, absolutePath)}\n`);
494
+ continue;
495
+ }
496
+
497
+ await writeFile(absolutePath, nextSource, "utf8");
498
+ stdout?.write(`[mobile] Refreshed ${normalizeRelativePath(appRoot, absolutePath)}.\n`);
542
499
  }
543
500
  }
544
501
 
545
502
  async function runMobileSyncAndroidCommand({
546
503
  ctx,
547
- commandAdd,
548
504
  appRoot,
549
505
  options = {},
550
506
  stdout,
@@ -554,19 +510,20 @@ async function runMobileSyncAndroidCommand({
554
510
  path: pathModule
555
511
  } = ctx;
556
512
 
557
- await refreshManagedMobileFiles({
513
+ await assertMobileRuntimePackageInstalled({
558
514
  ctx,
559
- commandAdd,
560
- appRoot,
561
- options,
562
- stdout,
563
- stderr
515
+ appRoot
564
516
  });
565
-
566
517
  await assertCapacitorShellInstalled({
567
518
  ctx,
568
519
  appRoot
569
520
  });
521
+ await refreshManagedMobileFiles({
522
+ ctx,
523
+ appRoot,
524
+ options,
525
+ stdout
526
+ });
570
527
  await ensureAndroidNativeShellIdentity({
571
528
  ctx,
572
529
  appRoot,
@@ -607,7 +564,6 @@ async function runMobileSyncAndroidCommand({
607
564
 
608
565
  async function runMobileRunAndroidCommand({
609
566
  ctx,
610
- commandAdd,
611
567
  appRoot,
612
568
  options = {},
613
569
  stdout,
@@ -632,26 +588,26 @@ async function runMobileRunAndroidCommand({
632
588
  if (mobileConfig.assetMode === "bundled") {
633
589
  await runMobileSyncAndroidCommand({
634
590
  ctx,
635
- commandAdd,
636
591
  appRoot,
637
592
  options,
638
593
  stdout,
639
594
  stderr
640
595
  });
641
596
  } else {
642
- await refreshManagedMobileFiles({
597
+ await assertMobileRuntimePackageInstalled({
643
598
  ctx,
644
- commandAdd,
645
- appRoot,
646
- options,
647
- stdout,
648
- stderr
599
+ appRoot
649
600
  });
650
-
651
601
  await assertCapacitorShellInstalled({
652
602
  ctx,
653
603
  appRoot
654
604
  });
605
+ await refreshManagedMobileFiles({
606
+ ctx,
607
+ appRoot,
608
+ options,
609
+ stdout
610
+ });
655
611
  await ensureAndroidNativeShellIdentity({
656
612
  ctx,
657
613
  appRoot,
@@ -722,7 +678,6 @@ async function runCapRunAndroidCommand({
722
678
 
723
679
  async function runMobileBuildAndroidCommand({
724
680
  ctx,
725
- commandAdd,
726
681
  appRoot,
727
682
  options = {},
728
683
  stdout,
@@ -751,7 +706,6 @@ async function runMobileBuildAndroidCommand({
751
706
 
752
707
  await runMobileSyncAndroidCommand({
753
708
  ctx,
754
- commandAdd,
755
709
  appRoot,
756
710
  options,
757
711
  stdout,
@@ -1005,7 +959,6 @@ async function runMobileRestartAndroidCommand({
1005
959
 
1006
960
  async function runMobileDevAndroidCommand({
1007
961
  ctx,
1008
- commandAdd,
1009
962
  appRoot,
1010
963
  options = {},
1011
964
  stdout,
@@ -1024,7 +977,6 @@ async function runMobileDevAndroidCommand({
1024
977
  stdout.write("[mobile] npx jskit mobile android sync\n");
1025
978
  await runMobileSyncAndroidCommand({
1026
979
  ctx,
1027
- commandAdd,
1028
980
  appRoot,
1029
981
  options,
1030
982
  stdout,
@@ -1060,16 +1012,12 @@ async function runMobileDevAndroidCommand({
1060
1012
  return 0;
1061
1013
  }
1062
1014
 
1063
- function createMobileCommands(ctx = {}, { commandAdd } = {}) {
1015
+ function createMobileCommands(ctx = {}) {
1064
1016
  const {
1065
1017
  createCliError,
1066
1018
  resolveAppRootFromCwd
1067
1019
  } = ctx;
1068
1020
 
1069
- if (typeof commandAdd !== "function") {
1070
- throw new TypeError("createMobileCommands requires commandAdd().");
1071
- }
1072
-
1073
1021
  async function commandMobile({ positional = [], options = {}, cwd = "", stdout, stderr }) {
1074
1022
  const firstToken = String(positional[0] || "").trim();
1075
1023
  const secondToken = String(positional[1] || "").trim();
@@ -1154,7 +1102,6 @@ function createMobileCommands(ctx = {}, { commandAdd } = {}) {
1154
1102
 
1155
1103
  return runMobileDevAndroidCommand({
1156
1104
  ctx,
1157
- commandAdd,
1158
1105
  appRoot,
1159
1106
  options,
1160
1107
  stdout,
@@ -1203,7 +1150,6 @@ function createMobileCommands(ctx = {}, { commandAdd } = {}) {
1203
1150
 
1204
1151
  return runMobileSyncAndroidCommand({
1205
1152
  ctx,
1206
- commandAdd,
1207
1153
  appRoot,
1208
1154
  options,
1209
1155
  stdout,
@@ -1220,7 +1166,6 @@ function createMobileCommands(ctx = {}, { commandAdd } = {}) {
1220
1166
 
1221
1167
  return runMobileRunAndroidCommand({
1222
1168
  ctx,
1223
- commandAdd,
1224
1169
  appRoot,
1225
1170
  options,
1226
1171
  stdout,
@@ -1237,7 +1182,6 @@ function createMobileCommands(ctx = {}, { commandAdd } = {}) {
1237
1182
 
1238
1183
  return runMobileBuildAndroidCommand({
1239
1184
  ctx,
1240
- commandAdd,
1241
1185
  appRoot,
1242
1186
  options,
1243
1187
  stdout,
@@ -158,6 +158,18 @@ function buildManagedMobileConfigStub({ packageJson = {} } = {}) {
158
158
  ].join("\n");
159
159
  }
160
160
 
161
+ function isEmptyDisabledMobileConfigPlaceholder(mobileConfig = {}) {
162
+ return (
163
+ mobileConfig?.enabled !== true &&
164
+ !String(mobileConfig?.strategy || "").trim() &&
165
+ !String(mobileConfig?.appId || "").trim() &&
166
+ !String(mobileConfig?.appName || "").trim() &&
167
+ !String(mobileConfig?.apiBaseUrl || "").trim() &&
168
+ !String(mobileConfig?.auth?.customScheme || "").trim() &&
169
+ !String(mobileConfig?.android?.packageName || "").trim()
170
+ );
171
+ }
172
+
161
173
  function parseAndroidSdkDirFromLocalProperties(source = "") {
162
174
  const lines = String(source || "").split(/\r?\n/u);
163
175
  for (const line of lines) {
@@ -489,9 +501,19 @@ async function ensureMobileConfigStub({
489
501
  } = ctx;
490
502
  const publicConfigPath = path.join(appRoot, PUBLIC_CONFIG_RELATIVE_PATH);
491
503
  const currentSource = await readFile(publicConfigPath, "utf8");
492
- if (/\bconfig\.mobile\b|\bmobile\s*:/u.test(currentSource)) {
504
+ if (
505
+ currentSource.includes(MANAGED_MOBILE_CONFIG_START_MARKER) &&
506
+ currentSource.includes(MANAGED_MOBILE_CONFIG_END_MARKER)
507
+ ) {
493
508
  return false;
494
509
  }
510
+ if (/\bconfig\.mobile\b|\bmobile\s*:/u.test(currentSource)) {
511
+ const mergedConfig = await loadAppConfigFromAppRoot({ appRoot });
512
+ const mobileConfig = resolveMobileConfig({ mobile: mergedConfig.mobile });
513
+ if (!isEmptyDisabledMobileConfigPlaceholder(mobileConfig)) {
514
+ return false;
515
+ }
516
+ }
495
517
 
496
518
  const stubSource = buildManagedMobileConfigStub({
497
519
  packageJson
@@ -913,6 +935,7 @@ export {
913
935
  ANDROID_DIRECTORY_NAME,
914
936
  ANDROID_MANIFEST_RELATIVE_PATH,
915
937
  buildManagedMobileConfigStub,
938
+ isEmptyDisabledMobileConfigPlaceholder,
916
939
  resolveInstalledMobileConfig,
917
940
  resolveAndroidSdkDetails,
918
941
  collectAndroidSdkComponentIssues,
@@ -54,6 +54,7 @@ function createCommandHandlerDeps(deps = {}) {
54
54
  removeManagedViteProxyEntries: deps.removeManagedViteProxyEntries,
55
55
  hashBuffer: deps.hashBuffer,
56
56
  rm: deps.rm,
57
+ discoverShellOutletSourcePathsFromApp: deps.discoverShellOutletSourcePathsFromApp,
57
58
  discoverShellOutletTargetsFromApp: deps.discoverShellOutletTargetsFromApp,
58
59
  discoverPlacementTopologyFromApp: deps.discoverPlacementTopologyFromApp
59
60
  };
@@ -317,14 +317,16 @@ const COMMAND_DESCRIPTORS = Object.freeze({
317
317
  minimalUse: "jskit list-placements",
318
318
  parameters: Object.freeze([]),
319
319
  defaults: Object.freeze([
320
- "Shows semantic placement targets from app placement topology by default.",
320
+ "Shows semantic placement targets from app placement topology grouped by authoring model.",
321
+ "Default output includes the generator command pattern for adding to each group.",
322
+ "Use --details to include compact, medium, and expanded layout outlet mappings.",
321
323
  "Use --concrete to inspect low-level ShellOutlet recipients.",
322
324
  "Use --all to show both semantic placements and concrete recipients."
323
325
  ]),
324
- fullUse: "jskit list-placements [--concrete] [--all] [--json]",
326
+ fullUse: "jskit list-placements [--details] [--concrete] [--all] [--json]",
325
327
  showHelpOnBareInvocation: false,
326
328
  handlerName: "commandListPlacements",
327
- allowedFlagKeys: Object.freeze(["concrete", "all", "json"]),
329
+ allowedFlagKeys: Object.freeze(["details", "concrete", "all", "json"]),
328
330
  inlineOptionMode: "none",
329
331
  allowedValueOptionNames: Object.freeze([])
330
332
  }),
@@ -7,6 +7,7 @@ import {
7
7
  import path from "node:path";
8
8
  import {
9
9
  discoverPlacementTopologyFromApp,
10
+ discoverShellOutletSourcePathsFromApp,
10
11
  discoverShellOutletTargetsFromApp
11
12
  } from "@jskit-ai/kernel/server/support";
12
13
  import { createCliError } from "../shared/cliError.js";
@@ -149,6 +150,7 @@ const commandHandlers = createCommandHandlers(
149
150
  removeManagedViteProxyEntries,
150
151
  hashBuffer,
151
152
  rm,
153
+ discoverShellOutletSourcePathsFromApp,
152
154
  discoverShellOutletTargetsFromApp,
153
155
  discoverPlacementTopologyFromApp
154
156
  })