@utopia-studio-design/design-system-cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +23 -0
  2. package/bin/utopia-ds-mcp.mjs +9 -2
  3. package/bin/utopia-ds.mjs +115 -7
  4. package/data/blocks/activity-feed/ActivityFeedBlock.tsx +255 -0
  5. package/data/blocks/activity-feed/README.md +16 -0
  6. package/data/blocks/activity-feed/activity-feed-contract.ts +96 -0
  7. package/data/blocks/activity-feed/activity-feed-fixture.ts +22 -0
  8. package/data/blocks/activity-feed/activity-feed.css +154 -0
  9. package/data/blocks/agent-status/AgentStatusBlock.tsx +313 -0
  10. package/data/blocks/agent-status/README.md +15 -0
  11. package/data/blocks/agent-status/agent-status-contract.ts +133 -0
  12. package/data/blocks/agent-status/agent-status-fixture.ts +79 -0
  13. package/data/blocks/agent-status/agent-status.css +216 -0
  14. package/data/blocks/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
  15. package/data/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
  16. package/data/blocks/analytics-dashboard/README.md +17 -0
  17. package/data/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
  18. package/data/blocks/calendar-application/CalendarApplicationBlocks.tsx +519 -0
  19. package/data/blocks/calendar-application/README.md +20 -0
  20. package/data/blocks/calendar-application/calendar-application.css +216 -0
  21. package/data/blocks/calendar-application/calendar-localization.ts +90 -0
  22. package/data/blocks/calendar-application/calendar-panel-contract.ts +72 -0
  23. package/data/blocks/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
  24. package/data/blocks/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
  25. package/data/blocks/dashboard-composer/README.md +16 -0
  26. package/data/blocks/dashboard-composer/RegistryDataBlocks.tsx +238 -0
  27. package/data/blocks/dashboard-composer/dashboard-composer.css +328 -0
  28. package/data/blocks/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
  29. package/data/blocks/database-workspace/README.md +9 -0
  30. package/data/blocks/database-workspace/database-workspace.css +107 -0
  31. package/data/blocks/interaction-workbench/InteractionWorkbench.tsx +271 -0
  32. package/data/blocks/interaction-workbench/README.md +9 -0
  33. package/data/blocks/interaction-workbench/interaction-workbench.css +226 -0
  34. package/data/docs/calendar.md +164 -0
  35. package/data/docs/charts.md +95 -0
  36. package/data/docs/composition-registry.md +174 -0
  37. package/data/docs/dashboard-composer.md +94 -0
  38. package/data/docs/database.md +68 -0
  39. package/data/docs/examples/dashboard-layout.v1.json +65 -0
  40. package/data/docs/foundations.md +2 -1
  41. package/data/docs/interaction-foundation.md +109 -0
  42. package/data/docs/observability.md +165 -0
  43. package/data/docs/quick-start-ai.md +55 -1
  44. package/data/manifests/blocks.json +392 -0
  45. package/data/manifests/catalog.json +7 -4
  46. package/data/manifests/components.json +191 -3
  47. package/data/manifests/composition-registry.json +427 -0
  48. package/data/manifests/motion-profiles.json +1 -1
  49. package/data/manifests/patterns.json +113 -1
  50. package/data/manifests/templates.json +346 -0
  51. package/data/manifests/theme-renacore.json +142 -0
  52. package/data/manifests/themes.json +314 -28
  53. package/data/templates/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
  54. package/data/templates/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
  55. package/data/templates/analytics-dashboard/README.md +6 -0
  56. package/data/templates/analytics-dashboard/analytics-dashboard.css +92 -0
  57. package/data/templates/analytics-dashboard/index.html +13 -0
  58. package/data/templates/analytics-dashboard/main.tsx +196 -0
  59. package/data/templates/analytics-dashboard/styles.css +120 -0
  60. package/data/templates/analytics-dashboard/template.manifest.json +17 -0
  61. package/data/templates/calendar-application/CalendarApplicationBlocks.tsx +519 -0
  62. package/data/templates/calendar-application/README.md +10 -0
  63. package/data/templates/calendar-application/calendar-application.css +216 -0
  64. package/data/templates/calendar-application/calendar-localization.ts +90 -0
  65. package/data/templates/calendar-application/index.html +14 -0
  66. package/data/templates/calendar-application/main.tsx +409 -0
  67. package/data/templates/calendar-application/styles.css +248 -0
  68. package/data/templates/calendar-application/template.manifest.json +27 -0
  69. package/data/templates/composition-registry-lab/README.md +9 -0
  70. package/data/templates/composition-registry-lab/index.html +14 -0
  71. package/data/templates/composition-registry-lab/main.tsx +217 -0
  72. package/data/templates/composition-registry-lab/sample-layout.json +63 -0
  73. package/data/templates/composition-registry-lab/styles.css +343 -0
  74. package/data/templates/composition-registry-lab/template.manifest.json +34 -0
  75. package/data/templates/dashboard-composer/ActivityFeedBlock.tsx +255 -0
  76. package/data/templates/dashboard-composer/AgentStatusBlock.tsx +313 -0
  77. package/data/templates/dashboard-composer/AnalyticsDashboardBlocks.tsx +107 -0
  78. package/data/templates/dashboard-composer/AnalyticsMetricBlocks.tsx +87 -0
  79. package/data/templates/dashboard-composer/CalendarApplicationBlocks.tsx +519 -0
  80. package/data/templates/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
  81. package/data/templates/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
  82. package/data/templates/dashboard-composer/DatabaseWorkspaceBlocks.tsx +240 -0
  83. package/data/templates/dashboard-composer/README.md +53 -0
  84. package/data/templates/dashboard-composer/RegistryDataBlocks.tsx +238 -0
  85. package/data/templates/dashboard-composer/activity-feed-contract.ts +96 -0
  86. package/data/templates/dashboard-composer/activity-feed-fixture.ts +22 -0
  87. package/data/templates/dashboard-composer/activity-feed.css +154 -0
  88. package/data/templates/dashboard-composer/agent-status-contract.ts +133 -0
  89. package/data/templates/dashboard-composer/agent-status-fixture.ts +79 -0
  90. package/data/templates/dashboard-composer/agent-status-lab/index.html +19 -0
  91. package/data/templates/dashboard-composer/agent-status-lab/main.tsx +151 -0
  92. package/data/templates/dashboard-composer/agent-status-lab/styles.css +139 -0
  93. package/data/templates/dashboard-composer/agent-status.css +216 -0
  94. package/data/templates/dashboard-composer/analytics-dashboard.css +92 -0
  95. package/data/templates/dashboard-composer/calendar-application.css +216 -0
  96. package/data/templates/dashboard-composer/calendar-localization.ts +90 -0
  97. package/data/templates/dashboard-composer/calendar-panel-contract.ts +72 -0
  98. package/data/templates/dashboard-composer/dashboard-composer.css +328 -0
  99. package/data/templates/dashboard-composer/database-workspace.css +107 -0
  100. package/data/templates/dashboard-composer/index.html +14 -0
  101. package/data/templates/dashboard-composer/main.tsx +550 -0
  102. package/data/templates/dashboard-composer/sample-layout.json +147 -0
  103. package/data/templates/dashboard-composer/styles.css +198 -0
  104. package/data/templates/dashboard-composer/template.manifest.json +34 -0
  105. package/data/templates/dashboard-composer/visual-editor/PuckEditorSurface.tsx +132 -0
  106. package/data/templates/dashboard-composer/visual-editor/VisualEditorApp.tsx +343 -0
  107. package/data/templates/dashboard-composer/visual-editor/index.html +17 -0
  108. package/data/templates/dashboard-composer/visual-editor/main.tsx +95 -0
  109. package/data/templates/dashboard-composer/visual-editor/styles.css +310 -0
  110. package/data/templates/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
  111. package/data/templates/database-workspace/README.md +5 -0
  112. package/data/templates/database-workspace/database-workspace.css +107 -0
  113. package/data/templates/database-workspace/index.html +14 -0
  114. package/data/templates/database-workspace/main.tsx +247 -0
  115. package/data/templates/database-workspace/styles.css +121 -0
  116. package/data/templates/database-workspace/template.manifest.json +19 -0
  117. package/data/templates/interaction-lab/InteractionWorkbench.tsx +271 -0
  118. package/data/templates/interaction-lab/README.md +7 -0
  119. package/data/templates/interaction-lab/index.html +14 -0
  120. package/data/templates/interaction-lab/interaction-workbench.css +226 -0
  121. package/data/templates/interaction-lab/main.tsx +104 -0
  122. package/data/templates/interaction-lab/styles.css +224 -0
  123. package/data/templates/interaction-lab/template.manifest.json +36 -0
  124. package/lib/api.mjs +72 -5
  125. package/lib/composition-runtime.mjs +382 -0
  126. package/lib/telemetry.mjs +74 -0
  127. package/package.json +4 -1
