@ikas/component-cli 1.4.0-beta.2 → 1.4.0-beta.200

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.
Files changed (191) hide show
  1. package/dist/build-tools.d.ts +37 -0
  2. package/dist/build-tools.d.ts.map +1 -0
  3. package/dist/build-tools.js +36 -0
  4. package/dist/build-tools.js.map +1 -0
  5. package/dist/commands/add-sections-to-page.d.ts +3 -0
  6. package/dist/commands/add-sections-to-page.d.ts.map +1 -0
  7. package/dist/commands/add-sections-to-page.js +39 -0
  8. package/dist/commands/add-sections-to-page.js.map +1 -0
  9. package/dist/commands/add-to-page.d.ts +3 -0
  10. package/dist/commands/add-to-page.d.ts.map +1 -0
  11. package/dist/commands/add-to-page.js +41 -0
  12. package/dist/commands/add-to-page.js.map +1 -0
  13. package/dist/commands/add.d.ts.map +1 -1
  14. package/dist/commands/add.js +11 -2
  15. package/dist/commands/add.js.map +1 -1
  16. package/dist/commands/build.d.ts.map +1 -1
  17. package/dist/commands/build.js +5 -165
  18. package/dist/commands/build.js.map +1 -1
  19. package/dist/commands/config.d.ts +35 -0
  20. package/dist/commands/config.d.ts.map +1 -1
  21. package/dist/commands/config.js +1235 -228
  22. package/dist/commands/config.js.map +1 -1
  23. package/dist/commands/create-design-tokens.d.ts +24 -0
  24. package/dist/commands/create-design-tokens.d.ts.map +1 -0
  25. package/dist/commands/create-design-tokens.js +327 -0
  26. package/dist/commands/create-design-tokens.js.map +1 -0
  27. package/dist/commands/create-global-variable.d.ts +3 -0
  28. package/dist/commands/create-global-variable.d.ts.map +1 -0
  29. package/dist/commands/create-global-variable.js +73 -0
  30. package/dist/commands/create-global-variable.js.map +1 -0
  31. package/dist/commands/create-page.d.ts +3 -0
  32. package/dist/commands/create-page.d.ts.map +1 -0
  33. package/dist/commands/create-page.js +31 -0
  34. package/dist/commands/create-page.js.map +1 -0
  35. package/dist/commands/delete-page.d.ts +3 -0
  36. package/dist/commands/delete-page.d.ts.map +1 -0
  37. package/dist/commands/delete-page.js +28 -0
  38. package/dist/commands/delete-page.js.map +1 -0
  39. package/dist/commands/delete-theme-globals.d.ts +4 -0
  40. package/dist/commands/delete-theme-globals.d.ts.map +1 -0
  41. package/dist/commands/delete-theme-globals.js +48 -0
  42. package/dist/commands/delete-theme-globals.js.map +1 -0
  43. package/dist/commands/dev.d.ts.map +1 -1
  44. package/dist/commands/dev.js +398 -53
  45. package/dist/commands/dev.js.map +1 -1
  46. package/dist/commands/enable-builtin-pages.d.ts +3 -0
  47. package/dist/commands/enable-builtin-pages.d.ts.map +1 -0
  48. package/dist/commands/enable-builtin-pages.js +27 -0
  49. package/dist/commands/enable-builtin-pages.js.map +1 -0
  50. package/dist/commands/get-component-props.d.ts +3 -0
  51. package/dist/commands/get-component-props.d.ts.map +1 -0
  52. package/dist/commands/get-component-props.js +32 -0
  53. package/dist/commands/get-component-props.js.map +1 -0
  54. package/dist/commands/get-page-by-type.d.ts +3 -0
  55. package/dist/commands/get-page-by-type.d.ts.map +1 -0
  56. package/dist/commands/get-page-by-type.js +25 -0
  57. package/dist/commands/get-page-by-type.js.map +1 -0
  58. package/dist/commands/get-section-values.d.ts +3 -0
  59. package/dist/commands/get-section-values.d.ts.map +1 -0
  60. package/dist/commands/get-section-values.js +39 -0
  61. package/dist/commands/get-section-values.js.map +1 -0
  62. package/dist/commands/import.d.ts +3 -0
  63. package/dist/commands/import.d.ts.map +1 -0
  64. package/dist/commands/import.js +25 -0
  65. package/dist/commands/import.js.map +1 -0
  66. package/dist/commands/list-builtin-pages.d.ts +3 -0
  67. package/dist/commands/list-builtin-pages.d.ts.map +1 -0
  68. package/dist/commands/list-builtin-pages.js +28 -0
  69. package/dist/commands/list-builtin-pages.js.map +1 -0
  70. package/dist/commands/list-entities.d.ts +3 -0
  71. package/dist/commands/list-entities.d.ts.map +1 -0
  72. package/dist/commands/list-entities.js +32 -0
  73. package/dist/commands/list-entities.js.map +1 -0
  74. package/dist/commands/list-imported.d.ts +3 -0
  75. package/dist/commands/list-imported.d.ts.map +1 -0
  76. package/dist/commands/list-imported.js +25 -0
  77. package/dist/commands/list-imported.js.map +1 -0
  78. package/dist/commands/list-page-sections.d.ts +3 -0
  79. package/dist/commands/list-page-sections.d.ts.map +1 -0
  80. package/dist/commands/list-page-sections.js +31 -0
  81. package/dist/commands/list-page-sections.js.map +1 -0
  82. package/dist/commands/list-pages.d.ts +3 -0
  83. package/dist/commands/list-pages.d.ts.map +1 -0
  84. package/dist/commands/list-pages.js +21 -0
  85. package/dist/commands/list-pages.js.map +1 -0
  86. package/dist/commands/list-theme-globals.d.ts +3 -0
  87. package/dist/commands/list-theme-globals.d.ts.map +1 -0
  88. package/dist/commands/list-theme-globals.js +22 -0
  89. package/dist/commands/list-theme-globals.js.map +1 -0
  90. package/dist/commands/move-page-section.d.ts +3 -0
  91. package/dist/commands/move-page-section.d.ts.map +1 -0
  92. package/dist/commands/move-page-section.js +62 -0
  93. package/dist/commands/move-page-section.js.map +1 -0
  94. package/dist/commands/publish-theme.d.ts +3 -0
  95. package/dist/commands/publish-theme.d.ts.map +1 -0
  96. package/dist/commands/publish-theme.js +29 -0
  97. package/dist/commands/publish-theme.js.map +1 -0
  98. package/dist/commands/publish.d.ts.map +1 -1
  99. package/dist/commands/publish.js +54 -3
  100. package/dist/commands/publish.js.map +1 -1
  101. package/dist/commands/remove-page-section.d.ts +3 -0
  102. package/dist/commands/remove-page-section.d.ts.map +1 -0
  103. package/dist/commands/remove-page-section.js +40 -0
  104. package/dist/commands/remove-page-section.js.map +1 -0
  105. package/dist/commands/search-products.d.ts +3 -0
  106. package/dist/commands/search-products.d.ts.map +1 -0
  107. package/dist/commands/search-products.js +40 -0
  108. package/dist/commands/search-products.js.map +1 -0
  109. package/dist/commands/update-builtin-page-prop.d.ts +3 -0
  110. package/dist/commands/update-builtin-page-prop.d.ts.map +1 -0
  111. package/dist/commands/update-builtin-page-prop.js +49 -0
  112. package/dist/commands/update-builtin-page-prop.js.map +1 -0
  113. package/dist/commands/update-global-variable.d.ts +3 -0
  114. package/dist/commands/update-global-variable.d.ts.map +1 -0
  115. package/dist/commands/update-global-variable.js +70 -0
  116. package/dist/commands/update-global-variable.js.map +1 -0
  117. package/dist/commands/update-page-sections.d.ts +3 -0
  118. package/dist/commands/update-page-sections.d.ts.map +1 -0
  119. package/dist/commands/update-page-sections.js +39 -0
  120. package/dist/commands/update-page-sections.js.map +1 -0
  121. package/dist/commands/update-section-prop.d.ts +3 -0
  122. package/dist/commands/update-section-prop.d.ts.map +1 -0
  123. package/dist/commands/update-section-prop.js +76 -0
  124. package/dist/commands/update-section-prop.js.map +1 -0
  125. package/dist/commands/upload-image.d.ts +3 -0
  126. package/dist/commands/upload-image.d.ts.map +1 -0
  127. package/dist/commands/upload-image.js +38 -0
  128. package/dist/commands/upload-image.js.map +1 -0
  129. package/dist/commands/upload-images.d.ts +3 -0
  130. package/dist/commands/upload-images.d.ts.map +1 -0
  131. package/dist/commands/upload-images.js +48 -0
  132. package/dist/commands/upload-images.js.map +1 -0
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +67 -0
  135. package/dist/index.js.map +1 -1
  136. package/dist/types.d.ts +52 -1
  137. package/dist/types.d.ts.map +1 -1
  138. package/dist/utils/compile.d.ts +17 -2
  139. package/dist/utils/compile.d.ts.map +1 -1
  140. package/dist/utils/compile.js +750 -117
  141. package/dist/utils/compile.js.map +1 -1
  142. package/dist/utils/component-helpers.d.ts +148 -3
  143. package/dist/utils/component-helpers.d.ts.map +1 -1
  144. package/dist/utils/component-helpers.js +376 -29
  145. package/dist/utils/component-helpers.js.map +1 -1
  146. package/dist/utils/css-import-resolver.d.ts +4 -1
  147. package/dist/utils/css-import-resolver.d.ts.map +1 -1
  148. package/dist/utils/css-import-resolver.js +9 -1
  149. package/dist/utils/css-import-resolver.js.map +1 -1
  150. package/dist/utils/editor-action-client.d.ts +29 -0
  151. package/dist/utils/editor-action-client.d.ts.map +1 -0
  152. package/dist/utils/editor-action-client.js +137 -0
  153. package/dist/utils/editor-action-client.js.map +1 -0
  154. package/dist/utils/esm-transform.d.ts +30 -0
  155. package/dist/utils/esm-transform.d.ts.map +1 -1
  156. package/dist/utils/esm-transform.js +169 -2
  157. package/dist/utils/esm-transform.js.map +1 -1
  158. package/dist/utils/find-component-for-file.d.ts +12 -0
  159. package/dist/utils/find-component-for-file.d.ts.map +1 -0
  160. package/dist/utils/find-component-for-file.js +24 -0
  161. package/dist/utils/find-component-for-file.js.map +1 -0
  162. package/dist/utils/forbid-deep-imports.d.ts +3 -0
  163. package/dist/utils/forbid-deep-imports.d.ts.map +1 -0
  164. package/dist/utils/forbid-deep-imports.js +54 -0
  165. package/dist/utils/forbid-deep-imports.js.map +1 -0
  166. package/dist/utils/forbid-external-packages.d.ts +26 -0
  167. package/dist/utils/forbid-external-packages.d.ts.map +1 -0
  168. package/dist/utils/forbid-external-packages.js +99 -0
  169. package/dist/utils/forbid-external-packages.js.map +1 -0
  170. package/dist/utils/load-image.d.ts +16 -0
  171. package/dist/utils/load-image.d.ts.map +1 -0
  172. package/dist/utils/load-image.js +50 -0
  173. package/dist/utils/load-image.js.map +1 -0
  174. package/dist/utils/vendor-storefront.d.ts +13 -0
  175. package/dist/utils/vendor-storefront.d.ts.map +1 -0
  176. package/dist/utils/vendor-storefront.js +91 -0
  177. package/dist/utils/vendor-storefront.js.map +1 -0
  178. package/dist/utils/websocket-server.d.ts +134 -3
  179. package/dist/utils/websocket-server.d.ts.map +1 -1
  180. package/dist/utils/websocket-server.js +279 -24
  181. package/dist/utils/websocket-server.js.map +1 -1
  182. package/package.json +17 -2
  183. package/templates/add/section.css +11 -0
  184. package/dist/commands/create.d.ts +0 -9
  185. package/dist/commands/create.d.ts.map +0 -1
  186. package/dist/commands/create.js +0 -9
  187. package/dist/commands/create.js.map +0 -1
  188. package/dist/commands/proxy.d.ts +0 -39
  189. package/dist/commands/proxy.d.ts.map +0 -1
  190. package/dist/commands/proxy.js +0 -212
  191. package/dist/commands/proxy.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  import { Command } from "commander";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