package/lib/api.mjs CHANGED
@@ -1,14 +1,20 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
2
  import { dirname, join, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import {
5
+ serializeLayoutDocument,
6
+ validateAgentLayoutMutation,
7
+ validateLayoutDocument,
8
+ } from './composition-runtime.mjs'
4
9
 
5
10
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
6
11
  const workspaceRoot = resolve(packageRoot, '../..')
7
12
  const packagedDataRoot = join(packageRoot, 'data')
8
13
  const hasWorkspaceSource = existsSync(join(workspaceRoot, 'packages/design-system/src/manifests/components.json'))
14
+ const packageMetadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
9
15
 
10
16
  export const apiVersion = 1
11
- export const cliVersion = '0.4.0'
17
+ export const cliVersion = packageMetadata.version
12
18
  export const mcpLaunch = {
13
19
  command: 'npx',
14
20
  args: ['-y', '--package', '@utopia-studio-design/design-system-cli', 'utopia-ds', 'mcp'],
@@ -52,6 +58,44 @@ export function listTemplates() {
52
58
  return readJson(join(paths().manifests, 'templates.json')).templates
53
59
  }
54
60
 
61
+ export function listBlocks() {
62
+ return readJson(join(paths().manifests, 'blocks.json')).blocks
63
+ }
64
+
65
+ export function getCompositionRegistry() {
66
+ return readJson(join(paths().manifests, 'composition-registry.json'))
67
+ }
68
+
69
+ export function listBlockDefinitions({ renderableOnly = false } = {}) {
70
+ return getCompositionRegistry().definitions.filter(
71
+ (definition) => !renderableOnly || definition.availability === 'available',
72
+ )
73
+ }
74
+
75
+ export function getBlockDefinition(name) {
76
+ const key = normalize(name)
77
+ return listBlockDefinitions().find(
78
+ (item) => normalize(item.id) === key || normalize(item.title) === key,
79
+ ) ?? null
80
+ }
81
+
82
+ export function validateLayout(document) {
83
+ return validateLayoutDocument(getCompositionRegistry(), document)
84
+ }
85
+
86
+ export function validateAgentMutation(current, next) {
87
+ return validateAgentLayoutMutation(getCompositionRegistry(), current, next)
88
+ }
89
+
90
+ export function serializeLayout(document) {
91
+ return serializeLayoutDocument(getCompositionRegistry(), document)
92
+ }
93
+
94
+ export function getBlock(name) {
95
+ const key = normalize(name)
96
+ return listBlocks().find((item) => normalize(item.id) === key || normalize(item.title) === key) ?? null
97
+ }
98
+
55
99
  export function getTemplate(name) {
56
100
  const key = normalize(name)
57
101
  return listTemplates().find((item) => normalize(item.id) === key || normalize(item.title) === key) ?? null
@@ -104,6 +148,8 @@ export function search(query) {
104
148
  const score = (text) => terms.reduce((total, term) => total + (text.toLowerCase().includes(term) ? 1 : 0), 0)
105
149
  const results = [
106
150
  ...listComponents().map((item) => ({ kind: 'component', id: item.name, title: item.name, summary: [...item.useWhen, item.category].join(' '), item })),
151
+ ...listBlockDefinitions().map((item) => ({ kind: 'block-definition', id: item.id, title: item.title, summary: `${item.category} ${item.description} ${item.availability}`, item })),
152
+ ...listBlocks().map((item) => ({ kind: 'block', id: item.id, title: item.title, summary: `${item.category} ${item.purpose}`, item })),
107
153
  ...listTemplates().map((item) => ({ kind: 'template', id: item.id, title: item.title, summary: `${item.category} ${item.purpose}`, item })),
108
154
  ...listThemes().map((item) => ({ kind: 'theme', id: item.id, title: item.name, summary: item.role, item })),
109
155
  ...listMotionProfiles().map((item) => ({ kind: 'motion-profile', id: item.id, title: item.label, summary: `${item.description} ${item.rules.join(' ')}`, item })),
@@ -130,6 +176,9 @@ export function capabilityManifest() {
130
176
  { name: 'init', args: ['[directory]'], flags: ['--yes', '--theme <id>'], responseType: 'init-result' },
131
177
  { name: 'search', args: ['<query>'], flags: [], responseType: 'search-results' },
132
178
  { name: 'component', args: ['<name>|--list'], flags: [], responseType: 'component|component-list' },
179
+ { name: 'registry', args: ['<block-id>|--list'], flags: ['--renderable'], responseType: 'block-definition|block-definition-list' },
180
+ { name: 'layout', args: ['validate <file>', 'serialize <file>'], flags: [], responseType: 'layout-validation-result|layout-serialization-result' },
181
+ { name: 'block', args: ['<id>|--list'], flags: ['--copy <directory>', '--force'], responseType: 'block|block-list|block-copy-result' },
133
182
  { name: 'template', args: ['<id>|--list', 'validate [directory]', 'submit [directory]'], flags: ['--skeleton', '--copy <directory>', '--theme <id>', '--force'], responseType: 'template|template-list|template-copy-result|template-validation-result|template-submit-result' },
134
183
  { name: 'theme', args: ['<id>|--list', 'create <id> [directory]'], flags: ['--force'], responseType: 'theme|theme-list|theme-create-result' },
135
184
  { name: 'motion', args: ['<id>|--list'], flags: [], responseType: 'motion-profile|motion-profile-list' },
@@ -140,16 +189,16 @@ export function capabilityManifest() {
140
189
  ],
141
190
  mcp: {
142
191
  ...mcpLaunch,
143
- tools: ['search', 'list_components', 'get_component', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_motion_profiles', 'get_motion_profile', 'list_docs', 'get_docs', 'doctor'],
192
+ tools: ['search', 'list_components', 'get_component', 'list_block_definitions', 'get_block_definition', 'validate_layout', 'validate_agent_layout_mutation', 'list_blocks', 'get_block', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_motion_profiles', 'get_motion_profile', 'list_docs', 'get_docs', 'doctor'],
144
193
  },
145
194
  }
146
195
  }
147
196
 
148
197
  export function repositoryDoctor() {
149
198
  const required = [
150
- 'catalog.json', 'components.json', 'templates.json', 'themes.json', 'motion-profiles.json',
199
+ 'catalog.json', 'components.json', 'blocks.json', 'composition-registry.json', 'templates.json', 'themes.json', 'motion-profiles.json',
151
200
  ].map((name) => join(paths().manifests, name))
152
- const docs = ['quick-start-ai', 'arabic-friendly', 'theme-authoring'].map((name) => join(paths().docs, `${name}.md`))
201
+ const docs = ['quick-start-ai', 'arabic-friendly', 'theme-authoring', 'composition-registry'].map((name) => join(paths().docs, `${name}.md`))
153
202
  const missing = [...required, ...docs].filter((path) => !existsSync(path))
154
203
  const motionRegistry = missing.includes(join(paths().manifests, 'motion-profiles.json')) ? null : getMotionRegistry()
155
204
  const motionContract = motionRegistry?.profiles?.every((profile) =>
@@ -158,12 +207,30 @@ export function repositoryDoctor() {
158
207
  )
159
208
  ) === true
160
209
  const contractIssues = motionContract ? [] : ['motion-profiles.json: incomplete semantic recipe contract']
210
+ const compositionRegistry = missing.includes(join(paths().manifests, 'composition-registry.json'))
211
+ ? null
212
+ : getCompositionRegistry()
213
+ const compositionContract = compositionRegistry?.version === 1
214
+ && compositionRegistry.layoutSchemaVersion === 1
215
+ && compositionRegistry.definitions?.every((definition) =>
216
+ definition.id
217
+ && definition.version === 1
218
+ && Array.isArray(definition.allowedSpans)
219
+ && definition.propsSchema?.additionalProperties === false
220
+ && Array.isArray(definition.states)
221
+ && definition.agentPermissions
222
+ ) === true
223
+ if (!compositionContract) contractIssues.push('composition-registry.json: incomplete block or layout contract')
161
224
  return {
162
- ok: missing.length === 0 && motionContract,
225
+ ok: missing.length === 0 && motionContract && compositionContract,
163
226
  checks: {
164
227
  manifests: required.every(existsSync),
165
228
  docs: docs.every(existsSync),
166
229
  components: listComponents().length,
230
+ blocks: listBlocks().length,
231
+ blockDefinitions: compositionRegistry?.definitions?.length ?? 0,
232
+ compositionContract,
233
+ layoutSchemaVersion: compositionRegistry?.layoutSchemaVersion ?? null,
167
234
  templates: listTemplates().length,
168
235
  themes: listThemes().length,
169
236
  motionProfiles: listMotionProfiles().length,
@@ -0,0 +1,382 @@
1
+ const forbiddenStyleKeys = new Set([
2
+ 'background',
3
+ 'border',
4
+ 'borderColor',
5
+ 'borderRadius',
6
+ 'borderWidth',
7
+ 'boxShadow',
8
+ 'className',
9
+ 'color',
10
+ 'css',
11
+ 'fill',
12
+ 'gap',
13
+ 'margin',
14
+ 'padding',
15
+ 'radius',
16
+ 'shadow',
17
+ 'spacing',
18
+ 'style',
19
+ 'stroke',
20
+ 'sx',
21
+ 'tokenOverrides',
22
+ 'tokens',
23
+ ])
24
+
25
+ function isRecord(value) {
26
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
27
+ }
28
+
29
+ function addIssue(issues, code, path, message, severity = 'error') {
30
+ issues.push({ code, path, message, severity })
31
+ }
32
+
33
+ function stableValue(value) {
34
+ if (Array.isArray(value)) return value.map(stableValue)
35
+ if (!isRecord(value)) return value
36
+ return Object.fromEntries(
37
+ Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
38
+ )
39
+ }
40
+
41
+ function equalValue(left, right) {
42
+ return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right))
43
+ }
44
+
45
+ function findDefinition(registry, type) {
46
+ return registry.definitions.find((definition) => definition.id === type) ?? null
47
+ }
48
+
49
+ function inspectForbiddenStyles(value, path, issues) {
50
+ if (Array.isArray(value)) {
51
+ value.forEach((entry, index) => inspectForbiddenStyles(entry, `${path}[${index}]`, issues))
52
+ return
53
+ }
54
+ if (!isRecord(value)) return
55
+ for (const [key, entry] of Object.entries(value)) {
56
+ if (forbiddenStyleKeys.has(key)) {
57
+ addIssue(
58
+ issues,
59
+ 'ARBITRARY_STYLE',
60
+ `${path}.${key}`,
61
+ `Arbitrary style key "${key}" is not part of the Ceramic semantic contract.`,
62
+ )
63
+ }
64
+ inspectForbiddenStyles(entry, `${path}.${key}`, issues)
65
+ }
66
+ }
67
+
68
+ function validateProp(rule, value, path, issues) {
69
+ if (rule.type === 'string') {
70
+ if (typeof value !== 'string') {
71
+ addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected a string.')
72
+ return
73
+ }
74
+ if (rule.enum && !rule.enum.includes(value)) {
75
+ addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected one of: ${rule.enum.join(', ')}.`)
76
+ }
77
+ return
78
+ }
79
+ if (rule.type === 'number') {
80
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
81
+ addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected a finite number.')
82
+ return
83
+ }
84
+ if (rule.minimum !== undefined && value < rule.minimum) {
85
+ addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected a value greater than or equal to ${rule.minimum}.`)
86
+ }
87
+ if (rule.maximum !== undefined && value > rule.maximum) {
88
+ addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected a value less than or equal to ${rule.maximum}.`)
89
+ }
90
+ return
91
+ }
92
+ if (rule.type === 'boolean') {
93
+ if (typeof value !== 'boolean') addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected a boolean.')
94
+ return
95
+ }
96
+ if (rule.type === 'array') {
97
+ if (!Array.isArray(value)) {
98
+ addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected an array.')
99
+ return
100
+ }
101
+ if (rule.minItems !== undefined && value.length < rule.minItems) {
102
+ addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected at least ${rule.minItems} item(s).`)
103
+ }
104
+ value.forEach((entry, index) => validateProp(rule.items, entry, `${path}[${index}]`, issues))
105
+ }
106
+ }
107
+
108
+ function validateProps(definition, props, path, issues) {
109
+ if (!isRecord(props)) {
110
+ addIssue(issues, 'INVALID_PROPS', path, 'Block props must be an object.')
111
+ return
112
+ }
113
+ inspectForbiddenStyles(props, path, issues)
114
+ const schema = definition.propsSchema
115
+ for (const required of schema.required ?? []) {
116
+ if (!(required in props)) addIssue(issues, 'MISSING_PROP', `${path}.${required}`, 'Required block prop is missing.')
117
+ }
118
+ for (const [key, value] of Object.entries(props)) {
119
+ const rule = schema.properties[key]
120
+ if (!rule) {
121
+ addIssue(issues, 'UNKNOWN_PROP', `${path}.${key}`, `Prop "${key}" is not registered for ${definition.id}.`)
122
+ continue
123
+ }
124
+ validateProp(rule, value, `${path}.${key}`, issues)
125
+ }
126
+ }
127
+
128
+ function validatePlacement(definition, placement, columns, path, issues) {
129
+ if (!isRecord(placement)) {
130
+ addIssue(issues, 'INVALID_PLACEMENT', path, 'Block placement must be an object.')
131
+ return
132
+ }
133
+ const allowed = new Set(['column', 'span', 'order', 'compactSpan'])
134
+ for (const key of Object.keys(placement)) {
135
+ if (!allowed.has(key)) addIssue(issues, 'UNKNOWN_PLACEMENT_KEY', `${path}.${key}`, `Unknown placement key "${key}".`)
136
+ }
137
+ for (const key of ['column', 'span', 'order']) {
138
+ if (!Number.isInteger(placement[key])) {
139
+ addIssue(issues, 'INVALID_PLACEMENT', `${path}.${key}`, 'Expected an integer.')
140
+ }
141
+ }
142
+ if (!Number.isInteger(placement.column) || !Number.isInteger(placement.span)) return
143
+ if (placement.column < 1 || placement.span < 1 || placement.column + placement.span - 1 > columns) {
144
+ addIssue(issues, 'PLACEMENT_OUT_OF_BOUNDS', path, `Placement must fit inside the ${columns}-column layout.`)
145
+ }
146
+ if (!definition.allowedSpans.includes(placement.span)) {
147
+ addIssue(
148
+ issues,
149
+ 'UNSUPPORTED_SPAN',
150
+ `${path}.span`,
151
+ `${definition.id} supports spans ${definition.allowedSpans.join(', ')}.`,
152
+ )
153
+ }
154
+ if (placement.compactSpan !== undefined) {
155
+ if (!Number.isInteger(placement.compactSpan) || placement.compactSpan < 1 || placement.compactSpan > columns) {
156
+ addIssue(issues, 'INVALID_COMPACT_SPAN', `${path}.compactSpan`, `Compact span must be between 1 and ${columns}.`)
157
+ }
158
+ }
159
+ }
160
+
161
+ function validateRegions(document, blockIds, issues) {
162
+ if (document.regions === undefined) return
163
+ if (!Array.isArray(document.regions)) {
164
+ addIssue(issues, 'INVALID_REGIONS', 'regions', 'Layout regions must be an array.')
165
+ return
166
+ }
167
+ const regionIds = new Set()
168
+ document.regions.forEach((region, index) => {
169
+ const path = `regions[${index}]`
170
+ if (!isRecord(region)) {
171
+ addIssue(issues, 'INVALID_REGION', path, 'Region must be an object.')
172
+ return
173
+ }
174
+ if (typeof region.id !== 'string' || !region.id) addIssue(issues, 'INVALID_REGION_ID', `${path}.id`, 'Region id is required.')
175
+ else if (regionIds.has(region.id)) addIssue(issues, 'DUPLICATE_REGION_ID', `${path}.id`, `Duplicate region id "${region.id}".`)
176
+ else regionIds.add(region.id)
177
+ if (typeof region.locked !== 'boolean') addIssue(issues, 'INVALID_REGION_LOCK', `${path}.locked`, 'Region locked must be a boolean.')
178
+ if (!Array.isArray(region.blockIds)) {
179
+ addIssue(issues, 'INVALID_REGION_BLOCKS', `${path}.blockIds`, 'Region blockIds must be an array.')
180
+ return
181
+ }
182
+ for (const blockId of region.blockIds) {
183
+ if (!blockIds.has(blockId)) {
184
+ addIssue(issues, 'UNKNOWN_REGION_BLOCK', `${path}.blockIds`, `Region references unknown block "${blockId}".`)
185
+ }
186
+ }
187
+ })
188
+ }
189
+
190
+ export function validateLayoutDocument(registry, document) {
191
+ const issues = []
192
+ if (!isRecord(document)) {
193
+ addIssue(issues, 'INVALID_DOCUMENT', '$', 'Ceramic layout must be an object.')
194
+ return { ok: false, issues, document: null }
195
+ }
196
+ const rootKeys = new Set(['schemaVersion', 'id', 'theme', 'columns', 'blocks', 'regions'])
197
+ for (const key of Object.keys(document)) {
198
+ if (!rootKeys.has(key)) addIssue(issues, 'UNKNOWN_DOCUMENT_KEY', key, `Unknown layout key "${key}".`)
199
+ }
200
+ if (document.schemaVersion !== registry.layoutSchemaVersion) {
201
+ addIssue(
202
+ issues,
203
+ 'UNSUPPORTED_SCHEMA_VERSION',
204
+ 'schemaVersion',
205
+ `Supported layout schema version is ${registry.layoutSchemaVersion}.`,
206
+ )
207
+ }
208
+ if (typeof document.id !== 'string' || !document.id) addIssue(issues, 'INVALID_LAYOUT_ID', 'id', 'Layout id is required.')
209
+ if (typeof document.theme !== 'string' || !document.theme) addIssue(issues, 'INVALID_THEME', 'theme', 'A Ceramic theme id is required.')
210
+ if (!Number.isInteger(document.columns) || document.columns < 1 || document.columns > 24) {
211
+ addIssue(issues, 'INVALID_COLUMNS', 'columns', 'Columns must be an integer between 1 and 24.')
212
+ }
213
+ if (!Array.isArray(document.blocks)) {
214
+ addIssue(issues, 'INVALID_BLOCKS', 'blocks', 'Layout blocks must be an array.')
215
+ return { ok: false, issues, document: null }
216
+ }
217
+
218
+ const blockIds = new Set()
219
+ const columns = Number.isInteger(document.columns) ? document.columns : 12
220
+ document.blocks.forEach((block, index) => {
221
+ const path = `blocks[${index}]`
222
+ if (!isRecord(block)) {
223
+ addIssue(issues, 'INVALID_BLOCK', path, 'Block must be an object.')
224
+ return
225
+ }
226
+ const allowed = new Set(['id', 'type', 'version', 'placement', 'props', 'locked'])
227
+ for (const key of Object.keys(block)) {
228
+ if (!allowed.has(key)) addIssue(issues, 'UNKNOWN_BLOCK_KEY', `${path}.${key}`, `Unknown block key "${key}".`)
229
+ }
230
+ if (typeof block.id !== 'string' || !block.id) addIssue(issues, 'INVALID_BLOCK_ID', `${path}.id`, 'Block id is required.')
231
+ else if (blockIds.has(block.id)) addIssue(issues, 'DUPLICATE_BLOCK_ID', `${path}.id`, `Duplicate block id "${block.id}".`)
232
+ else blockIds.add(block.id)
233
+ if (typeof block.type !== 'string') {
234
+ addIssue(issues, 'INVALID_BLOCK_TYPE', `${path}.type`, 'Block type is required.')
235
+ return
236
+ }
237
+ const definition = findDefinition(registry, block.type)
238
+ if (!definition) {
239
+ addIssue(issues, 'UNKNOWN_BLOCK_TYPE', `${path}.type`, `Block type "${block.type}" is not registered.`)
240
+ return
241
+ }
242
+ if (block.version !== definition.version) {
243
+ addIssue(issues, 'UNSUPPORTED_BLOCK_VERSION', `${path}.version`, `${definition.id} requires version ${definition.version}.`)
244
+ }
245
+ if (definition.availability !== 'available') {
246
+ addIssue(
247
+ issues,
248
+ 'BLOCK_NOT_RENDERABLE',
249
+ `${path}.type`,
250
+ `${definition.id} is registered for planning but has no production renderer yet.`,
251
+ 'warning',
252
+ )
253
+ }
254
+ if (block.locked !== undefined && typeof block.locked !== 'boolean') {
255
+ addIssue(issues, 'INVALID_BLOCK_LOCK', `${path}.locked`, 'Block locked must be a boolean.')
256
+ }
257
+ validatePlacement(definition, block.placement, columns, `${path}.placement`, issues)
258
+ validateProps(definition, block.props, `${path}.props`, issues)
259
+ })
260
+ validateRegions(document, blockIds, issues)
261
+ return {
262
+ ok: !issues.some((issue) => issue.severity === 'error'),
263
+ issues,
264
+ document,
265
+ }
266
+ }
267
+
268
+ function lockedBlockIds(document) {
269
+ const ids = new Set(document.blocks.filter((block) => block.locked).map((block) => block.id))
270
+ for (const region of document.regions ?? []) {
271
+ if (region.locked) for (const blockId of region.blockIds) ids.add(blockId)
272
+ }
273
+ return ids
274
+ }
275
+
276
+ export function validateAgentLayoutMutation(registry, current, next) {
277
+ const currentResult = validateLayoutDocument(registry, current)
278
+ const nextResult = validateLayoutDocument(registry, next)
279
+ const issues = [...currentResult.issues, ...nextResult.issues]
280
+ if (!currentResult.ok || !nextResult.ok) return { ok: false, issues }
281
+
282
+ const currentById = new Map(current.blocks.map((block) => [block.id, block]))
283
+ const nextById = new Map(next.blocks.map((block) => [block.id, block]))
284
+ const locked = lockedBlockIds(current)
285
+
286
+ for (const block of current.blocks) {
287
+ const nextBlock = nextById.get(block.id)
288
+ const definition = findDefinition(registry, block.type)
289
+ if (!nextBlock) {
290
+ if (locked.has(block.id)) addIssue(issues, 'BLOCK_LOCKED', `blocks.${block.id}`, 'Locked blocks cannot be removed by an agent.')
291
+ else if (!definition.agentPermissions.remove) addIssue(issues, 'AGENT_REMOVE_DENIED', `blocks.${block.id}`, 'Agent removal is not allowed.')
292
+ continue
293
+ }
294
+ if (locked.has(block.id) && !equalValue(block, nextBlock)) {
295
+ addIssue(issues, 'BLOCK_LOCKED', `blocks.${block.id}`, 'Locked blocks cannot be changed by an agent.')
296
+ continue
297
+ }
298
+ if (block.type !== nextBlock.type || block.version !== nextBlock.version) {
299
+ addIssue(
300
+ issues,
301
+ 'BLOCK_IDENTITY_CHANGED',
302
+ `blocks.${block.id}`,
303
+ 'An existing block id cannot be reassigned to a different type or version.',
304
+ )
305
+ continue
306
+ }
307
+ if (Boolean(block.locked) !== Boolean(nextBlock.locked)) {
308
+ addIssue(
309
+ issues,
310
+ 'AGENT_LOCK_DENIED',
311
+ `blocks.${block.id}.locked`,
312
+ 'Block locks are controlled by the user or Studio, not by an agent.',
313
+ )
314
+ }
315
+ if (!equalValue(block.placement, nextBlock.placement) && !definition.agentPermissions.move) {
316
+ addIssue(issues, 'AGENT_MOVE_DENIED', `blocks.${block.id}.placement`, 'Agent movement is not allowed.')
317
+ }
318
+ const propKeys = new Set([...Object.keys(block.props), ...Object.keys(nextBlock.props)])
319
+ for (const key of propKeys) {
320
+ if (!equalValue(block.props[key], nextBlock.props[key])
321
+ && !definition.agentPermissions.editableProps.includes(key)) {
322
+ addIssue(issues, 'AGENT_PROP_DENIED', `blocks.${block.id}.props.${key}`, `Agent editing is not allowed for prop "${key}".`)
323
+ }
324
+ }
325
+ }
326
+
327
+ for (const block of next.blocks) {
328
+ if (currentById.has(block.id)) continue
329
+ const definition = findDefinition(registry, block.type)
330
+ if (!definition?.agentPermissions.create) {
331
+ addIssue(issues, 'AGENT_CREATE_DENIED', `blocks.${block.id}`, `Agent creation is not allowed for ${block.type}.`)
332
+ }
333
+ if (block.locked) {
334
+ addIssue(
335
+ issues,
336
+ 'AGENT_LOCK_DENIED',
337
+ `blocks.${block.id}.locked`,
338
+ 'An agent cannot create a user-locked block.',
339
+ )
340
+ }
341
+ }
342
+
343
+ for (const region of current.regions ?? []) {
344
+ const nextRegion = (next.regions ?? []).find((candidate) => candidate.id === region.id)
345
+ if (region.locked && !equalValue(region, nextRegion)) {
346
+ addIssue(issues, 'REGION_LOCKED', `regions.${region.id}`, 'Locked regions cannot be changed by an agent.')
347
+ continue
348
+ }
349
+ if (nextRegion && Boolean(region.locked) !== Boolean(nextRegion.locked)) {
350
+ addIssue(
351
+ issues,
352
+ 'AGENT_LOCK_DENIED',
353
+ `regions.${region.id}.locked`,
354
+ 'Region locks are controlled by the user or Studio, not by an agent.',
355
+ )
356
+ }
357
+ }
358
+
359
+ const currentRegionIds = new Set((current.regions ?? []).map((region) => region.id))
360
+ for (const region of next.regions ?? []) {
361
+ if (!currentRegionIds.has(region.id) && region.locked) {
362
+ addIssue(
363
+ issues,
364
+ 'AGENT_LOCK_DENIED',
365
+ `regions.${region.id}.locked`,
366
+ 'An agent cannot create a user-locked region.',
367
+ )
368
+ }
369
+ }
370
+
371
+ return { ok: !issues.some((issue) => issue.severity === 'error'), issues }
372
+ }
373
+
374
+ export function serializeLayoutDocument(registry, document) {
375
+ const result = validateLayoutDocument(registry, document)
376
+ if (!result.ok) {
377
+ const error = new Error('Ceramic layout validation failed.')
378
+ error.issues = result.issues
379
+ throw error
380
+ }
381
+ return `${JSON.stringify(stableValue(document), null, 2)}\n`
382
+ }
@@ -0,0 +1,74 @@
1
+ function safeId(value) {
2
+ return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value)
3
+ ? value
4
+ : undefined
5
+ }
6
+
7
+ function randomId() {
8
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
9
+ }
10
+
11
+ const SAFE_COMMANDS = new Set([
12
+ 'component',
13
+ 'doctor',
14
+ 'docs',
15
+ 'help',
16
+ 'init',
17
+ 'manifest',
18
+ 'motion',
19
+ 'search',
20
+ 'template',
21
+ 'theme',
22
+ ])
23
+
24
+ export function installCliTelemetry({ command, packageVersion, getErrorCode }) {
25
+ const endpoint = process.env.CERAMIC_TELEMETRY_ENDPOINT?.replace(/\/$/, '')
26
+ const ingestKey = process.env.CERAMIC_TELEMETRY_INGEST_KEY
27
+ const appId = safeId(process.env.CERAMIC_TELEMETRY_APP_ID)
28
+ const consented = process.env.CERAMIC_TELEMETRY_MODE === 'consented'
29
+ && process.env.CERAMIC_TELEMETRY_CONSENT === 'granted'
30
+ const enabled = process.env.CERAMIC_TELEMETRY_ENABLED === 'true'
31
+ && endpoint
32
+ && ingestKey
33
+ && appId
34
+ && command !== 'mcp'
35
+ if (!enabled) return
36
+
37
+ let sent = false
38
+ process.on('beforeExit', async () => {
39
+ if (sent) return
40
+ sent = true
41
+ const errorCode = getErrorCode()
42
+ const success = !errorCode && (process.exitCode ?? 0) === 0
43
+ if (!consented && success) return
44
+ const event = {
45
+ schemaVersion: 1,
46
+ id: randomId(),
47
+ occurredAt: new Date().toISOString(),
48
+ name: 'cli_command_completed',
49
+ appId,
50
+ source: 'cli',
51
+ sessionId: randomId(),
52
+ accountId: consented ? safeId(process.env.CERAMIC_TELEMETRY_ACCOUNT_ID) : undefined,
53
+ actorId: consented ? safeId(process.env.CERAMIC_TELEMETRY_ACTOR_ID) : undefined,
54
+ errorCode,
55
+ operation: SAFE_COMMANDS.has(command) ? command : 'unknown',
56
+ packageVersion,
57
+ release: process.env.CERAMIC_RELEASE?.slice(0, 128),
58
+ properties: consented ? { success } : undefined,
59
+ }
60
+ try {
61
+ await fetch(`${endpoint}/v1/events`, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'content-type': 'application/json',
65
+ 'x-ceramic-ingest-key': ingestKey,
66
+ },
67
+ body: JSON.stringify(event),
68
+ signal: AbortSignal.timeout(750),
69
+ })
70
+ } catch {
71
+ // Telemetry is best effort and must never change CLI behavior.
72
+ }
73
+ })
74
+ }
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@utopia-studio-design/design-system-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "AI-readable CLI and MCP server for Ceramic Design System",
5
+ "ceramic": {
6
+ "designSystem": "^0.7.0"
7
+ },
5
8
  "type": "module",
6
9
  "license": "MIT",
7
10
  "bin": {