- import { PROP_TYPES, toPascalCase, generateTypesFile, generateGlobalTypesFile, collectUsedEnumIds, generateComponentFile, generateStylesFile, generateProjectId, generateComponentId, generateUniqueId, updateBarrelExport, findPropGroup, collectPropGroupIds, movePropGroupInTree, } from "../utils/component-helpers.js";
4
+ import { runEditorAction } from "../utils/editor-action-client.js";
5
+ import { PROP_TYPES, toPascalCase, generateTypesFile, isBuiltInEnumTypeId, generateGlobalTypesFile, collectUsedEnumIds, generateComponentFile, generateStylesFile, generateProjectId, isValidProjectId, generateComponentId, generateUniqueId, updateBarrelExport, findPropGroup, collectPropGroupIds, movePropGroupInTree, validateFilteredComponentIds, visibilityGateError, } from "../utils/component-helpers.js";
5
6
  function loadConfig() {
6
7
  const configPath = path.resolve(process.cwd(), "ikas.config.json");
7
8
  if (!fs.existsSync(configPath)) {
@@ -17,13 +18,90 @@ function loadConfig() {
17
18
  function saveConfig(configPath, config) {
18
19
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
19
20
  }
20
- function findComponent(config, name) {
21
- const pascalName = toPascalCase(name);
22
- return config.components.find((c) => c.name === pascalName);
21
+ /**
22
+ * Resolve a component by canonical id (preferred) or exact PascalCase name.
23
+ * No silent normalization: pass --id from `config list-components`, or pass --name
24
+ * exactly as stored in ikas.config.json. Exits with a structured JSON error
25
+ * (listing valid {id, name} pairs) if zero/both/missing.
26
+ */
27
+ function resolveComponent(config, ref) {
28
+ const id = ref.id?.trim() || undefined;
29
+ const name = ref.name?.trim() || undefined;
30
+ if (!id && !name) {
31
+ console.log(JSON.stringify({
32
+ success: false,
33
+ error: "Component reference required: pass --id (preferred) or --name (exact PascalCase). " +
34
+ "Run `ikas-component config list-components` to see canonical ids.",
35
+ validComponents: config.components.map((c) => ({
36
+ id: c.id,
37
+ name: c.name,
38
+ })),
39
+ }));
40
+ process.exit(1);
41
+ }
42
+ if (id && name) {
43
+ console.log(JSON.stringify({
44
+ success: false,
45
+ error: "Specify only one of --id or --name, not both.",
46
+ }));
47
+ process.exit(1);
48
+ }
49
+ const found = id
50
+ ? config.components.find((c) => c.id === id)
51
+ : config.components.find((c) => c.name === name);
52
+ if (!found) {
53
+ console.log(JSON.stringify({
54
+ success: false,
55
+ error: id
56
+ ? `Component not found: id="${id}".`
57
+ : `Component not found: name="${name}". Names are matched exactly (PascalCase, no normalization).`,
58
+ lookup: id ? { id } : { name },
59
+ validComponents: config.components.map((c) => ({
60
+ id: c.id,
61
+ name: c.name,
62
+ })),
63
+ hint: "Use --id (preferred) or exact PascalCase --name. " +
64
+ "Run `ikas-component config list-components` to see canonical ids.",
65
+ }));
66
+ process.exit(1);
67
+ }
68
+ return found;
23
69
  }
24
70
  function getComponentNames(config) {
25
71
  return config.components.map((c) => c.name);
26
72
  }
73
+ /**
74
+ * Emit a structured JSON error for unknown filteredComponentIds entries and exit non-zero.
75
+ * Lists valid `{ id, name }` pairs so the LLM/caller can recover.
76
+ */
77
+ function emitFilteredComponentIdsError(unknown, components) {
78
+ console.log(JSON.stringify({
79
+ success: false,
80
+ error: `Unknown filteredComponentIds: ${unknown.join(", ")}. ` +
81
+ `Component ids are opaque and cannot be derived from names — they must come from the ` +
82
+ `componentId returned by 'config add-component' or from 'config list'.`,
83
+ validComponents: components.map((c) => ({ id: c.id, name: c.name })),
84
+ }));
85
+ process.exit(1);
86
+ }
87
+ /**
88
+ * Validate `input` ids against `config.components` and return the same array on success.
89
+ * Exits with a JSON error if any id is unknown. Use at every write site that accepts
90
+ * filteredComponentIds from user/LLM input.
91
+ */
92
+ function assertKnownComponentIds(input, config) {
93
+ const components = config?.components ?? [];
94
+ const { unknown } = validateFilteredComponentIds(input, components);
95
+ if (unknown.length > 0) {
96
+ emitFilteredComponentIdsError(unknown, components);
97
+ }
98
+ return input;
99
+ }
100
+ // The set of component ids that currently exist in the project, for validateComponentDefaultValue's
101
+ // existence check. Undefined when no config is loaded, which makes the check a no-op (back-compat).
102
+ function existingComponentIdSet(config) {
103
+ return config ? new Set(config.components.map((c) => c.id)) : undefined;
104
+ }
27
105
  function regenerateTypes(component, componentType, config) {
28
106
  const componentDir = path.resolve(process.cwd(), path.dirname(component.entry));
29
107
  const typesPath = path.join(componentDir, "types.ts");
@@ -78,6 +156,125 @@ function camelCaseToDisplayName(name) {
78
156
  .replace(/([a-z])([A-Z])/g, "$1 $2")
79
157
  .replace(/^./, (c) => c.toUpperCase());
80
158
  }
159
+ const ALLOWED_PROP_FIELDS = new Set([
160
+ "name",
161
+ "displayName",
162
+ "type",
163
+ "required",
164
+ "description",
165
+ "defaultValue",
166
+ "groupId",
167
+ "typeId",
168
+ "enumTypeId",
169
+ "filteredComponentIds",
170
+ "privateVarMap",
171
+ "numberRangeData",
172
+ "requiredProp",
173
+ "requiredPropValue",
174
+ "requiredEnumOptionId",
175
+ "requiredEnumOptionValue",
176
+ ]);
177
+ // Emit the standard CLI error envelope and exit(1) when a validator returned a message; a no-op
178
+ // when the message is null. Collapses the repeated `if (err) { console.log(...); process.exit(1) }`
179
+ // tail that follows every default-value validator call.
180
+ function failIf(error) {
181
+ if (error) {
182
+ console.log(JSON.stringify({ success: false, error }));
183
+ process.exit(1);
184
+ }
185
+ }
186
+ /**
187
+ * A `--requiredPropValue` flag as a boolean. Commander hands every flag over as a string, and this is
188
+ * the one place that turns it back; anything other than true/false is refused rather than coerced,
189
+ * since "0" or "off" silently becoming `true` is exactly the kind of gate nobody would think to
190
+ * check.
191
+ */
192
+ function parseRequiredPropValue(raw, where) {
193
+ if (raw === undefined)
194
+ return undefined;
195
+ if (typeof raw === "boolean")
196
+ return raw;
197
+ if (raw === "true")
198
+ return true;
199
+ if (raw === "false")
200
+ return false;
201
+ failIf(`--requiredPropValue for ${where} must be "true" or "false" (got "${raw}").`);
202
+ return undefined;
203
+ }
204
+ /**
205
+ * Validate/sanitize a numberRangeData object for a NUMBER_RANGE prop. Accepts an object
206
+ * or a JSON string. min/max/interval are truncated to integers; only a positive interval
207
+ * is kept (0/negative would yield an invalid slider step). Exits with a structured error
208
+ * on malformed input.
209
+ */
210
+ function parseNumberRangeData(raw, propName) {
211
+ const fail = (error) => {
212
+ console.log(JSON.stringify({ success: false, error }));
213
+ process.exit(1);
214
+ };
215
+ let obj = raw;
216
+ if (typeof raw === "string") {
217
+ try {
218
+ obj = JSON.parse(raw);
219
+ }
220
+ catch {
221
+ fail(`Invalid --numberRangeData JSON for prop "${propName}": ${raw}`);
222
+ }
223
+ }
224
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
225
+ fail(`numberRangeData for prop "${propName}" must be an object { min, max, interval?, unit? }.`);
226
+ }
227
+ const o = obj;
228
+ const asInt = (v, field) => {
229
+ if (v === undefined || v === null)
230
+ return undefined;
231
+ if (typeof v !== "number" || !Number.isFinite(v)) {
232
+ fail(`numberRangeData.${field} for prop "${propName}" must be a finite number.`);
233
+ }
234
+ return Math.trunc(v);
235
+ };
236
+ const min = asInt(o.min, "min");
237
+ const max = asInt(o.max, "max");
238
+ const interval = asInt(o.interval, "interval");
239
+ const unit = typeof o.unit === "string" && o.unit.trim() ? o.unit.trim() : undefined;
240
+ if (min !== undefined && max !== undefined && min >= max) {
241
+ fail(`numberRangeData for prop "${propName}": min (${min}) must be less than max (${max}).`);
242
+ }
243
+ return {
244
+ ...(min !== undefined ? { min } : {}),
245
+ ...(max !== undefined ? { max } : {}),
246
+ ...(interval !== undefined && interval > 0 ? { interval } : {}),
247
+ ...(unit ? { unit } : {}),
248
+ };
249
+ }
250
+ const numberRangeDataRequiredError = (propName) => `numberRangeData with min and max is required for NUMBER_RANGE prop "${propName}". ` +
251
+ `Provide it, e.g. --numberRangeData '{"min":0,"max":100,"interval":5,"unit":"px"}' ` +
252
+ `(or a "numberRangeData" field inside --props). interval and unit are optional.`;
253
+ // A NUMBER_RANGE slider config is complete only with both bounds; interval/unit stay optional.
254
+ export const isNumberRangeComplete = (data) => data.min !== undefined && data.max !== undefined;
255
+ /**
256
+ * A NUMBER_RANGE prop must carry a slider config with at least min and max — the editor UI marks
257
+ * Minimum/Maksimum Değer as required, so a prop authored without them yields a half-configured
258
+ * slider. Enforce it at creation time: reject when numberRangeData is absent or missing min/max.
259
+ */
260
+ export function requireNumberRangeData(raw, propName) {
261
+ if (raw === undefined) {
262
+ console.log(JSON.stringify({
263
+ success: false,
264
+ error: numberRangeDataRequiredError(propName),
265
+ }));
266
+ process.exit(1);
267
+ }
268
+ const parsed = parseNumberRangeData(raw, propName);
269
+ if (!isNumberRangeComplete(parsed)) {
270
+ console.log(JSON.stringify({
271
+ success: false,
272
+ error: numberRangeDataRequiredError(propName),
273
+ }));
274
+ process.exit(1);
275
+ }
276
+ return parsed;
277
+ }
81
278
  /**
82
279
  * Parse and validate the --props JSON flag for add-component.
83
280
  * Returns validated ComponentProp[] or exits with error.
@@ -103,8 +300,20 @@ async function parsePropsFlag(propsJson, config, configPath) {
103
300
  }
104
301
  const seenNames = new Set();
105
302
  const props = [];
303
+ // Loop-invariant: build the known-id set once for the whole batch, not per prop.
304
+ const existingComponentIds = existingComponentIdSet(config);
106
305
  for (const raw of rawProps) {
107
306
  const r = raw;
307
+ const unknownFields = Object.keys(r).filter((k) => !ALLOWED_PROP_FIELDS.has(k));
308
+ if (unknownFields.length > 0) {
309
+ console.log(JSON.stringify({
310
+ success: false,
311
+ error: `Unknown field(s) in --props entry: ${unknownFields.join(", ")}. ` +
312
+ `Allowed: ${[...ALLOWED_PROP_FIELDS].join(", ")}. ` +
313
+ `Note: use "groupId" (not "group") and "defaultValue" (not "default") in --props JSON.`,
314
+ }));
315
+ process.exit(1);
316
+ }
108
317
  if (!r.name || typeof r.name !== "string") {
109
318
  console.log(JSON.stringify({
110
319
  success: false,
@@ -145,6 +354,36 @@ async function parsePropsFlag(propsJson, config, configPath) {
145
354
  }));
146
355
  process.exit(1);
147
356
  }
357
+ // Dynamic (merchant-data reference) props can't carry a static default.
358
+ rejectDynamicDefault(r.name, propType, r.defaultValue);
359
+ // Validate LINK / LIST_OF_LINK defaultValue shape (reject JSON strings and legacy { href })
360
+ if ((propType === "LINK" || propType === "LIST_OF_LINK") &&
361
+ r.defaultValue !== undefined) {
362
+ const linkError = validateLinkDefaultValue(propType, r.defaultValue, r.name);
363
+ failIf(linkError);
364
+ }
365
+ // Validate SVG / SVG_LIST defaultValue (reject malformed/oversized/unsafe SVG)
366
+ if ((propType === "SVG" || propType === "SVG_LIST") &&
367
+ r.defaultValue !== undefined) {
368
+ const svgError = validateSvgDefaultValue(propType, r.defaultValue, r.name);
369
+ failIf(svgError);
370
+ }
371
+ // Validate COMPONENT / COMPONENT_LIST defaultValue (reference-only child code components)
372
+ if ((propType === "COMPONENT" || propType === "COMPONENT_LIST") &&
373
+ r.defaultValue !== undefined) {
374
+ failIf(validateComponentDefaultValue(propType, r.defaultValue, r.name, r.filteredComponentIds, existingComponentIds));
375
+ }
376
+ // Validate TYPE (structured/style) defaultValue shape — must be an object (or an array for an
377
+ // _array typeId), never a bare string that would be emitted as a wrong-shaped default.
378
+ if (propType === "TYPE" && r.defaultValue !== undefined) {
379
+ const typeError = validateTypeDefaultValue(r.defaultValue, r.name, r.typeId);
380
+ failIf(typeError);
381
+ }
382
+ // Validate NUMBER defaultValue — reject empty/non-finite (Number("") silently becomes 0).
383
+ if (propType === "NUMBER" && r.defaultValue !== undefined) {
384
+ const numError = validateNumberDefaultValue(r.defaultValue, r.name);
385
+ failIf(numError);
386
+ }
148
387
  // Auto-generate displayName if omitted
149
388
  const displayName = typeof r.displayName === "string" && r.displayName
150
389
  ? r.displayName
@@ -169,7 +408,10 @@ async function parsePropsFlag(propsJson, config, configPath) {
169
408
  process.exit(1);
170
409
  }
171
410
  // Validate enum type exists (checks config then live dev server)
172
- if (config && configPath && typeof r.enumTypeId === "string" && !r.enumTypeId.startsWith("@ikas/")) {
411
+ if (config &&
412
+ configPath &&
413
+ typeof r.enumTypeId === "string" &&
414
+ !isBuiltInEnumTypeId(r.enumTypeId)) {
173
415
  const resolved = await resolveEnumType(r.enumTypeId, config, configPath);
174
416
  if (!resolved) {
175
417
  console.log(JSON.stringify({
@@ -188,9 +430,7 @@ async function parsePropsFlag(propsJson, config, configPath) {
188
430
  ...(typeof r.description === "string" && r.description
189
431
  ? { description: r.description }
190
432
  : {}),
191
- ...(r.defaultValue !== undefined
192
- ? { defaultValue: r.defaultValue }
193
- : {}),
433
+ ...(r.defaultValue !== undefined ? { defaultValue: r.defaultValue } : {}),
194
434
  ...(typeof r.groupId === "string" && r.groupId
195
435
  ? { groupId: r.groupId }
196
436
  : {}),
@@ -200,15 +440,47 @@ async function parsePropsFlag(propsJson, config, configPath) {
200
440
  ...(propType === "ENUM" && typeof r.enumTypeId === "string"
201
441
  ? { enumTypeId: r.enumTypeId }
202
442
  : {}),
203
- ...((propType === "COMPONENT" || propType === "COMPONENT_LIST") && r.filteredComponentIds && Array.isArray(r.filteredComponentIds)
204
- ? { filteredComponentIds: r.filteredComponentIds }
443
+ ...((propType === "COMPONENT" || propType === "COMPONENT_LIST") &&
444
+ r.filteredComponentIds &&
445
+ Array.isArray(r.filteredComponentIds)
446
+ ? {
447
+ filteredComponentIds: assertKnownComponentIds(r.filteredComponentIds, config),
448
+ }
449
+ : {}),
450
+ ...((propType === "COMPONENT" || propType === "COMPONENT_LIST") &&
451
+ r.privateVarMap &&
452
+ typeof r.privateVarMap === "object"
453
+ ? {
454
+ privateVarMap: r.privateVarMap,
455
+ }
456
+ : {}),
457
+ ...(propType === "NUMBER_RANGE"
458
+ ? { numberRangeData: requireNumberRangeData(r.numberRangeData, r.name) }
459
+ : {}),
460
+ ...(r.requiredProp !== undefined
461
+ ? { requiredProp: r.requiredProp }
462
+ : {}),
463
+ ...(r.requiredPropValue !== undefined
464
+ ? { requiredPropValue: r.requiredPropValue }
205
465
  : {}),
206
- ...((propType === "COMPONENT" || propType === "COMPONENT_LIST") && r.privateVarMap && typeof r.privateVarMap === "object"
207
- ? { privateVarMap: r.privateVarMap }
466
+ ...(r.requiredEnumOptionId !== undefined
467
+ ? { requiredEnumOptionId: r.requiredEnumOptionId }
468
+ : {}),
469
+ ...(r.requiredEnumOptionValue !== undefined
470
+ ? { requiredEnumOptionValue: r.requiredEnumOptionValue }
208
471
  : {}),
209
472
  };
210
473
  props.push(prop);
211
474
  }
475
+ // Gates are checked once the whole batch is parsed: a prop may name one declared after it, and
476
+ // cycle detection needs every prop in hand. The batch IS the component's prop list here — the
477
+ // component itself does not exist yet — so it stands in as one.
478
+ const batch = { name: "the new component", props };
479
+ for (const prop of props) {
480
+ failIf(visibilityGateError(batch, prop, `prop "${prop.name}"`, {
481
+ selfPropName: prop.name,
482
+ }));
483
+ }
212
484
  return props;
213
485
  }
214
486
  // --- Subcommands ---
@@ -236,14 +508,29 @@ async function addComponent(name, options) {
236
508
  if (!config.projectId) {
237
509
  config.projectId = generateProjectId();
238
510
  }
511
+ else if (!isValidProjectId(config.projectId)) {
512
+ // A malformed projectId (hand-edited config) breaks the editor's republish
513
+ // stability: published/local id prefixes stop matching and every publish
514
+ // INSERTs duplicate design elements. Fail fast instead of corrupting data.
515
+ console.log(JSON.stringify({
516
+ success: false,
517
+ error: `Invalid projectId "${config.projectId}" in ikas.config.json. ` +
518
+ "projectId must be at least 6 alphanumeric characters (no '-' or '_'). " +
519
+ "Restore the original generated value — do not hand-edit it.",
520
+ }));
521
+ process.exit(1);
522
+ }
523
+ // Parse --props (and validate filteredComponentIds) BEFORE any filesystem side-effects
524
+ // so a bad payload doesn't leave an empty component directory behind.
525
+ const props = options.props
526
+ ? await parsePropsFlag(options.props, config, configPath)
527
+ : [];
528
+ // Reject section-disallowed TYPE props at definition time (best-effort, needs dev server)
529
+ await assertTypeIdsUsable(componentType, props.flatMap((p) => (p.type === "TYPE" && p.typeId ? [p.typeId] : [])));
239
530
  const componentId = generateComponentId(config.projectId);
240
531
  const componentDir = path.resolve(process.cwd(), `src/components/${pascalName}`);
241
532
  // Create directory
242
533
  fs.mkdirSync(componentDir, { recursive: true });
243
- // Parse --props if provided, otherwise empty
244
- const props = options.props
245
- ? await parsePropsFlag(options.props, config, configPath)
246
- : [];
247
534
  // Auto-create prop groups from groupId references in props
248
535
  const referencedGroupIds = new Set();
249
536
  for (const prop of props) {
@@ -294,16 +581,9 @@ async function addComponent(name, options) {
294
581
  ],
295
582
  }));
296
583
  }
297
- async function addProp(componentName, options) {
584
+ async function addProp(ref, options) {
298
585
  const { config, configPath } = loadConfig();
299
- const component = findComponent(config, componentName);
300
- if (!component) {
301
- console.log(JSON.stringify({
302
- success: false,
303
- error: `Component "${componentName}" not found. Available: ${getComponentNames(config).join(", ")}`,
304
- }));
305
- process.exit(1);
306
- }
586
+ const component = resolveComponent(config, ref);
307
587
  // Validate prop type
308
588
  const propType = options.type.toUpperCase();
309
589
  if (!PROP_TYPES.includes(propType)) {
@@ -337,6 +617,10 @@ async function addProp(componentName, options) {
337
617
  }));
338
618
  process.exit(1);
339
619
  }
620
+ // Reject section-disallowed types at definition time (best-effort, needs dev server)
621
+ if (propType === "TYPE" && options.typeId) {
622
+ await assertTypeIdsUsable(component.type, [options.typeId]);
623
+ }
340
624
  // Validate enumTypeId for ENUM props
341
625
  if (propType === "ENUM" && !options.enumTypeId) {
342
626
  console.log(JSON.stringify({
@@ -347,7 +631,9 @@ async function addProp(componentName, options) {
347
631
  }
348
632
  // Validate that enumTypeId references an existing enum type
349
633
  // (checks config.customTypes first, then falls back to the live dev server)
350
- if (propType === "ENUM" && options.enumTypeId && !options.enumTypeId.startsWith("@ikas/")) {
634
+ if (propType === "ENUM" &&
635
+ options.enumTypeId &&
636
+ !isBuiltInEnumTypeId(options.enumTypeId)) {
351
637
  const resolved = await resolveEnumType(options.enumTypeId, config, configPath);
352
638
  if (!resolved) {
353
639
  console.log(JSON.stringify({
@@ -377,49 +663,117 @@ async function addProp(componentName, options) {
377
663
  }
378
664
  // Parse filteredComponentIds JSON for COMPONENT/COMPONENT_LIST
379
665
  let parsedFilteredIds;
380
- if (options.filteredComponentIds && (propType === "COMPONENT" || propType === "COMPONENT_LIST")) {
666
+ if (options.filteredComponentIds &&
667
+ (propType === "COMPONENT" || propType === "COMPONENT_LIST")) {
381
668
  try {
382
669
  parsedFilteredIds = JSON.parse(options.filteredComponentIds);
383
670
  if (!Array.isArray(parsedFilteredIds)) {
384
- console.log(JSON.stringify({ success: false, error: "--filteredComponentIds must be a JSON array of strings." }));
671
+ console.log(JSON.stringify({
672
+ success: false,
673
+ error: "--filteredComponentIds must be a JSON array of strings.",
674
+ }));
385
675
  process.exit(1);
386
676
  }
387
677
  }
388
678
  catch {
389
- console.log(JSON.stringify({ success: false, error: `Invalid --filteredComponentIds JSON: ${options.filteredComponentIds}` }));
679
+ console.log(JSON.stringify({
680
+ success: false,
681
+ error: `Invalid --filteredComponentIds JSON: ${options.filteredComponentIds}`,
682
+ }));
390
683
  process.exit(1);
391
684
  }
685
+ assertKnownComponentIds(parsedFilteredIds, config);
392
686
  }
393
687
  // Parse privateVarMap JSON for COMPONENT/COMPONENT_LIST
394
688
  let parsedPrivateVarMap;
395
- if (options.privateVarMap && (propType === "COMPONENT" || propType === "COMPONENT_LIST")) {
689
+ if (options.privateVarMap &&
690
+ (propType === "COMPONENT" || propType === "COMPONENT_LIST")) {
396
691
  try {
397
692
  parsedPrivateVarMap = JSON.parse(options.privateVarMap);
398
- if (typeof parsedPrivateVarMap !== "object" || Array.isArray(parsedPrivateVarMap)) {
399
- console.log(JSON.stringify({ success: false, error: "--privateVarMap must be a JSON object mapping keys to {id, typeId}." }));
693
+ if (typeof parsedPrivateVarMap !== "object" ||
694
+ Array.isArray(parsedPrivateVarMap)) {
695
+ console.log(JSON.stringify({
696
+ success: false,
697
+ error: "--privateVarMap must be a JSON object mapping keys to {id, typeId}.",
698
+ }));
400
699
  process.exit(1);
401
700
  }
402
701
  }
403
702
  catch {
404
- console.log(JSON.stringify({ success: false, error: `Invalid --privateVarMap JSON: ${options.privateVarMap}` }));
703
+ console.log(JSON.stringify({
704
+ success: false,
705
+ error: `Invalid --privateVarMap JSON: ${options.privateVarMap}`,
706
+ }));
405
707
  process.exit(1);
406
708
  }
407
709
  }
710
+ rejectDynamicDefault(options.name, propType, options.defaultValue);
711
+ const parsedDefaultValue = options.defaultValue !== undefined
712
+ ? parseDefaultValue(options.defaultValue, propType)
713
+ : undefined;
714
+ if ((propType === "LINK" || propType === "LIST_OF_LINK") &&
715
+ parsedDefaultValue !== undefined) {
716
+ const linkError = validateLinkDefaultValue(propType, parsedDefaultValue, options.name);
717
+ failIf(linkError);
718
+ }
719
+ if ((propType === "SVG" || propType === "SVG_LIST") &&
720
+ parsedDefaultValue !== undefined) {
721
+ const svgError = validateSvgDefaultValue(propType, parsedDefaultValue, options.name);
722
+ failIf(svgError);
723
+ }
724
+ if ((propType === "COMPONENT" || propType === "COMPONENT_LIST") &&
725
+ parsedDefaultValue !== undefined) {
726
+ failIf(validateComponentDefaultValue(propType, parsedDefaultValue, options.name, parsedFilteredIds, existingComponentIdSet(config)));
727
+ }
728
+ if (propType === "TYPE" && parsedDefaultValue !== undefined) {
729
+ const typeError = validateTypeDefaultValue(parsedDefaultValue, options.name, options.typeId);
730
+ failIf(typeError);
731
+ }
732
+ // Validate against the raw flag string so an empty --defaultValue (Number("") → 0) is caught.
733
+ if (propType === "NUMBER" && options.defaultValue !== undefined) {
734
+ const numError = validateNumberDefaultValue(options.defaultValue, options.name);
735
+ failIf(numError);
736
+ }
737
+ const parsedVisibilityValue = parseRequiredPropValue(options.requiredPropValue, `prop "${options.name}"`);
738
+ const parsedNumberRangeData = propType === "NUMBER_RANGE"
739
+ ? requireNumberRangeData(options.numberRangeData, options.name)
740
+ : undefined;
741
+ if (propType === "NUMBER_RANGE" && parsedDefaultValue !== undefined) {
742
+ const nrError = validateNumberRangeDefaultValue(parsedDefaultValue, parsedNumberRangeData, options.name);
743
+ failIf(nrError);
744
+ }
408
745
  const newProp = {
409
746
  name: options.name,
410
747
  displayName: options.displayName,
411
748
  type: propType,
412
749
  required: options.required ?? false,
413
750
  ...(options.description ? { description: options.description } : {}),
414
- ...(options.defaultValue !== undefined
415
- ? { defaultValue: parseDefaultValue(options.defaultValue, propType) }
751
+ ...(parsedDefaultValue !== undefined
752
+ ? { defaultValue: parsedDefaultValue }
416
753
  : {}),
417
754
  ...(options.group ? { groupId: options.group } : {}),
418
755
  ...(options.typeId ? { typeId: options.typeId } : {}),
419
756
  ...(options.enumTypeId ? { enumTypeId: options.enumTypeId } : {}),
420
757
  ...(parsedFilteredIds ? { filteredComponentIds: parsedFilteredIds } : {}),
421
758
  ...(parsedPrivateVarMap ? { privateVarMap: parsedPrivateVarMap } : {}),
759
+ ...(parsedNumberRangeData
760
+ ? { numberRangeData: parsedNumberRangeData }
761
+ : {}),
762
+ ...(options.requiredProp ? { requiredProp: options.requiredProp } : {}),
763
+ ...(parsedVisibilityValue !== undefined
764
+ ? { requiredPropValue: parsedVisibilityValue }
765
+ : {}),
766
+ ...(options.requiredProp && options.requiredEnumOptionValue !== undefined
767
+ ? {
768
+ // The editor has no separate option id; the value doubles as one.
769
+ requiredEnumOptionId: options.requiredEnumOptionValue,
770
+ requiredEnumOptionValue: options.requiredEnumOptionValue,
771
+ }
772
+ : {}),
422
773
  };
774
+ failIf(visibilityGateError(component, newProp, `prop "${newProp.name}"`, {
775
+ selfPropName: newProp.name,
776
+ }));
423
777
  component.props.push(newProp);
424
778
  saveConfig(configPath, config);
425
779
  // Regenerate types.ts and global types
@@ -432,34 +786,448 @@ async function addProp(componentName, options) {
432
786
  name: newProp.name,
433
787
  displayName: newProp.displayName,
434
788
  type: newProp.type,
435
- required: newProp.required,
436
789
  ...(newProp.groupId ? { groupId: newProp.groupId } : {}),
437
790
  ...(newProp.enumTypeId ? { enumTypeId: newProp.enumTypeId } : {}),
438
- ...(newProp.filteredComponentIds ? { filteredComponentIds: newProp.filteredComponentIds } : {}),
439
- ...(newProp.privateVarMap ? { privateVarMap: newProp.privateVarMap } : {}),
791
+ ...(newProp.filteredComponentIds
792
+ ? { filteredComponentIds: newProp.filteredComponentIds }
793
+ : {}),
794
+ ...(newProp.privateVarMap
795
+ ? { privateVarMap: newProp.privateVarMap }
796
+ : {}),
797
+ ...(newProp.numberRangeData
798
+ ? { numberRangeData: newProp.numberRangeData }
799
+ : {}),
800
+ ...(newProp.requiredProp
801
+ ? {
802
+ requiredProp: newProp.requiredProp,
803
+ ...(newProp.requiredEnumOptionValue !== undefined
804
+ ? { requiredEnumOptionValue: newProp.requiredEnumOptionValue }
805
+ : { requiredPropValue: newProp.requiredPropValue ?? true }),
806
+ }
807
+ : {}),
440
808
  },
441
809
  }));
442
810
  }
443
- function parseDefaultValue(value, propType) {
811
+ export function parseDefaultValue(value, propType) {
444
812
  switch (propType) {
445
813
  case "NUMBER":
446
814
  return Number(value);
447
815
  case "BOOLEAN":
448
816
  return value === "true";
817
+ case "COMPONENT":
818
+ case "COMPONENT_LIST":
819
+ case "LINK":
820
+ case "LIST_OF_LINK":
821
+ case "SVG_LIST":
822
+ case "NUMBER_RANGE":
823
+ case "TYPE":
824
+ // These all carry a typed value (object/array), not a scalar. The --defaultValue flag arrives
825
+ // as a string, so parse it here; if it is not valid JSON, return the raw string so the type's
826
+ // validator (validateLinkDefaultValue / validateSvgDefaultValue / validateNumberRangeDefaultValue
827
+ // / validateTypeDefaultValue / validateComponentDefaultValue) can report a precise error instead
828
+ // of silently storing a bad shape.
829
+ try {
830
+ return JSON.parse(value);
831
+ }
832
+ catch {
833
+ return value;
834
+ }
449
835
  default:
450
836
  return value;
451
837
  }
452
838
  }
453
- function updateProp(componentName, options) {
454
- const { config, configPath } = loadConfig();
455
- const component = findComponent(config, componentName);
456
- if (!component) {
839
+ // Prop types whose value is a dynamic reference to merchant storefront data (products, media,
840
+ // categories, brands, blogs, raffles). They resolve at storefront render time, so a static config
841
+ // default is invalid and is rejected on add/update-prop. Keep in sync with @ikas/editor-models
842
+ // DYNAMIC_PROP_TYPES (the CLI must not depend on that private package).
843
+ export const DYNAMIC_PROP_TYPES = [
844
+ "IMAGE",
845
+ "IMAGE_LIST",
846
+ "VIDEO",
847
+ "PRODUCT",
848
+ "PRODUCT_LIST",
849
+ "PRODUCT_ATTRIBUTE",
850
+ "PRODUCT_ATTRIBUTE_LIST",
851
+ "BRAND",
852
+ "BRAND_LIST",
853
+ "CATEGORY",
854
+ "CATEGORY_LIST",
855
+ "BLOG",
856
+ "BLOG_LIST",
857
+ "BLOG_CATEGORY",
858
+ "BLOG_CATEGORY_LIST",
859
+ "RAFFLE",
860
+ "RAFFLE_LIST",
861
+ ];
862
+ const dynamicPropDefaultError = (propName, propType) => `defaultValue is not allowed for prop "${propName}" of type ${propType}. ${propType} resolves to merchant storefront data at render time, so it can't carry a static default — omit defaultValue.`;
863
+ // Reject a static default on a dynamic (merchant-data reference) prop type: print the standard
864
+ // error and exit. No-op when the type isn't dynamic or no default was supplied.
865
+ export const rejectDynamicDefault = (propName, propType, defaultValue) => {
866
+ if (DYNAMIC_PROP_TYPES.includes(propType) && defaultValue !== undefined) {
457
867
  console.log(JSON.stringify({
458
868
  success: false,
459
- error: `Component "${componentName}" not found. Available: ${getComponentNames(config).join(", ")}`,
869
+ error: dynamicPropDefaultError(propName, propType),
460
870
  }));
461
871
  process.exit(1);
462
872
  }
873
+ };
874
+ const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
875
+ // FILE is intentionally excluded from CLI-authored defaults: a code-component / AI-authored default
876
+ // can't reference a store-specific uploaded file, so a "FILE" link default is always rejected. Store
877
+ // owners can still create FILE links manually in the editor (editor-core keeps FILE in its own set).
878
+ const VALID_LINK_TYPES = ["PAGE", "EXTERNAL"];
879
+ const LINK_SHAPE_HELP = 'A link must be an object: { "linkType": "EXTERNAL", "label": "Text", "externalLink": "https://...", "subLinks": [] } ' +
880
+ 'for external links, or { "linkType": "PAGE", "label": "Text", "pageType": "INDEX", "subLinks": [] } for store pages. ' +
881
+ "Do NOT pass a JSON string or a { label, href } shape.";
882
+ // Returns the index of the first array element that fails `fn`, as an error string, or null.
883
+ const firstError = (arr, fn) => arr.reduce((acc, item, index) => acc ?? fn(item, index), null);
884
+ const validateSingleLink = (link, propName, path) => {
885
+ const where = `defaultValue for prop "${propName}"${path}`;
886
+ if (typeof link === "string") {
887
+ return `${where} is a JSON string but must be an object. ${LINK_SHAPE_HELP}`;
888
+ }
889
+ if (!link || typeof link !== "object" || Array.isArray(link)) {
890
+ return `${where} must be a link object. ${LINK_SHAPE_HELP}`;
891
+ }
892
+ const l = link;
893
+ if ("href" in l && !("linkType" in l)) {
894
+ return `${where} uses a legacy { href } shape without "linkType". Use "linkType" + "externalLink"/"pageType" instead. ${LINK_SHAPE_HELP}`;
895
+ }
896
+ if (typeof l.linkType !== "string" ||
897
+ !VALID_LINK_TYPES.includes(l.linkType)) {
898
+ return `${where} must have a valid "linkType" (one of ${VALID_LINK_TYPES.join(", ")}). ${LINK_SHAPE_HELP}`;
899
+ }
900
+ // Require the destination field for the declared linkType, so a link that points nowhere never
901
+ // reaches a published theme. A PAGE default carries a pageType (pageId is per-store, filled on
902
+ // materialization); pageId alone is also accepted for a specific page.
903
+ if (l.linkType === "PAGE" && !nonEmpty(l.pageType) && !nonEmpty(l.pageId)) {
904
+ return `${where}: a "PAGE" link needs a "pageType" (e.g. "INDEX") — or a "pageId" for a specific page. ${LINK_SHAPE_HELP}`;
905
+ }
906
+ if (l.linkType === "EXTERNAL" && !nonEmpty(l.externalLink)) {
907
+ return `${where}: an "EXTERNAL" link needs a non-empty "externalLink" URL. ${LINK_SHAPE_HELP}`;
908
+ }
909
+ if (l.subLinks !== undefined && !Array.isArray(l.subLinks)) {
910
+ return `${where}: "subLinks" must be an array (use [] when there are none).`;
911
+ }
912
+ if (Array.isArray(l.subLinks)) {
913
+ return firstError(l.subLinks, (sl, i) => validateSingleLink(sl, propName, `${path}.subLinks[${i}]`));
914
+ }
915
+ return null;
916
+ };
917
+ // Validates a LINK / LIST_OF_LINK defaultValue. Returns an error message, or null if valid.
918
+ // These defaults are a frequent source of bad data: agents tend to pass a JSON string or a
919
+ // legacy { label, href } shape. Rejecting them at authoring time keeps malformed link values
920
+ // out of published themes.
921
+ export function validateLinkDefaultValue(propType, value, propName) {
922
+ if (value === undefined || value === null)
923
+ return null;
924
+ if (propType === "LINK") {
925
+ return validateSingleLink(value, propName, "");
926
+ }
927
+ // LIST_OF_LINK
928
+ if (typeof value === "string") {
929
+ return `defaultValue for prop "${propName}" is a JSON string but must be an object { "links": [...] }. ${LINK_SHAPE_HELP}`;
930
+ }
931
+ if (!value ||
932
+ typeof value !== "object" ||
933
+ Array.isArray(value) ||
934
+ !Array.isArray(value.links)) {
935
+ return `defaultValue for prop "${propName}" must be an object with a "links" array: { "links": [ <link>, ... ] }. ${LINK_SHAPE_HELP}`;
936
+ }
937
+ const links = value.links;
938
+ return firstError(links, (link, i) => validateSingleLink(link, propName, `.links[${i}]`));
939
+ }
940
+ const COMPONENT_SHAPE_HELP = "A COMPONENT / COMPONENT_LIST default must be an object listing child code components: " +
941
+ '{ "components": [ { "codeComponentId": "<id>" } ] }. Only code components are allowed; each entry ' +
942
+ 'needs a string "codeComponentId" (no componentId, no inline propValues).';
943
+ /**
944
+ * Validate a COMPONENT / COMPONENT_LIST defaultValue. Reference-only: each entry must name a code
945
+ * component via `codeComponentId`. Rejects non-CC (`componentId`) children, missing ids, more than
946
+ * one entry for a single COMPONENT, and ids outside the prop's filteredComponentIds allowlist.
947
+ * Returns an error message, or null if valid.
948
+ */
949
+ export function validateComponentDefaultValue(propType, value, propName, filteredComponentIds, existingComponentIds) {
950
+ if (value === undefined || value === null)
951
+ return null;
952
+ const where = `defaultValue for prop "${propName}"`;
953
+ if (typeof value === "string") {
954
+ return `${where} is a JSON string but must be an object. ${COMPONENT_SHAPE_HELP}`;
955
+ }
956
+ if (!value ||
957
+ typeof value !== "object" ||
958
+ Array.isArray(value) ||
959
+ !Array.isArray(value.components)) {
960
+ return `${where} must be an object with a "components" array. ${COMPONENT_SHAPE_HELP}`;
961
+ }
962
+ const components = value.components;
963
+ if (propType === "COMPONENT" && components.length > 1) {
964
+ return `${where}: a COMPONENT prop holds a single child — provide at most one entry (use COMPONENT_LIST for many).`;
965
+ }
966
+ const allow = filteredComponentIds && filteredComponentIds.length
967
+ ? new Set(filteredComponentIds)
968
+ : null;
969
+ return firstError(components, (entry, i) => {
970
+ const at = `${where}.components[${i}]`;
971
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
972
+ return `${at} must be an object { "codeComponentId": "<id>" }. ${COMPONENT_SHAPE_HELP}`;
973
+ }
974
+ const e = entry;
975
+ if ("componentId" in e && !("codeComponentId" in e)) {
976
+ return `${at} references a non-code component (componentId). Only code components are allowed as defaults. ${COMPONENT_SHAPE_HELP}`;
977
+ }
978
+ if (typeof e.codeComponentId !== "string" ||
979
+ e.codeComponentId.trim().length === 0) {
980
+ return `${at} must have a non-empty string "codeComponentId". ${COMPONENT_SHAPE_HELP}`;
981
+ }
982
+ if (allow && !allow.has(e.codeComponentId)) {
983
+ return `${at}: "${e.codeComponentId}" is not in this prop's filteredComponentIds allowlist.`;
984
+ }
985
+ // Reject a reference to a component that does not exist in the project (deleted, renamed, or
986
+ // never created). A dangling default would seed a ghost entry on every placement that renders
987
+ // nothing. Only enforced when the caller supplies the known-component set (the config-backed
988
+ // add-prop / update-prop / batch sites); pure-shape callers pass no set and skip this check.
989
+ if (existingComponentIds && !existingComponentIds.has(e.codeComponentId)) {
990
+ return `${at}: no component with id "${e.codeComponentId}" exists in this project. Create it first (config add-component) or reference an existing component id (config list).`;
991
+ }
992
+ return null;
993
+ });
994
+ }
995
+ const SVG_DEFAULT_HELP = "An SVG default must be a single well-formed <svg>…</svg> string ≤ 64 KB with no embedded base64 raster images. " +
996
+ 'Tip: the editor uses each svg\'s `class` attribute as its display name — set class="my-icon" to name it; ' +
997
+ "SVG_LIST items with no class show as svg-1, svg-2, ….";
998
+ // Keep the cap and base64 pattern in sync with @ikas/editor-models validate-svg.ts (the
999
+ // authoritative validator). Duplicated here on purpose — see validateSvgShape below.
1000
+ const SVG_MAX_SIZE_KB = 64;
1001
+ const SVG_BASE64_RASTER_RE = /data:image\/[a-z0-9.+-]+;base64/i;
1002
+ const SVG_SHAPE_RE = /^\s*(?:<\?xml[^>]*\?>\s*)?(?:<!DOCTYPE[^>]*>\s*)?<svg[\s>][\s\S]*<\/svg>\s*$/i;
1003
+ // Count TOP-LEVEL <svg> roots without a DOM: scan svg open/close tags in order and track
1004
+ // nesting depth. A root is any <svg> opened at depth 0. This matches the editor's DOM-based
1005
+ // single-root check — it rejects siblings (`<svg/><svg/>`) while allowing a nested <svg>.
1006
+ // Self-closing <svg .../> counts as a root but opens no nesting level.
1007
+ function svgRootCount(input) {
1008
+ const tags = input.match(/<svg\b[^>]*>|<\/svg\s*>/gi) || [];
1009
+ return tags.reduce((acc, tag) => {
1010
+ if (tag.startsWith("</"))
1011
+ return { depth: Math.max(0, acc.depth - 1), roots: acc.roots };
1012
+ const roots = acc.depth === 0 ? acc.roots + 1 : acc.roots;
1013
+ const depth = /\/>\s*$/.test(tag) ? acc.depth : acc.depth + 1;
1014
+ return { depth, roots };
1015
+ }, { depth: 0, roots: 0 }).roots;
1016
+ }
1017
+ // Lightweight, dependency-free SVG structure check. This standalone CLI is publishable and
1018
+ // must not depend on the private @ikas/editor-models package (nor pull a DOM/DOMPurify into a
1019
+ // CLI), so it mirrors the RULES of the editor's validateSvg (size, base64, single <svg> root)
1020
+ // for fast authoring feedback. The authoritative sanitize runs in the editor/upload path.
1021
+ // Returns a rejection reason, or null if the shape is acceptable.
1022
+ function validateSvgShape(input) {
1023
+ if (typeof input !== "string" || input.trim() === "")
1024
+ return "empty or not a string";
1025
+ if (Buffer.byteLength(input, "utf8") > SVG_MAX_SIZE_KB * 1024)
1026
+ return `exceeds ${SVG_MAX_SIZE_KB} KB`;
1027
+ if (SVG_BASE64_RASTER_RE.test(input))
1028
+ return "embedded base64 raster image not allowed";
1029
+ if (!SVG_SHAPE_RE.test(input))
1030
+ return "not a single-root <svg> document";
1031
+ if (svgRootCount(input) !== 1)
1032
+ return "must be exactly one <svg> root";
1033
+ return null;
1034
+ }
1035
+ // Validates an SVG / SVG_LIST defaultValue. Returns an error message, or null if valid.
1036
+ // Authoring an SVG default is a common mistake source for agents; rejecting here (with an
1037
+ // instructive message) keeps malformed/oversized SVGs out of published components.
1038
+ export function validateSvgDefaultValue(propType, value, propName) {
1039
+ if (value === undefined || value === null)
1040
+ return null;
1041
+ if (propType === "SVG") {
1042
+ const reason = validateSvgShape(value);
1043
+ return reason
1044
+ ? `defaultValue for prop "${propName}" is not a valid SVG: ${reason}. ${SVG_DEFAULT_HELP}`
1045
+ : null;
1046
+ }
1047
+ // SVG_LIST
1048
+ if (!Array.isArray(value)) {
1049
+ return `defaultValue for prop "${propName}" must be an array of SVG strings: [ "<svg>…</svg>", ... ]. ${SVG_DEFAULT_HELP}`;
1050
+ }
1051
+ return firstError(value, (svg, i) => {
1052
+ const reason = validateSvgShape(svg);
1053
+ return reason
1054
+ ? `defaultValue[${i}] for prop "${propName}" is not a valid SVG: ${reason}. ${SVG_DEFAULT_HELP}`
1055
+ : null;
1056
+ });
1057
+ }
1058
+ const NUMBER_RANGE_DEFAULT_HELP = 'A NUMBER_RANGE default must be an object { "value": <number>, "unit": "px" | null }. ' +
1059
+ "When the prop defines numberRangeData, value must be within [min, max] and on the interval grid (min + k·interval).";
1060
+ // Validates a NUMBER_RANGE defaultValue. Returns an error message, or null if valid.
1061
+ // Checks the { value, unit } shape and — when the prop carries numberRangeData — that the
1062
+ // value is within [min, max] and lands on the interval grid, mirroring the editor's slider
1063
+ // so an agent can't author an out-of-range or off-step default.
1064
+ export function validateNumberRangeDefaultValue(value, numberRangeData, propName) {
1065
+ if (value === undefined || value === null)
1066
+ return null;
1067
+ const where = `defaultValue for prop "${propName}"`;
1068
+ if (typeof value === "string") {
1069
+ return `${where} is a JSON string but must be an object. ${NUMBER_RANGE_DEFAULT_HELP}`;
1070
+ }
1071
+ if (typeof value !== "object" || Array.isArray(value)) {
1072
+ return `${where} must be an object. ${NUMBER_RANGE_DEFAULT_HELP}`;
1073
+ }
1074
+ const v = value;
1075
+ if (typeof v.value !== "number" || !Number.isFinite(v.value)) {
1076
+ return `${where}: "value" must be a finite number. ${NUMBER_RANGE_DEFAULT_HELP}`;
1077
+ }
1078
+ if (v.unit !== undefined && v.unit !== null && typeof v.unit !== "string") {
1079
+ return `${where}: "unit" must be a string or null. ${NUMBER_RANGE_DEFAULT_HELP}`;
1080
+ }
1081
+ if (numberRangeData) {
1082
+ const { min, max, interval } = numberRangeData;
1083
+ if (min !== undefined && v.value < min) {
1084
+ return `${where}: value ${v.value} is below the configured min (${min}).`;
1085
+ }
1086
+ if (max !== undefined && v.value > max) {
1087
+ return `${where}: value ${v.value} is above the configured max (${max}).`;
1088
+ }
1089
+ if (interval !== undefined &&
1090
+ interval > 0 &&
1091
+ min !== undefined &&
1092
+ (v.value - min) % interval !== 0) {
1093
+ return `${where}: value ${v.value} is not on the interval grid (min ${min} + k·${interval}).`;
1094
+ }
1095
+ }
1096
+ return null;
1097
+ }
1098
+ const NUMBER_DEFAULT_HELP = "A NUMBER default must be a finite number, e.g. --defaultValue 42.";
1099
+ // Validates a NUMBER defaultValue. Returns an error message, or null if valid. Guards two footguns:
1100
+ // an empty/whitespace flag string (Number("") === 0 would silently store 0) and a non-numeric or
1101
+ // non-finite value (e.g. "42px" → NaN, "Infinity"). The flag path passes the raw --defaultValue
1102
+ // string; the --props path passes the already-parsed JSON value — both are handled here.
1103
+ export function validateNumberDefaultValue(value, propName) {
1104
+ if (value === undefined || value === null)
1105
+ return null;
1106
+ const where = `defaultValue for prop "${propName}"`;
1107
+ if (typeof value === "string") {
1108
+ if (value.trim() === "") {
1109
+ return `${where} is empty, but NUMBER requires a finite number. ${NUMBER_DEFAULT_HELP}`;
1110
+ }
1111
+ if (!Number.isFinite(Number(value))) {
1112
+ return `${where} ("${value}") is not a finite number. ${NUMBER_DEFAULT_HELP}`;
1113
+ }
1114
+ return null;
1115
+ }
1116
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1117
+ return `${where} must be a finite number. ${NUMBER_DEFAULT_HELP}`;
1118
+ }
1119
+ return null;
1120
+ }
1121
+ const STYLE_TYPE_DEFAULT_HELP = "A size/style TYPE default must be an object matching the type's shape: " +
1122
+ '{ "value": <number>, "unit": "px" | "rem" | "%" | "vh" | "vw" } (or { "css": "<raw css>" }). ' +
1123
+ "Author it via add-component --props '[…]' or --defaultValue '<json>'.";
1124
+ const BP_STOREFRONT_PREFIX = "@ikas/bp-storefront-models-";
1125
+ // The size/style types that share the { css?, value?, unit? } shape. This is EXACTLY the set of
1126
+ // TYPE typeIds whose default the generator emits (mirror of DYNAMIC_STYLE_TYPE_IDS in
1127
+ // @ikas/blueprint-modules design-generator/generate-helpers/styles.ts — the standalone CLI must not
1128
+ // depend on that package, so it is hand-maintained here and kept in sync by a parity test). Every
1129
+ // OTHER TYPE typeId (BorderStyleType, BoxShadowStyleType, GridTemplateColumnsStyleType, string-union
1130
+ // styles like TextAlignStyleType, IkasProduct, custom types) has a DIFFERENT value shape, so their
1131
+ // defaults are not shape-checked (and are dead code in the generator anyway).
1132
+ const SIZE_STYLE_TYPE_NAMES = [
1133
+ "SizeStyleType",
1134
+ "PaddingStyleType",
1135
+ "PaddingTopStyleType",
1136
+ "PaddingRightStyleType",
1137
+ "PaddingBottomStyleType",
1138
+ "PaddingLeftStyleType",
1139
+ "MarginStyleType",
1140
+ "MarginTopStyleType",
1141
+ "MarginRightStyleType",
1142
+ "MarginBottomStyleType",
1143
+ "MarginLeftStyleType",
1144
+ "BorderRadiusStyleType",
1145
+ "BorderRadiusTopLeftStyleType",
1146
+ "BorderRadiusTopRightStyleType",
1147
+ "BorderRadiusBottomRightStyleType",
1148
+ "BorderRadiusBottomLeftStyleType",
1149
+ "FontSizeStyleType",
1150
+ "LineHeightStyleType",
1151
+ "LetterSpacingStyleType",
1152
+ "HeightStyleType",
1153
+ "MinHeightStyleType",
1154
+ "MaxHeightStyleType",
1155
+ "WidthStyleType",
1156
+ "MinWidthStyleType",
1157
+ "MaxWidthStyleType",
1158
+ "GapStyleType",
1159
+ "BorderWidthStyleType",
1160
+ "TopStyleType",
1161
+ "RightStyleType",
1162
+ "BottomStyleType",
1163
+ "LeftStyleType",
1164
+ ];
1165
+ // Validates a single style-type default object. Every size style type shares the optional
1166
+ // { css?, value?, unit? } shape, so inner fields are checked loosely — the key rejection is a
1167
+ // non-object (e.g. a bare JSON string, the common failure when a --defaultValue flag is not parsed).
1168
+ function styleDefaultObjectError(value, where) {
1169
+ if (typeof value === "string") {
1170
+ return `${where} is a JSON string but must be an object. ${STYLE_TYPE_DEFAULT_HELP}`;
1171
+ }
1172
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1173
+ return `${where} must be an object. ${STYLE_TYPE_DEFAULT_HELP}`;
1174
+ }
1175
+ const v = value;
1176
+ // css/value/unit are all optional, and the editor treats an explicit null as "unset" (it accepts
1177
+ // { value: null }). So only type-check a field that is present AND non-null — otherwise the CLI
1178
+ // would be stricter than the authoritative editor and reject a value it would happily accept.
1179
+ // Unknown fields ARE rejected below: every size-style type shares exactly this { css?, value?, unit? }
1180
+ // shape, so an extra key is always a mistake (this matches the editor). The per-type value-DOMAIN
1181
+ // (which units a given type allows — BorderWidth also permits em/vmin/vmax while the rest are
1182
+ // px/rem/%/vh/vw) stays editor-side: it is per-type and can't be mirrored offline without drift.
1183
+ if (v.css !== undefined && v.css !== null && typeof v.css !== "string") {
1184
+ return `${where}: "css" must be a string. ${STYLE_TYPE_DEFAULT_HELP}`;
1185
+ }
1186
+ if (v.value !== undefined &&
1187
+ v.value !== null &&
1188
+ (typeof v.value !== "number" || !Number.isFinite(v.value))) {
1189
+ return `${where}: "value" must be a finite number. ${STYLE_TYPE_DEFAULT_HELP}`;
1190
+ }
1191
+ if (v.unit !== undefined && v.unit !== null && typeof v.unit !== "string") {
1192
+ return `${where}: "unit" must be a string. ${STYLE_TYPE_DEFAULT_HELP}`;
1193
+ }
1194
+ const unknownKey = Object.keys(v).find((k) => k !== "css" && k !== "value" && k !== "unit");
1195
+ if (unknownKey) {
1196
+ return `${where}: unknown field "${unknownKey}". A size/style default allows only "css", "value", "unit". ${STYLE_TYPE_DEFAULT_HELP}`;
1197
+ }
1198
+ return null;
1199
+ }
1200
+ // Validates a TYPE prop defaultValue — but ONLY for the size/style types whose shape we know
1201
+ // ({ css?, value?, unit? }). Any other TYPE typeId (color/border/box-shadow/grid/string-union
1202
+ // styles, IkasProduct, custom types) has a different value shape we can't verify offline, so its
1203
+ // default is passed through unchecked. The generator only emits a default for these size/style
1204
+ // types anyway. Returns an error message, or null if valid / not a size-style type.
1205
+ export function validateTypeDefaultValue(value, propName, typeId) {
1206
+ if (value === undefined || value === null)
1207
+ return null;
1208
+ if (typeof typeId !== "string" || !typeId.startsWith(BP_STOREFRONT_PREFIX)) {
1209
+ return null;
1210
+ }
1211
+ // Strip the prefix and an optional _array suffix to get the base type name.
1212
+ const withoutPrefix = typeId.slice(BP_STOREFRONT_PREFIX.length);
1213
+ const isArray = withoutPrefix.endsWith("_array");
1214
+ const baseName = isArray
1215
+ ? withoutPrefix.slice(0, -"_array".length)
1216
+ : withoutPrefix;
1217
+ if (!SIZE_STYLE_TYPE_NAMES.includes(baseName))
1218
+ return null;
1219
+ const where = `defaultValue for prop "${propName}"`;
1220
+ if (isArray) {
1221
+ if (!Array.isArray(value)) {
1222
+ return `${where} must be an array for an _array TYPE prop: [ { "value": …, "unit": … }, … ]. ${STYLE_TYPE_DEFAULT_HELP}`;
1223
+ }
1224
+ return firstError(value, (el, i) => styleDefaultObjectError(el, `${where}[${i}]`));
1225
+ }
1226
+ return styleDefaultObjectError(value, where);
1227
+ }
1228
+ async function updateProp(ref, options) {
1229
+ const { config, configPath } = loadConfig();
1230
+ const component = resolveComponent(config, ref);
463
1231
  const propIndex = component.props.findIndex((p) => p.name === options.prop);
464
1232
  if (propIndex === -1) {
465
1233
  console.log(JSON.stringify({
@@ -489,8 +1257,87 @@ function updateProp(componentName, options) {
489
1257
  if (options.description !== undefined) {
490
1258
  prop.description = options.description || undefined;
491
1259
  }
1260
+ // Resolve numberRangeData before defaultValue so a NUMBER_RANGE default can be validated
1261
+ // against the (possibly just-updated) slider config in the same call.
1262
+ if (options.numberRangeData !== undefined) {
1263
+ if (options.numberRangeData === "" || options.numberRangeData === "none") {
1264
+ delete prop.numberRangeData;
1265
+ }
1266
+ else {
1267
+ prop.numberRangeData = parseNumberRangeData(options.numberRangeData, prop.name);
1268
+ }
1269
+ }
1270
+ // A NUMBER_RANGE prop must keep a valid slider config (min + max). Enforce the invariant only when
1271
+ // this update touches the type or numberRangeData — a benign update (e.g. displayName) on an
1272
+ // existing prop must still pass. This catches switching a prop TO NUMBER_RANGE without a config, or
1273
+ // clearing/blanking numberRangeData on a NUMBER_RANGE prop.
1274
+ if ((options.type !== undefined || options.numberRangeData !== undefined) &&
1275
+ prop.type === "NUMBER_RANGE" &&
1276
+ (!prop.numberRangeData || !isNumberRangeComplete(prop.numberRangeData))) {
1277
+ console.log(JSON.stringify({
1278
+ success: false,
1279
+ error: numberRangeDataRequiredError(prop.name),
1280
+ }));
1281
+ process.exit(1);
1282
+ }
1283
+ // Resolve filteredComponentIds before defaultValue so a COMPONENT / COMPONENT_LIST default can be
1284
+ // validated against the (possibly just-updated) allowlist in the same call.
1285
+ if (options.filteredComponentIds !== undefined) {
1286
+ if (options.filteredComponentIds === "" ||
1287
+ options.filteredComponentIds === "none") {
1288
+ delete prop.filteredComponentIds;
1289
+ }
1290
+ else {
1291
+ try {
1292
+ const parsed = JSON.parse(options.filteredComponentIds);
1293
+ if (!Array.isArray(parsed)) {
1294
+ console.log(JSON.stringify({
1295
+ success: false,
1296
+ error: "--filteredComponentIds must be a JSON array of strings.",
1297
+ }));
1298
+ process.exit(1);
1299
+ }
1300
+ assertKnownComponentIds(parsed, config);
1301
+ prop.filteredComponentIds = parsed;
1302
+ }
1303
+ catch (err) {
1304
+ if (err instanceof SyntaxError) {
1305
+ console.log(JSON.stringify({
1306
+ success: false,
1307
+ error: `Invalid --filteredComponentIds JSON: ${options.filteredComponentIds}`,
1308
+ }));
1309
+ process.exit(1);
1310
+ }
1311
+ throw err;
1312
+ }
1313
+ }
1314
+ }
492
1315
  if (options.defaultValue !== undefined) {
493
- prop.defaultValue = parseDefaultValue(options.defaultValue, prop.type);
1316
+ rejectDynamicDefault(prop.name, prop.type, options.defaultValue);
1317
+ const parsed = parseDefaultValue(options.defaultValue, prop.type);
1318
+ // Validate against the raw flag string so an empty --defaultValue (Number("") → 0) is caught.
1319
+ if (prop.type === "NUMBER") {
1320
+ const numError = validateNumberDefaultValue(options.defaultValue, prop.name);
1321
+ failIf(numError);
1322
+ }
1323
+ if (prop.type === "LINK" || prop.type === "LIST_OF_LINK") {
1324
+ const linkError = validateLinkDefaultValue(prop.type, parsed, prop.name);
1325
+ failIf(linkError);
1326
+ }
1327
+ if ((prop.type === "COMPONENT" || prop.type === "COMPONENT_LIST") &&
1328
+ parsed !== undefined) {
1329
+ failIf(validateComponentDefaultValue(prop.type, parsed, prop.name, prop.filteredComponentIds, existingComponentIdSet(config)));
1330
+ }
1331
+ if (prop.type === "NUMBER_RANGE") {
1332
+ const nrError = validateNumberRangeDefaultValue(parsed, prop.numberRangeData, prop.name);
1333
+ failIf(nrError);
1334
+ }
1335
+ if (prop.type === "TYPE") {
1336
+ // Prefer a typeId supplied in this same update over the stored one.
1337
+ const typeError = validateTypeDefaultValue(parsed, prop.name, options.typeId ?? prop.typeId);
1338
+ failIf(typeError);
1339
+ }
1340
+ prop.defaultValue = parsed;
494
1341
  }
495
1342
  if (options.group !== undefined) {
496
1343
  if (options.group === "" || options.group === "none") {
@@ -523,6 +1370,13 @@ function updateProp(componentName, options) {
523
1370
  prop.typeId = options.typeId;
524
1371
  }
525
1372
  }
1373
+ // Reject section-disallowed types when the caller changes type/typeId;
1374
+ // untouched legacy props stay editable (displayName, group, ...).
1375
+ if ((options.type !== undefined || options.typeId !== undefined) &&
1376
+ prop.type === "TYPE" &&
1377
+ prop.typeId) {
1378
+ await assertTypeIdsUsable(component.type, [prop.typeId]);
1379
+ }
526
1380
  if (options.enumTypeId !== undefined) {
527
1381
  if (options.enumTypeId === "" || options.enumTypeId === "none") {
528
1382
  delete prop.enumTypeId;
@@ -531,25 +1385,6 @@ function updateProp(componentName, options) {
531
1385
  prop.enumTypeId = options.enumTypeId;
532
1386
  }
533
1387
  }
534
- if (options.filteredComponentIds !== undefined) {
535
- if (options.filteredComponentIds === "" || options.filteredComponentIds === "none") {
536
- delete prop.filteredComponentIds;
537
- }
538
- else {
539
- try {
540
- const parsed = JSON.parse(options.filteredComponentIds);
541
- if (!Array.isArray(parsed)) {
542
- console.log(JSON.stringify({ success: false, error: "--filteredComponentIds must be a JSON array of strings." }));
543
- process.exit(1);
544
- }
545
- prop.filteredComponentIds = parsed;
546
- }
547
- catch {
548
- console.log(JSON.stringify({ success: false, error: `Invalid --filteredComponentIds JSON: ${options.filteredComponentIds}` }));
549
- process.exit(1);
550
- }
551
- }
552
- }
553
1388
  if (options.privateVarMap !== undefined) {
554
1389
  if (options.privateVarMap === "" || options.privateVarMap === "none") {
555
1390
  delete prop.privateVarMap;
@@ -558,17 +1393,50 @@ function updateProp(componentName, options) {
558
1393
  try {
559
1394
  const parsed = JSON.parse(options.privateVarMap);
560
1395
  if (typeof parsed !== "object" || Array.isArray(parsed)) {
561
- console.log(JSON.stringify({ success: false, error: "--privateVarMap must be a JSON object mapping keys to {id, typeId}." }));
1396
+ console.log(JSON.stringify({
1397
+ success: false,
1398
+ error: "--privateVarMap must be a JSON object mapping keys to {id, typeId}.",
1399
+ }));
562
1400
  process.exit(1);
563
1401
  }
564
1402
  prop.privateVarMap = parsed;
565
1403
  }
566
1404
  catch {
567
- console.log(JSON.stringify({ success: false, error: `Invalid --privateVarMap JSON: ${options.privateVarMap}` }));
1405
+ console.log(JSON.stringify({
1406
+ success: false,
1407
+ error: `Invalid --privateVarMap JSON: ${options.privateVarMap}`,
1408
+ }));
568
1409
  process.exit(1);
569
1410
  }
570
1411
  }
571
1412
  }
1413
+ if (options.requiredProp !== undefined) {
1414
+ if (options.requiredProp === "" || options.requiredProp === "none") {
1415
+ // Every field goes together — see `IProp.requiredPropId`.
1416
+ delete prop.requiredProp;
1417
+ delete prop.requiredPropValue;
1418
+ delete prop.requiredEnumOptionId;
1419
+ delete prop.requiredEnumOptionValue;
1420
+ }
1421
+ else {
1422
+ prop.requiredProp = options.requiredProp;
1423
+ }
1424
+ }
1425
+ if (options.requiredEnumOptionValue !== undefined && prop.requiredProp) {
1426
+ prop.requiredEnumOptionId = options.requiredEnumOptionValue;
1427
+ prop.requiredEnumOptionValue = options.requiredEnumOptionValue;
1428
+ delete prop.requiredPropValue;
1429
+ }
1430
+ if (options.requiredPropValue !== undefined && prop.requiredProp) {
1431
+ delete prop.requiredEnumOptionId;
1432
+ delete prop.requiredEnumOptionValue;
1433
+ prop.requiredPropValue = parseRequiredPropValue(options.requiredPropValue, `prop "${prop.name}"`);
1434
+ }
1435
+ // Re-checked on every update, not only when a visibility flag is passed: flipping `required`, or
1436
+ // renaming the prop this one hangs on, can invalidate a gate that was sound when it was written.
1437
+ failIf(visibilityGateError(component, prop, `prop "${prop.name}"`, {
1438
+ selfPropName: prop.name,
1439
+ }));
572
1440
  saveConfig(configPath, config);
573
1441
  // Regenerate types.ts and global types
574
1442
  regenerateTypes(component, undefined, config);
@@ -580,25 +1448,30 @@ function updateProp(componentName, options) {
580
1448
  name: prop.name,
581
1449
  displayName: prop.displayName,
582
1450
  type: prop.type,
583
- required: prop.required,
584
1451
  ...(prop.groupId ? { groupId: prop.groupId } : {}),
585
1452
  ...(prop.typeId ? { typeId: prop.typeId } : {}),
586
1453
  ...(prop.enumTypeId ? { enumTypeId: prop.enumTypeId } : {}),
587
- ...(prop.filteredComponentIds ? { filteredComponentIds: prop.filteredComponentIds } : {}),
1454
+ ...(prop.filteredComponentIds
1455
+ ? { filteredComponentIds: prop.filteredComponentIds }
1456
+ : {}),
588
1457
  ...(prop.privateVarMap ? { privateVarMap: prop.privateVarMap } : {}),
1458
+ ...(prop.numberRangeData
1459
+ ? { numberRangeData: prop.numberRangeData }
1460
+ : {}),
1461
+ ...(prop.requiredProp
1462
+ ? {
1463
+ requiredProp: prop.requiredProp,
1464
+ ...(prop.requiredEnumOptionValue !== undefined
1465
+ ? { requiredEnumOptionValue: prop.requiredEnumOptionValue }
1466
+ : { requiredPropValue: prop.requiredPropValue ?? true }),
1467
+ }
1468
+ : {}),
589
1469
  },
590
1470
  }));
591
1471
  }
592
- function removeProp(componentName, options) {
1472
+ function removeProp(ref, options) {
593
1473
  const { config, configPath } = loadConfig();
594
- const component = findComponent(config, componentName);
595
- if (!component) {
596
- console.log(JSON.stringify({
597
- success: false,
598
- error: `Component "${componentName}" not found. Available: ${getComponentNames(config).join(", ")}`,
599
- }));
600
- process.exit(1);
601
- }
1474
+ const component = resolveComponent(config, ref);
602
1475
  const propIndex = component.props.findIndex((p) => p.name === options.prop);
603
1476
  if (propIndex === -1) {
604
1477
  console.log(JSON.stringify({
@@ -619,21 +1492,30 @@ function removeProp(componentName, options) {
619
1492
  remainingProps: component.props.map((p) => p.name),
620
1493
  }));
621
1494
  }
622
- function removeComponent(options) {
623
- const { config, configPath } = loadConfig();
624
- const pascalName = toPascalCase(options.name);
625
- const componentIndex = config.components.findIndex((c) => c.name === pascalName);
626
- if (componentIndex === -1) {
627
- console.log(JSON.stringify({
628
- success: false,
629
- error: `Component "${options.name}" not found. Available: ${getComponentNames(config).join(", ")}`,
630
- }));
631
- process.exit(1);
1495
+ /**
1496
+ * Strip a deleted component's ID from every other component's COMPONENT/COMPONENT_LIST
1497
+ * `filteredComponentIds` allowlist. Empty arrays are deleted to keep the field optional
1498
+ * (mirrors the editor-side convention).
1499
+ */
1500
+ function pruneFilteredComponentIdRefs(config, removedId) {
1501
+ for (const component of config.components) {
1502
+ for (const prop of component.props || []) {
1503
+ if (!prop.filteredComponentIds)
1504
+ continue;
1505
+ prop.filteredComponentIds = prop.filteredComponentIds.filter((id) => id !== removedId);
1506
+ if (prop.filteredComponentIds.length === 0)
1507
+ delete prop.filteredComponentIds;
1508
+ }
632
1509
  }
633
- const component = config.components[componentIndex];
1510
+ }
1511
+ function removeComponent(ref) {
1512
+ const { config, configPath } = loadConfig();
1513
+ const component = resolveComponent(config, ref);
1514
+ const componentIndex = config.components.indexOf(component);
634
1515
  const componentDir = path.resolve(process.cwd(), path.dirname(component.entry));
635
- // Remove component from config
1516
+ // Remove component from config and strip orphaned references from remaining components
636
1517
  config.components.splice(componentIndex, 1);
1518
+ pruneFilteredComponentIdRefs(config, component.id);
637
1519
  saveConfig(configPath, config);
638
1520
  // Remove component directory
639
1521
  if (fs.existsSync(componentDir)) {
@@ -643,21 +1525,18 @@ function removeComponent(options) {
643
1525
  updateBarrelExport(process.cwd(), getComponentNames(config));
644
1526
  console.log(JSON.stringify({
645
1527
  success: true,
646
- removedComponent: pascalName,
1528
+ removedComponentId: component.id,
1529
+ removedComponentName: component.name,
647
1530
  removedDirectory: path.relative(process.cwd(), componentDir),
648
- remainingComponents: getComponentNames(config),
1531
+ remainingComponents: config.components.map((c) => ({
1532
+ id: c.id,
1533
+ name: c.name,
1534
+ })),
649
1535
  }));
650
1536
  }
651
- function addPropGroup(componentName, options) {
1537
+ function addPropGroup(ref, options) {
652
1538
  const { config, configPath } = loadConfig();
653
- const component = findComponent(config, componentName);
654
- if (!component) {
655
- console.log(JSON.stringify({
656
- success: false,
657
- error: `Component "${componentName}" not found. Available: ${getComponentNames(config).join(", ")}`,
658
- }));
659
- process.exit(1);
660
- }
1539
+ const component = resolveComponent(config, ref);
661
1540
  if (!component.propGroups)
662
1541
  component.propGroups = [];
663
1542
  // Check uniqueness
@@ -673,7 +1552,24 @@ function addPropGroup(componentName, options) {
673
1552
  id: options.id,
674
1553
  name: options.name,
675
1554
  ...(options.description ? { description: options.description } : {}),
1555
+ ...(options.requiredProp ? { requiredProp: options.requiredProp } : {}),
1556
+ ...(options.requiredProp && options.requiredEnumOptionValue !== undefined
1557
+ ? {
1558
+ requiredEnumOptionId: options.requiredEnumOptionValue,
1559
+ requiredEnumOptionValue: options.requiredEnumOptionValue,
1560
+ }
1561
+ : {}),
1562
+ ...(options.requiredProp &&
1563
+ options.requiredEnumOptionValue === undefined &&
1564
+ options.requiredPropValue !== undefined
1565
+ ? {
1566
+ requiredPropValue: parseRequiredPropValue(options.requiredPropValue, `group "${options.id}"`),
1567
+ }
1568
+ : {}),
676
1569
  };
1570
+ failIf(visibilityGateError(component, newGroup, `group "${newGroup.id}"`, {
1571
+ group: newGroup,
1572
+ }));
677
1573
  if (options.parent) {
678
1574
  const found = findPropGroup(component.propGroups, options.parent);
679
1575
  if (!found) {
@@ -705,19 +1601,20 @@ function addPropGroup(componentName, options) {
705
1601
  id: newGroup.id,
706
1602
  name: newGroup.name,
707
1603
  ...(options.parent ? { parentId: options.parent } : {}),
1604
+ ...(newGroup.requiredProp
1605
+ ? {
1606
+ requiredProp: newGroup.requiredProp,
1607
+ ...(newGroup.requiredEnumOptionValue !== undefined
1608
+ ? { requiredEnumOptionValue: newGroup.requiredEnumOptionValue }
1609
+ : { requiredPropValue: newGroup.requiredPropValue ?? true }),
1610
+ }
1611
+ : {}),
708
1612
  },
709
1613
  }));
710
1614
  }
711
- function updatePropGroup(componentName, options) {
1615
+ function updatePropGroup(ref, options) {
712
1616
  const { config, configPath } = loadConfig();
713
- const component = findComponent(config, componentName);
714
- if (!component) {
715
- console.log(JSON.stringify({
716
- success: false,
717
- error: `Component "${componentName}" not found. Available: ${getComponentNames(config).join(", ")}`,
718
- }));
719
- process.exit(1);
720
- }
1617
+ const component = resolveComponent(config, ref);
721
1618
  if (!component.propGroups || component.propGroups.length === 0) {
722
1619
  console.log(JSON.stringify({
723
1620
  success: false,
@@ -737,6 +1634,34 @@ function updatePropGroup(componentName, options) {
737
1634
  found.group.name = options.name;
738
1635
  if (options.description !== undefined)
739
1636
  found.group.description = options.description || undefined;
1637
+ if (options.requiredProp !== undefined) {
1638
+ if (options.requiredProp === "" || options.requiredProp === "none") {
1639
+ // Every field goes together — see `IThemePropGroup.requiredPropId`.
1640
+ delete found.group.requiredProp;
1641
+ delete found.group.requiredPropValue;
1642
+ delete found.group.requiredEnumOptionId;
1643
+ delete found.group.requiredEnumOptionValue;
1644
+ }
1645
+ else {
1646
+ found.group.requiredProp = options.requiredProp;
1647
+ }
1648
+ }
1649
+ if (options.requiredEnumOptionValue !== undefined &&
1650
+ found.group.requiredProp) {
1651
+ found.group.requiredEnumOptionId = options.requiredEnumOptionValue;
1652
+ found.group.requiredEnumOptionValue = options.requiredEnumOptionValue;
1653
+ delete found.group.requiredPropValue;
1654
+ }
1655
+ if (options.requiredPropValue !== undefined && found.group.requiredProp) {
1656
+ delete found.group.requiredEnumOptionId;
1657
+ delete found.group.requiredEnumOptionValue;
1658
+ found.group.requiredPropValue = parseRequiredPropValue(options.requiredPropValue, `group "${found.group.id}"`);
1659
+ }
1660
+ // Re-checked on every update: moving a prop into this group can invalidate a gate that was sound
1661
+ // when it was written.
1662
+ failIf(visibilityGateError(component, found.group, `group "${found.group.id}"`, {
1663
+ group: found.group,
1664
+ }));
740
1665
  saveConfig(configPath, config);
741
1666
  console.log(JSON.stringify({
742
1667
  success: true,
@@ -744,19 +1669,22 @@ function updatePropGroup(componentName, options) {
744
1669
  updatedPropGroup: {
745
1670
  id: found.group.id,
746
1671
  name: found.group.name,
1672
+ ...(found.group.requiredProp
1673
+ ? {
1674
+ requiredProp: found.group.requiredProp,
1675
+ ...(found.group.requiredEnumOptionValue !== undefined
1676
+ ? {
1677
+ requiredEnumOptionValue: found.group.requiredEnumOptionValue,
1678
+ }
1679
+ : { requiredPropValue: found.group.requiredPropValue ?? true }),
1680
+ }
1681
+ : {}),
747
1682
  },
748
1683
  }));
749
1684
  }
750
- function removePropGroup(componentName, options) {
1685
+ function removePropGroup(ref, options) {
751
1686
  const { config, configPath } = loadConfig();
752
- const component = findComponent(config, componentName);
753
- if (!component) {
754
- console.log(JSON.stringify({
755
- success: false,
756
- error: `Component "${componentName}" not found. Available: ${getComponentNames(config).join(", ")}`,
757
- }));
758
- process.exit(1);
759
- }
1687
+ const component = resolveComponent(config, ref);
760
1688
  if (!component.propGroups || component.propGroups.length === 0) {
761
1689
  console.log(JSON.stringify({
762
1690
  success: false,
@@ -773,10 +1701,10 @@ function removePropGroup(componentName, options) {
773
1701
  process.exit(1);
774
1702
  }
775
1703
  if (found.parent) {
776
- found.parent.children = found.parent.children.filter(c => c.id !== options.id);
1704
+ found.parent.children = found.parent.children.filter((c) => c.id !== options.id);
777
1705
  }
778
1706
  else {
779
- component.propGroups = component.propGroups.filter(g => g.id !== options.id);
1707
+ component.propGroups = component.propGroups.filter((g) => g.id !== options.id);
780
1708
  }
781
1709
  // Clear groupId from props referencing the deleted group
782
1710
  for (const prop of component.props) {
@@ -789,19 +1717,12 @@ function removePropGroup(componentName, options) {
789
1717
  success: true,
790
1718
  componentName: component.name,
791
1719
  removedPropGroup: options.id,
792
- remainingPropGroups: component.propGroups.map(g => g.id),
1720
+ remainingPropGroups: component.propGroups.map((g) => g.id),
793
1721
  }));
794
1722
  }
795
- function movePropGroup(componentName, options) {
1723
+ function movePropGroup(ref, options) {
796
1724
  const { config, configPath } = loadConfig();
797
- const component = findComponent(config, componentName);
798
- if (!component) {
799
- console.log(JSON.stringify({
800
- success: false,
801
- error: `Component "${componentName}" not found. Available: ${getComponentNames(config).join(", ")}`,
802
- }));
803
- process.exit(1);
804
- }
1725
+ const component = resolveComponent(config, ref);
805
1726
  if (!component.propGroups || component.propGroups.length === 0) {
806
1727
  console.log(JSON.stringify({
807
1728
  success: false,
@@ -813,7 +1734,10 @@ function movePropGroup(componentName, options) {
813
1734
  component.propGroups = movePropGroupInTree(component.propGroups, options.id, options.parent || null, options.index);
814
1735
  }
815
1736
  catch (e) {
816
- console.log(JSON.stringify({ success: false, error: e.message || "Failed to move prop group" }));
1737
+ console.log(JSON.stringify({
1738
+ success: false,
1739
+ error: e.message || "Failed to move prop group",
1740
+ }));
817
1741
  process.exit(1);
818
1742
  }
819
1743
  saveConfig(configPath, config);
@@ -824,16 +1748,9 @@ function movePropGroup(componentName, options) {
824
1748
  parentGroupId: options.parent || null,
825
1749
  }));
826
1750
  }
827
- function updateComponent(options) {
1751
+ function updateComponent(ref, options) {
828
1752
  const { config, configPath } = loadConfig();
829
- const component = findComponent(config, options.name);
830
- if (!component) {
831
- console.log(JSON.stringify({
832
- success: false,
833
- error: `Component "${options.name}" not found. Available: ${getComponentNames(config).join(", ")}`,
834
- }));
835
- process.exit(1);
836
- }
1753
+ const component = resolveComponent(config, ref);
837
1754
  if (options.isHeader !== undefined) {
838
1755
  if (options.isHeader) {
839
1756
  component.isHeader = true;
@@ -876,7 +1793,9 @@ function listComponents() {
876
1793
  required: p.required,
877
1794
  ...(p.groupId ? { groupId: p.groupId } : {}),
878
1795
  })),
879
- ...(c.propGroups && c.propGroups.length > 0 ? { propGroups: c.propGroups } : {}),
1796
+ ...(c.propGroups && c.propGroups.length > 0
1797
+ ? { propGroups: c.propGroups }
1798
+ : {}),
880
1799
  }));
881
1800
  console.log(JSON.stringify({
882
1801
  success: true,
@@ -890,7 +1809,9 @@ function listComponents() {
890
1809
  async function addEnum(options) {
891
1810
  const { config, configPath } = loadConfig();
892
1811
  // Validate name
893
- if (!options.name || typeof options.name !== "string" || !options.name.trim()) {
1812
+ if (!options.name ||
1813
+ typeof options.name !== "string" ||
1814
+ !options.name.trim()) {
894
1815
  console.log(JSON.stringify({ success: false, error: "Enum name is required." }));
895
1816
  process.exit(1);
896
1817
  }
@@ -1004,7 +1925,7 @@ function updateEnum(options) {
1004
1925
  Object.keys(enumOptions).length === 0) {
1005
1926
  console.log(JSON.stringify({
1006
1927
  success: false,
1007
- error: 'Options must be a non-empty JSON object.',
1928
+ error: "Options must be a non-empty JSON object.",
1008
1929
  }));
1009
1930
  process.exit(1);
1010
1931
  }
@@ -1023,7 +1944,7 @@ function updateEnum(options) {
1023
1944
  enumOptions: enumType.enumOptions,
1024
1945
  }));
1025
1946
  }
1026
- function removeEnum(options) {
1947
+ async function removeEnum(options) {
1027
1948
  const { config, configPath } = loadConfig();
1028
1949
  if (!config.customTypes)
1029
1950
  config.customTypes = [];
@@ -1047,6 +1968,26 @@ function removeEnum(options) {
1047
1968
  }
1048
1969
  }
1049
1970
  }
1971
+ // A theme ENUM global variable can select from this enum too. Those live in the editor's
1972
+ // project, not in ikas.config.json, so ask the connected editor (best effort: with no dev
1973
+ // server / editor the removal proceeds and the result carries a warning). The editor itself
1974
+ // refuses to delete a type its globals reference, so this mirrors that guard for the CLI path.
1975
+ const liveGlobals = await fetchLiveThemeGlobals();
1976
+ const referencingGlobals = liveGlobals
1977
+ ? Object.values(liveGlobals).filter((s) => s.type === "ENUM" && s.enumTypeId === options.id)
1978
+ : [];
1979
+ if (referencingGlobals.length > 0) {
1980
+ const names = referencingGlobals
1981
+ .map((s) => `"${s.displayName}" (${s.name})`)
1982
+ .join(", ");
1983
+ console.log(JSON.stringify({
1984
+ success: false,
1985
+ error: `Cannot remove enum "${config.customTypes[enumIndex].name}": theme global variable(s) ${names} ` +
1986
+ `in the connected editor select from it. Change or delete those globals first ` +
1987
+ `(update-global-variable --enum-type-id / delete-global-variable).`,
1988
+ }));
1989
+ process.exit(1);
1990
+ }
1050
1991
  const removed = config.customTypes.splice(enumIndex, 1)[0];
1051
1992
  saveConfig(configPath, config);
1052
1993
  // Regenerate global types and all component types
@@ -1058,6 +1999,11 @@ function removeEnum(options) {
1058
1999
  success: true,
1059
2000
  removedEnumId: removed.id,
1060
2001
  removedEnumName: removed.name,
2002
+ ...(liveGlobals === null
2003
+ ? {
2004
+ warning: "Editor not connected — could not verify that no theme global variable selects from this enum.",
2005
+ }
2006
+ : {}),
1061
2007
  }));
1062
2008
  }
1063
2009
  function listEnums() {
@@ -1077,8 +2023,7 @@ function listEnums() {
1077
2023
  }
1078
2024
  // --- Command registration ---
1079
2025
  export function createConfigCommand() {
1080
- const config = new Command("config")
1081
- .description("Non-interactive commands for managing ikas.config.json (designed for AI agents and scripting)");
2026
+ const config = new Command("config").description("Non-interactive commands for managing ikas.config.json (designed for AI agents and scripting)");
1082
2027
  config
1083
2028
  .command("add-component")
1084
2029
  .description("Add a new component to the project")
@@ -1086,14 +2031,16 @@ export function createConfigCommand() {
1086
2031
  .option("--type <type>", "Component type: section or component", "component")
1087
2032
  .option("--isHeader", "Mark this section as the store header (only for type: section)")
1088
2033
  .option("--isFooter", "Mark this section as the store footer (only for type: section)")
1089
- .option("--props <json>", "JSON array of props, e.g. '[{\"name\":\"title\",\"type\":\"TEXT\",\"required\":true}]'. Each prop needs name+type; displayName is auto-generated from name if omitted.")
2034
+ .option("--props <json>", "JSON array of props. Required per entry: name, type. Optional: displayName (auto from name), required, description, defaultValue, groupId, typeId (TYPE props), enumTypeId (ENUM props), filteredComponentIds, privateVarMap. " +
2035
+ 'Example: \'[{"name":"title","type":"TEXT","required":true,"defaultValue":"Hello","groupId":"basic"}]\'')
1090
2036
  .action((options) => {
1091
2037
  addComponent(options.name, options);
1092
2038
  });
1093
2039
  config
1094
2040
  .command("add-prop")
1095
2041
  .description("Add a prop to a component")
1096
- .requiredOption("--component <name>", "Component name")
2042
+ .option("--component-id <id>", "Component id (preferred; from `config list-components`)")
2043
+ .option("--component <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1097
2044
  .requiredOption("--name <name>", "Prop name (camelCase)")
1098
2045
  .requiredOption("--displayName <displayName>", "Display name in editor")
1099
2046
  .requiredOption("--type <type>", `Prop type: ${PROP_TYPES.join(", ")}`)
@@ -1104,14 +2051,19 @@ export function createConfigCommand() {
1104
2051
  .option("--typeId <typeId>", "Type ID for TYPE props (required when type is TYPE)")
1105
2052
  .option("--enumTypeId <enumTypeId>", "Enum type ID for ENUM props (required when type is ENUM)")
1106
2053
  .option("--filteredComponentIds <json>", "JSON array of component IDs to restrict selection (for COMPONENT/COMPONENT_LIST)")
1107
- .option("--privateVarMap <json>", 'JSON object mapping variable keys to {id, typeId} (for COMPONENT/COMPONENT_LIST)')
2054
+ .option("--privateVarMap <json>", "JSON object mapping variable keys to {id, typeId} (for COMPONENT/COMPONENT_LIST)")
2055
+ .option("--numberRangeData <json>", 'JSON slider config for NUMBER_RANGE props, e.g. \'{"min":0,"max":100,"interval":5,"unit":"px"}\'')
2056
+ .option("--requiredProp <propName>", 'Show this only while the named BOOLEAN or ENUM prop satisfies the condition ("Koşullu Prop")')
2057
+ .option("--requiredPropValue <boolean>", "For a BOOLEAN --requiredProp: which state shows it, true (default) or false")
2058
+ .option("--requiredEnumOptionValue <value>", "For an ENUM --requiredProp: the option value that shows it")
1108
2059
  .action((options) => {
1109
- addProp(options.component, options);
2060
+ addProp({ id: options.componentId, name: options.component }, options);
1110
2061
  });
1111
2062
  config
1112
2063
  .command("update-prop")
1113
2064
  .description("Update a prop on a component")
1114
- .requiredOption("--component <name>", "Component name")
2065
+ .option("--component-id <id>", "Component id (preferred; from `config list-components`)")
2066
+ .option("--component <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1115
2067
  .requiredOption("--prop <propName>", "Prop name to update")
1116
2068
  .option("--displayName <displayName>", "New display name")
1117
2069
  .option("--type <type>", "New prop type")
@@ -1123,77 +2075,100 @@ export function createConfigCommand() {
1123
2075
  .option("--enumTypeId <enumTypeId>", "Enum type ID for ENUM props (use 'none' to clear)")
1124
2076
  .option("--filteredComponentIds <json>", "JSON array of component IDs (use 'none' to clear)")
1125
2077
  .option("--privateVarMap <json>", "JSON object mapping variable keys to {id, typeId} (use 'none' to clear)")
2078
+ .option("--numberRangeData <json>", "JSON slider config for NUMBER_RANGE props (use 'none' to clear)")
2079
+ .option("--requiredProp <propName>", "BOOLEAN or ENUM prop that gates this prop in the editor panel (use 'none' to clear)")
2080
+ .option("--requiredPropValue <boolean>", "For a BOOLEAN --requiredProp: which state shows it, true (default) or false")
2081
+ .option("--requiredEnumOptionValue <value>", "For an ENUM --requiredProp: the option value that shows it")
1126
2082
  .action((options) => {
1127
- updateProp(options.component, options);
2083
+ updateProp({ id: options.componentId, name: options.component }, options);
1128
2084
  });
1129
2085
  config
1130
2086
  .command("remove-prop")
1131
2087
  .description("Remove a prop from a component")
1132
- .requiredOption("--component <name>", "Component name")
2088
+ .option("--component-id <id>", "Component id (preferred; from `config list-components`)")
2089
+ .option("--component <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1133
2090
  .requiredOption("--prop <propName>", "Prop name to remove")
1134
2091
  .action((options) => {
1135
- removeProp(options.component, options);
2092
+ removeProp({ id: options.componentId, name: options.component }, options);
1136
2093
  });
1137
2094
  config
1138
2095
  .command("update-component")
1139
- .description("Update a component's metadata (isHeader, isFooter)")
1140
- .requiredOption("--name <name>", "Component name")
2096
+ .description("Update a component's metadata (isHeader, isFooter). Identify by --id (preferred) or exact --name.")
2097
+ .option("--id <id>", "Component id (preferred; from `config list-components`)")
2098
+ .option("--name <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1141
2099
  .option("--isHeader", "Mark as store header")
1142
2100
  .option("--no-isHeader", "Unmark as store header")
1143
2101
  .option("--isFooter", "Mark as store footer")
1144
2102
  .option("--no-isFooter", "Unmark as store footer")
1145
2103
  .action((options) => {
1146
- updateComponent(options);
2104
+ updateComponent({ id: options.id, name: options.name }, options);
1147
2105
  });
1148
2106
  config
1149
2107
  .command("remove-component")
1150
- .description("Remove a component from the project (deletes files)")
1151
- .requiredOption("--name <name>", "Component name to remove")
2108
+ .description("Remove a component from the project (deletes files). Identify by --id (preferred) or exact --name.")
2109
+ .option("--id <id>", "Component id (preferred; from `config list-components`)")
2110
+ .option("--name <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1152
2111
  .action((options) => {
1153
- removeComponent(options);
2112
+ removeComponent({ id: options.id, name: options.name });
1154
2113
  });
1155
2114
  config
1156
2115
  .command("add-prop-group")
1157
2116
  .description("Add a prop group to a component")
1158
- .requiredOption("--component <name>", "Component name")
2117
+ .option("--component-id <componentId>", "Component id (preferred; from `config list-components`)")
2118
+ .option("--component <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1159
2119
  .requiredOption("--id <id>", "Group ID (unique within component, kebab-case)")
1160
2120
  .requiredOption("--name <name>", "Display name in editor")
1161
2121
  .option("--description <description>", "Group description")
1162
2122
  .option("--parent <parentId>", "Parent group ID (for nesting, max 1 level)")
2123
+ .option("--requiredProp <propName>", 'Show this only while the named BOOLEAN or ENUM prop satisfies the condition ("Koşullu Prop")')
2124
+ .option("--requiredPropValue <boolean>", "For a BOOLEAN --requiredProp: which state shows it, true (default) or false")
2125
+ .option("--requiredEnumOptionValue <value>", "For an ENUM --requiredProp: the option value that shows it")
1163
2126
  .action((options) => {
1164
- addPropGroup(options.component, options);
2127
+ addPropGroup({ id: options.componentId, name: options.component }, options);
1165
2128
  });
1166
2129
  config
1167
2130
  .command("update-prop-group")
1168
2131
  .description("Update a prop group on a component")
1169
- .requiredOption("--component <name>", "Component name")
2132
+ .option("--component-id <componentId>", "Component id (preferred; from `config list-components`)")
2133
+ .option("--component <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1170
2134
  .requiredOption("--id <id>", "Group ID to update")
1171
2135
  .option("--name <name>", "New display name")
1172
2136
  .option("--description <description>", "New description")
2137
+ .option("--requiredProp <propName>", "BOOLEAN or ENUM prop that gates this group in the editor panel (use 'none' to clear)")
2138
+ .option("--requiredPropValue <boolean>", "For a BOOLEAN --requiredProp: which state shows it, true (default) or false")
2139
+ .option("--requiredEnumOptionValue <value>", "For an ENUM --requiredProp: the option value that shows it")
1173
2140
  .action((options) => {
1174
- updatePropGroup(options.component, options);
2141
+ updatePropGroup({ id: options.componentId, name: options.component }, options);
1175
2142
  });
1176
2143
  config
1177
2144
  .command("remove-prop-group")
1178
2145
  .description("Remove a prop group from a component (props become ungrouped)")
1179
- .requiredOption("--component <name>", "Component name")
2146
+ .option("--component-id <componentId>", "Component id (preferred; from `config list-components`)")
2147
+ .option("--component <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1180
2148
  .requiredOption("--id <id>", "Group ID to remove")
1181
2149
  .action((options) => {
1182
- removePropGroup(options.component, options);
2150
+ removePropGroup({ id: options.componentId, name: options.component }, options);
1183
2151
  });
1184
2152
  config
1185
2153
  .command("move-prop-group")
1186
2154
  .description("Move a prop group to a different parent or position (for drag-and-drop reordering)")
1187
- .requiredOption("--component <name>", "Component name")
2155
+ .option("--component-id <componentId>", "Component id (preferred; from `config list-components`)")
2156
+ .option("--component <name>", "Component name — exact match (PascalCase, as stored in ikas.config.json)")
1188
2157
  .requiredOption("--id <id>", "Group ID to move")
1189
2158
  .option("--parent <parentId>", "Target parent group ID (omit to move to root)")
1190
- .option("--index <index>", "Zero-based insertion index within the target parent (appends when omitted)", v => parseInt(v, 10))
2159
+ .option("--index <index>", "Zero-based insertion index within the target parent (appends when omitted)", (v) => parseInt(v, 10))
1191
2160
  .action((options) => {
1192
- movePropGroup(options.component, options);
2161
+ movePropGroup({ id: options.componentId, name: options.component }, options);
2162
+ });
2163
+ config
2164
+ .command("list-components")
2165
+ .description("List all components and their props (with canonical ids for use with --id flags)")
2166
+ .action(() => {
2167
+ listComponents();
1193
2168
  });
1194
2169
  config
1195
2170
  .command("list")
1196
- .description("List all components and their props")
2171
+ .description("Alias for `list-components` (kept for backwards compatibility)")
1197
2172
  .action(() => {
1198
2173
  listComponents();
1199
2174
  });
@@ -1223,10 +2198,10 @@ export function createConfigCommand() {
1223
2198
  });
1224
2199
  config
1225
2200
  .command("remove-enum")
1226
- .description("Remove a custom enum type (fails if any prop references it)")
2201
+ .description("Remove a custom enum type (fails if any prop references it, or — while the editor is connected — if a theme ENUM global variable selects from it)")
1227
2202
  .requiredOption("--id <id>", "Enum ID to remove")
1228
- .action((options) => {
1229
- removeEnum(options);
2203
+ .action(async (options) => {
2204
+ await removeEnum(options);
1230
2205
  });
1231
2206
  config
1232
2207
  .command("list-enums")
@@ -1241,54 +2216,15 @@ export function createConfigCommand() {
1241
2216
  * Returns the matching type (with enumOptions) if found, null otherwise.
1242
2217
  */
1243
2218
  async function fetchLiveEditorEnumById(enumId) {
1244
- const { WebSocket } = await import("ws");
1245
- const WS_URL = "ws://localhost:5201";
1246
- const TIMEOUT_MS = 1500;
1247
- return new Promise((resolve) => {
1248
- let ws;
1249
- let settled = false;
1250
- const finish = (value) => {
1251
- if (settled)
1252
- return;
1253
- settled = true;
1254
- try {
1255
- ws?.close();
1256
- }
1257
- catch {
1258
- /* ignore */
1259
- }
1260
- resolve(value);
1261
- };
1262
- try {
1263
- ws = new WebSocket(WS_URL);
2219
+ const types = await fetchLiveEditorTypes();
2220
+ const match = types?.find((t) => t.category === "enum" && t.id === enumId);
2221
+ return match
2222
+ ? {
2223
+ id: match.id,
2224
+ name: match.name,
2225
+ ...(match.enumOptions ? { enumOptions: match.enumOptions } : {}),
1264
2226
  }
1265
- catch {
1266
- resolve(null);
1267
- return;
1268
- }
1269
- const timeout = setTimeout(() => finish(null), TIMEOUT_MS);
1270
- ws.on("open", () => {
1271
- ws.send(JSON.stringify({ type: "request-types" }));
1272
- });
1273
- ws.on("message", (data) => {
1274
- try {
1275
- const message = JSON.parse(data.toString());
1276
- if (message.type === "types-list") {
1277
- clearTimeout(timeout);
1278
- const types = message.payload?.types || [];
1279
- const match = types.find((t) => t.category === "enum" && t.id === enumId);
1280
- finish(match ? { id: match.id, name: match.name, ...(match.enumOptions ? { enumOptions: match.enumOptions } : {}) } : null);
1281
- }
1282
- }
1283
- catch {
1284
- // Ignore non-JSON messages
1285
- }
1286
- });
1287
- ws.on("error", () => {
1288
- clearTimeout(timeout);
1289
- finish(null);
1290
- });
1291
- });
2227
+ : null;
1292
2228
  }
1293
2229
  /**
1294
2230
  * Best-effort lookup of an enum by PascalCase name in the live editor session.
@@ -1298,8 +2234,37 @@ async function fetchLiveEditorEnumById(enumId) {
1298
2234
  * must treat null as "could not verify, fall back to on-disk check only".
1299
2235
  */
1300
2236
  async function fetchLiveEditorEnumByName(pascalName) {
2237
+ const types = await fetchLiveEditorTypes();
2238
+ const match = types?.find((t) => t.category === "enum" && toPascalCase(t.name) === pascalName);
2239
+ return match ? { id: match.id, name: match.name } : null;
2240
+ }
2241
+ /**
2242
+ * Best-effort read of the connected editor's theme global variables (the same editor action
2243
+ * `list-theme-globals` runs). Returns null on any failure — no dev server, no editor connected,
2244
+ * timeout — and callers must treat null as "could not verify, allow".
2245
+ */
2246
+ async function fetchLiveThemeGlobals() {
2247
+ try {
2248
+ const result = await runEditorAction("list-theme-globals", {}, { timeoutMs: 1500 });
2249
+ if (!result.success)
2250
+ return null;
2251
+ const settings = result.payload?.settings;
2252
+ return settings && typeof settings === "object" ? settings : null;
2253
+ }
2254
+ catch {
2255
+ return null;
2256
+ }
2257
+ }
2258
+ /**
2259
+ * Best-effort fetch of the editor's synced type list (with category /
2260
+ * sectionAllowed / enumOptions metadata). Returns null on any failure (no dev
2261
+ * server, timeout) — callers must treat null as "could not verify, allow".
2262
+ */
2263
+ async function fetchLiveEditorTypes() {
1301
2264
  const { WebSocket } = await import("ws");
1302
- const WS_URL = "ws://localhost:5201";
2265
+ // `client=control` keeps this short-lived socket out of the dev server's
2266
+ // `editor-action` target pool — it only reads types, it can't apply commands.
2267
+ const WS_URL = "ws://localhost:5201/?client=control";
1303
2268
  const TIMEOUT_MS = 1500;
1304
2269
  return new Promise((resolve) => {
1305
2270
  let ws;
@@ -1332,9 +2297,7 @@ async function fetchLiveEditorEnumByName(pascalName) {
1332
2297
  const message = JSON.parse(data.toString());
1333
2298
  if (message.type === "types-list") {
1334
2299
  clearTimeout(timeout);
1335
- const types = message.payload?.types || [];
1336
- const match = types.find((t) => t.category === "enum" && toPascalCase(t.name) === pascalName);
1337
- finish(match ? { id: match.id, name: match.name } : null);
2300
+ finish(message.payload?.types || []);
1338
2301
  }
1339
2302
  }
1340
2303
  catch {
@@ -1347,9 +2310,52 @@ async function fetchLiveEditorEnumByName(pascalName) {
1347
2310
  });
1348
2311
  });
1349
2312
  }
2313
+ /**
2314
+ * Definition-time guard for TYPE-prop typeIds, verified against the live
2315
+ * editor's type list (skipped when the dev server is unreachable or the type
2316
+ * is unknown to the editor). Two rules:
2317
+ * - deprecated types are rejected on ANY component: they are legacy
2318
+ * render-only types — old themes may still carry values, but new props must
2319
+ * not be defined with them.
2320
+ * - on section components, only section-allowed (style) types pass — any other
2321
+ * type renders an empty placeholder merchants cannot fill, and section-level
2322
+ * bindings cannot supply a value either, so the prop would be dead weight.
2323
+ * On violation prints the standard { success: false } envelope and exits.
2324
+ */
2325
+ async function assertTypeIdsUsable(componentType, typeIds) {
2326
+ if (typeIds.length === 0)
2327
+ return;
2328
+ const types = await fetchLiveEditorTypes();
2329
+ if (!types)
2330
+ return;
2331
+ const matches = typeIds.flatMap((typeId) => {
2332
+ const match = types.find((t) => t.id === typeId);
2333
+ return match ? [match] : [];
2334
+ });
2335
+ const legacy = matches.find((t) => t.deprecated === true);
2336
+ if (legacy) {
2337
+ console.log(JSON.stringify({
2338
+ success: false,
2339
+ error: `Type "${legacy.id}" is a legacy render-only type — values of it may still exist in old themes, but NEW props must not use it. Pick a current style type instead (run "config list-types" to see them).`,
2340
+ }));
2341
+ process.exit(1);
2342
+ }
2343
+ if (componentType !== "section")
2344
+ return;
2345
+ const disallowed = matches.find((t) => t.sectionAllowed === false);
2346
+ if (!disallowed)
2347
+ return;
2348
+ console.log(JSON.stringify({
2349
+ success: false,
2350
+ error: `Type "${disallowed.id}" is not allowed for TYPE props on section components — merchants cannot fill it in the editor (sections only support style types). Run "config list-types --component-type section" to see allowed types, or use a dedicated prop type (e.g. PRODUCT, CATEGORY) for domain data.`,
2351
+ }));
2352
+ process.exit(1);
2353
+ }
1350
2354
  async function listTypes(componentType) {
1351
2355
  const { WebSocket } = await import("ws");
1352
- const WS_URL = "ws://localhost:5201";
2356
+ // `client=control` keeps this short-lived socket out of the dev server's
2357
+ // `editor-action` target pool — it only reads types, it can't apply commands.
2358
+ const WS_URL = "ws://localhost:5201/?client=control";
1353
2359
  const TIMEOUT_MS = 5000;
1354
2360
  await new Promise((resolve) => {
1355
2361
  let ws;
@@ -1359,7 +2365,7 @@ async function listTypes(componentType) {
1359
2365
  catch {
1360
2366
  console.log(JSON.stringify({
1361
2367
  success: false,
1362
- error: "Failed to connect to dev server. Make sure 'ikas-component dev' is running.",
2368
+ error: "Failed to connect to dev server. Make sure 'ikas theme dev' is running.",
1363
2369
  }));
1364
2370
  process.exit(1);
1365
2371
  return;
@@ -1380,7 +2386,8 @@ async function listTypes(componentType) {
1380
2386
  const message = JSON.parse(data.toString());
1381
2387
  if (message.type === "types-list") {
1382
2388
  clearTimeout(timeout);
1383
- let types = message.payload.types;
2389
+ // Legacy render-only types are never offered for new props.
2390
+ let types = message.payload.types.filter((t) => !t.deprecated);
1384
2391
  if (componentType === "section") {
1385
2392
  types = types.filter((t) => t.sectionAllowed);
1386
2393
  }
@@ -1400,7 +2407,7 @@ async function listTypes(componentType) {
1400
2407
  clearTimeout(timeout);
1401
2408
  console.log(JSON.stringify({
1402
2409
  success: false,
1403
- error: "Failed to connect to dev server. Make sure 'ikas-component dev' is running.",
2410
+ error: "Failed to connect to dev server. Make sure 'ikas theme dev' is running.",
1404
2411
  }));
1405
2412
  process.exit(1);
1406
2413
  });