@astrale-os/cli 1.0.0-beta.22 → 1.0.0-beta.24

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 (33) hide show
  1. package/dist/astrale.js +2759 -625
  2. package/dist/public/connect-core.js +4 -4
  3. package/dist/types/admin/contract.d.ts +2 -2
  4. package/package.json +5 -4
  5. package/src/admin/contract.ts +3 -2
  6. package/src/commands/__tests__/read-commands.test.ts +56 -0
  7. package/src/commands/__tests__/view.test.ts +1 -2
  8. package/src/commands/introspect.ts +8 -9
  9. package/src/ui/.spec/architecture.md +8 -7
  10. package/src/ui/.spec/laws.ts +7 -0
  11. package/src/ui/__tests__/ui.test.ts +357 -5
  12. package/src/ui/lock.ts +1 -1
  13. package/src/ui/operations.ts +89 -19
  14. package/src/ui/project.ts +114 -0
  15. package/src/ui/release.ts +9 -2
  16. package/studio/client/dist/assets/{index-7YHdeCxp.js → index-OeJK1TjG.js} +2 -2
  17. package/studio/client/dist/assets/{schema-studio-CABpTfvH.js → schema-studio-DRdfu_cQ.js} +2 -2
  18. package/studio/client/dist/index.html +1 -1
  19. package/studio/package.json +3 -3
  20. package/studio/server/cli-consumers.test.ts +0 -1
  21. package/studio/server/introspect/anatomy/views.ts +1 -2
  22. package/studio/server/introspect/anatomy-extras.test.ts +3 -14
  23. package/studio/server/introspect/canonical-schema.test.ts +1 -1
  24. package/studio/server/introspect/canonical-schema.ts +0 -4
  25. package/studio/server/introspect/diff.test.ts +10 -2
  26. package/studio/server/introspect/diff.ts +1 -9
  27. package/studio/server/introspect/runtime.test.ts +1 -1
  28. package/studio/server/introspect/schema-ir-json.test.ts +12 -1
  29. package/studio/server/introspect/schema-ir-json.ts +1 -1
  30. package/studio/server/views/target.test.ts +1 -2
  31. package/studio/shared/contracts/schema.ts +1 -3
  32. package/studio/tsconfig.json +2 -1
  33. package/viewer/dist/main.js +13 -13
@@ -28,6 +28,30 @@ const registry: UiRegistry = {
28
28
  },
29
29
  ],
30
30
  }
31
+ const componentRegistry: UiRegistry = {
32
+ name: 'astrale-ui',
33
+ items: [
34
+ {
35
+ name: 'component-sidebar',
36
+ type: 'registry:component',
37
+ title: 'Component · sidebar',
38
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
39
+ files: [
40
+ {
41
+ path: 'sidebar.tsx',
42
+ type: 'registry:component',
43
+ target: 'components/astrale/component/sidebar/sidebar.tsx',
44
+ },
45
+ {
46
+ path: 'use-mobile.ts',
47
+ type: 'registry:hook',
48
+ target: 'components/astrale/component/sidebar/use-mobile.ts',
49
+ },
50
+ ],
51
+ meta: { canonicalAddress: 'component/sidebar', ownership: 'consumer-source' },
52
+ },
53
+ ],
54
+ }
31
55
  const themeCss = `/* Generated from observatory.astrale-theme.json. Consumer-owned after installation. */
32
56
  :root,
33
57
  [data-ui-theme='observatory'] {
@@ -257,15 +281,17 @@ function lock(): UiLock {
257
281
  }
258
282
 
259
283
  describe('UI release and runner contracts', () => {
284
+ /** @evidence TEST-CLI-UI-BETA-DEFAULT */
260
285
  test('resolves the default release from the public beta channel', async () => {
261
286
  const seen: string[] = []
287
+ const fallback = mockFetch()
262
288
  const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
263
289
  const url = String(input)
290
+ seen.push(url)
264
291
  if (url.endsWith('/@astrale-os/ui/beta')) {
265
- seen.push(url)
266
292
  return Response.json({ version: '0.3.0-beta.1' })
267
293
  }
268
- return mockFetch(seen)(input, init)
294
+ return fallback(input, init)
269
295
  }) as typeof fetch
270
296
 
271
297
  const release = await resolveUiRelease(undefined, fetcher)
@@ -322,6 +348,36 @@ describe('UI release and runner contracts', () => {
322
348
  ])
323
349
  })
324
350
 
351
+ test('admits an exact consumer-owned component with item-local dependencies and files', async () => {
352
+ const release = await resolveUiRelease('0.3.0-beta.0', mockFetch([], componentRegistry))
353
+ expect(release.registry.items).toEqual([
354
+ expect.objectContaining({
355
+ name: 'component-sidebar',
356
+ type: 'registry:component',
357
+ dependencies: ['@astrale-os/ui@^0.3.0-beta.0', 'lucide-react@1.32.0'],
358
+ meta: expect.objectContaining({ canonicalAddress: 'component/sidebar' }),
359
+ files: [
360
+ expect.objectContaining({
361
+ type: 'registry:component',
362
+ target: 'components/astrale/component/sidebar/sidebar.tsx',
363
+ }),
364
+ expect.objectContaining({
365
+ type: 'registry:hook',
366
+ target: 'components/astrale/component/sidebar/use-mobile.ts',
367
+ }),
368
+ ],
369
+ }),
370
+ ])
371
+ })
372
+
373
+ test('rejects a component item that targets another registry ownership category', async () => {
374
+ const invalid = structuredClone(componentRegistry)
375
+ invalid.items[0]!.files[0]!.target = 'components/astrale/pattern/sidebar/sidebar.tsx'
376
+ await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], invalid))).rejects.toMatchObject({
377
+ code: 'UI_REGISTRY_UNAVAILABLE',
378
+ })
379
+ })
380
+
325
381
  test('lists release themes through the public type filter', async () => {
326
382
  const themes = await listUi(
327
383
  undefined,
@@ -795,8 +851,60 @@ describe('UI initialization transaction', () => {
795
851
  )
796
852
  })
797
853
 
854
+ test('preserves an existing development-only UI dependency during initialization', async () => {
855
+ const root = await fixture()
856
+ const manifestPath = path.join(root, 'package.json')
857
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
858
+ manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
859
+ await writeFile(manifestPath, JSON.stringify(manifest))
860
+
861
+ await initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() })
862
+
863
+ const written = JSON.parse(await readFile(manifestPath, 'utf8'))
864
+ expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
865
+ expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
866
+ })
867
+
868
+ test('doctor accepts one exact development dependency and rejects drift or duplication', async () => {
869
+ const root = await lockedFixture()
870
+ const manifestPath = path.join(root, 'package.json')
871
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
872
+ delete manifest.dependencies['@astrale-os/ui']
873
+ manifest.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
874
+ await writeFile(manifestPath, JSON.stringify(manifest))
875
+ await writeFile(
876
+ path.join(root, 'components.json'),
877
+ JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
878
+ )
879
+ await writeFile(
880
+ path.join(root, 'src/index.css'),
881
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
882
+ )
883
+
884
+ expect((await doctorUi(root)).healthy).toBe(true)
885
+
886
+ manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.1'
887
+ await writeFile(manifestPath, JSON.stringify(manifest))
888
+ expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
889
+ ok: false,
890
+ detail: 'devDependencies:0.3.0-beta.1',
891
+ })
892
+
893
+ manifest.devDependencies['@astrale-os/ui'] = '0.3.0-beta.0'
894
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
895
+ await writeFile(manifestPath, JSON.stringify(manifest))
896
+ expect((await doctorUi(root)).checks.find(({ check }) => check === 'package')).toMatchObject({
897
+ ok: false,
898
+ detail: 'declared in dependencies and devDependencies',
899
+ })
900
+ })
901
+
798
902
  test('repeated exact init performs no release fetch while requested drift rejects', async () => {
799
903
  const root = await fixture()
904
+ const manifestPath = path.join(root, 'package.json')
905
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
906
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
907
+ await writeFile(manifestPath, JSON.stringify(manifest))
800
908
  await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
801
909
  await writeFile(
802
910
  path.join(root, 'components.json'),
@@ -821,6 +929,11 @@ describe('UI initialization transaction', () => {
821
929
  await expect(initUi({ path: root, preset: 'compact' })).rejects.toMatchObject({
822
930
  code: 'UI_ITEM_CONFLICT',
823
931
  })
932
+ manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.1'
933
+ await writeFile(manifestPath, JSON.stringify(manifest))
934
+ await expect(initUi({ path: root, preset: 'astrale' })).rejects.toMatchObject({
935
+ code: 'UI_ITEM_CONFLICT',
936
+ })
824
937
  })
825
938
  })
826
939
 
@@ -1096,6 +1209,201 @@ describe('UI source operations', () => {
1096
1209
  ).rejects.toBeInstanceOf(UiError)
1097
1210
  })
1098
1211
 
1212
+ test('records multi-file component targets resolved through the consumer components alias', async () => {
1213
+ const root = await lockedFixture()
1214
+ await writeFile(
1215
+ path.join(root, 'components.json'),
1216
+ JSON.stringify({
1217
+ style: 'base-nova',
1218
+ tailwind: { css: 'src/index.css' },
1219
+ aliases: { components: '@/components' },
1220
+ }),
1221
+ )
1222
+ await writeFile(
1223
+ path.join(root, 'tsconfig.json'),
1224
+ JSON.stringify({
1225
+ compilerOptions: {
1226
+ paths: { '@/*': ['./src/*'], '@/components/*': ['./app/ui/*'] },
1227
+ },
1228
+ }),
1229
+ )
1230
+ await writeFile(
1231
+ path.join(root, 'src/index.css'),
1232
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
1233
+ )
1234
+ const sidebar = path.join(root, 'src/components/astrale/component/sidebar/sidebar.tsx')
1235
+ const hook = path.join(root, 'src/components/astrale/component/sidebar/use-mobile.ts')
1236
+
1237
+ const planned = await addUi(
1238
+ ['component/sidebar'],
1239
+ { project: root, dryRun: true, yes: true },
1240
+ {
1241
+ fetcher: mockFetch([], componentRegistry),
1242
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1243
+ },
1244
+ )
1245
+ expect(planned.sources).toEqual([
1246
+ expect.objectContaining({
1247
+ files: [
1248
+ 'src/components/astrale/component/sidebar/sidebar.tsx',
1249
+ 'src/components/astrale/component/sidebar/use-mobile.ts',
1250
+ ],
1251
+ }),
1252
+ ])
1253
+
1254
+ await addUi(
1255
+ ['component/sidebar'],
1256
+ { project: root, yes: true },
1257
+ {
1258
+ fetcher: mockFetch([], componentRegistry),
1259
+ runner: async () => {
1260
+ await mkdir(path.dirname(sidebar), { recursive: true })
1261
+ await writeFile(sidebar, 'export const Sidebar = true\n')
1262
+ await writeFile(hook, 'export const useMobile = true\n')
1263
+ return { code: 0, stdout: '', stderr: '' }
1264
+ },
1265
+ },
1266
+ )
1267
+
1268
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
1269
+ expect(written.items['component/sidebar'].files).toEqual({
1270
+ 'src/components/astrale/component/sidebar/sidebar.tsx': digest(
1271
+ 'export const Sidebar = true\n',
1272
+ ),
1273
+ 'src/components/astrale/component/sidebar/use-mobile.ts': digest(
1274
+ 'export const useMobile = true\n',
1275
+ ),
1276
+ })
1277
+ expect((await doctorUi(root)).healthy).toBe(true)
1278
+ })
1279
+
1280
+ test('resolves a components alias through package imports before tsconfig paths', async () => {
1281
+ const root = await lockedFixture()
1282
+ const manifestPath = path.join(root, 'package.json')
1283
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
1284
+ manifest.imports = { '#app/*': './src/app/*' }
1285
+ await writeFile(manifestPath, JSON.stringify(manifest))
1286
+ await writeFile(
1287
+ path.join(root, 'components.json'),
1288
+ JSON.stringify({
1289
+ style: 'base-nova',
1290
+ tailwind: { css: 'src/index.css' },
1291
+ aliases: { components: '#app/components' },
1292
+ }),
1293
+ )
1294
+
1295
+ const planned = await addUi(
1296
+ ['pattern/chart/line/basic'],
1297
+ { project: root, dryRun: true, yes: true },
1298
+ {
1299
+ fetcher: mockFetch(),
1300
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1301
+ },
1302
+ )
1303
+
1304
+ expect(planned.sources).toEqual([
1305
+ expect.objectContaining({
1306
+ files: ['src/app/components/astrale/pattern/chart/line-basic.tsx'],
1307
+ }),
1308
+ ])
1309
+ })
1310
+
1311
+ test('rejects an unresolved package-import alias before invoking shadcn', async () => {
1312
+ const root = await lockedFixture()
1313
+ await writeFile(
1314
+ path.join(root, 'components.json'),
1315
+ JSON.stringify({
1316
+ style: 'base-nova',
1317
+ tailwind: { css: 'src/index.css' },
1318
+ aliases: { components: '#missing/components' },
1319
+ }),
1320
+ )
1321
+ let invoked = false
1322
+
1323
+ await expect(
1324
+ addUi(
1325
+ ['pattern/chart/line/basic'],
1326
+ { project: root, yes: true },
1327
+ {
1328
+ fetcher: mockFetch(),
1329
+ runner: async () => {
1330
+ invoked = true
1331
+ return { code: 0, stdout: '', stderr: '' }
1332
+ },
1333
+ },
1334
+ ),
1335
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
1336
+ expect(invoked).toBe(false)
1337
+ })
1338
+
1339
+ test('resolves an extended JSONC baseUrl with the pinned shadcn config loader', async () => {
1340
+ const root = await lockedFixture()
1341
+ await writeFile(
1342
+ path.join(root, 'components.json'),
1343
+ JSON.stringify({
1344
+ style: 'base-nova',
1345
+ tailwind: { css: 'src/index.css' },
1346
+ aliases: { components: '@/components' },
1347
+ }),
1348
+ )
1349
+ await mkdir(path.join(root, 'config'), { recursive: true })
1350
+ await writeFile(
1351
+ path.join(root, 'tsconfig.json'),
1352
+ '{\n // shadcn loads this root config.\n "extends": "./config/base.json",\n}\n',
1353
+ )
1354
+ await writeFile(
1355
+ path.join(root, 'config/base.json'),
1356
+ '{\n "compilerOptions": {\n "baseUrl": "..",\n "paths": { "@/*": ["frontend/src/*"], },\n },\n}\n',
1357
+ )
1358
+
1359
+ const planned = await addUi(
1360
+ ['pattern/chart/line/basic'],
1361
+ { project: root, dryRun: true, yes: true },
1362
+ {
1363
+ fetcher: mockFetch(),
1364
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1365
+ },
1366
+ )
1367
+
1368
+ expect(planned.sources).toEqual([
1369
+ expect.objectContaining({
1370
+ files: ['frontend/src/components/astrale/pattern/chart/line-basic.tsx'],
1371
+ }),
1372
+ ])
1373
+ })
1374
+
1375
+ test('rejects an alias mapping outside the project before invoking shadcn', async () => {
1376
+ const root = await lockedFixture()
1377
+ await writeFile(
1378
+ path.join(root, 'components.json'),
1379
+ JSON.stringify({
1380
+ style: 'base-nova',
1381
+ tailwind: { css: 'src/index.css' },
1382
+ aliases: { components: '@/components' },
1383
+ }),
1384
+ )
1385
+ await writeFile(
1386
+ path.join(root, 'tsconfig.json'),
1387
+ JSON.stringify({ compilerOptions: { paths: { '@/*': ['../outside/*'] } } }),
1388
+ )
1389
+ let invoked = false
1390
+
1391
+ await expect(
1392
+ addUi(
1393
+ ['pattern/chart/line/basic'],
1394
+ { project: root, yes: true },
1395
+ {
1396
+ fetcher: mockFetch(),
1397
+ runner: async () => {
1398
+ invoked = true
1399
+ return { code: 0, stdout: '', stderr: '' }
1400
+ },
1401
+ },
1402
+ ),
1403
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
1404
+ expect(invoked).toBe(false)
1405
+ })
1406
+
1099
1407
  test('restores the exact locked UI dependency after shadcn applies its compatible range', async () => {
1100
1408
  const root = await lockedFixture()
1101
1409
  const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
@@ -1132,6 +1440,38 @@ describe('UI source operations', () => {
1132
1440
  ).toBe('0.3.0-beta.0')
1133
1441
  })
1134
1442
 
1443
+ test('restores a development-only UI dependency without retaining shadcn duplication', async () => {
1444
+ const root = await lockedFixture()
1445
+ const manifestPath = path.join(root, 'package.json')
1446
+ const initial = JSON.parse(await readFile(manifestPath, 'utf8'))
1447
+ delete initial.dependencies['@astrale-os/ui']
1448
+ initial.devDependencies = { '@astrale-os/ui': '0.3.0-beta.0' }
1449
+ await writeFile(manifestPath, JSON.stringify(initial))
1450
+ const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1451
+
1452
+ await addUi(
1453
+ ['pattern/chart/line/basic'],
1454
+ { project: root, yes: true },
1455
+ {
1456
+ fetcher: mockFetch(),
1457
+ runner: async (_file, args) => {
1458
+ if (args[0] === 'dlx') {
1459
+ await mkdir(path.dirname(installed), { recursive: true })
1460
+ await writeFile(installed, 'export const Chart = true\n')
1461
+ const changed = JSON.parse(await readFile(manifestPath, 'utf8'))
1462
+ changed.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
1463
+ await writeFile(manifestPath, JSON.stringify(changed))
1464
+ }
1465
+ return { code: 0, stdout: '', stderr: '' }
1466
+ },
1467
+ },
1468
+ )
1469
+
1470
+ const written = JSON.parse(await readFile(manifestPath, 'utf8'))
1471
+ expect(written.dependencies['@astrale-os/ui']).toBeUndefined()
1472
+ expect(written.devDependencies['@astrale-os/ui']).toBe('0.3.0-beta.0')
1473
+ })
1474
+
1135
1475
  test('rolls back item and package state when restoring the locked dependency fails', async () => {
1136
1476
  const root = await lockedFixture()
1137
1477
  const manifestPath = path.join(root, 'package.json')
@@ -1190,10 +1530,22 @@ describe('UI source operations', () => {
1190
1530
  expect(invoked).toBe(false)
1191
1531
  })
1192
1532
 
1193
- test('restores declared files and package state after a partial shadcn failure', async () => {
1533
+ test('restores alias-resolved files and package state after a partial shadcn failure', async () => {
1194
1534
  const root = await lockedFixture()
1195
- const first = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1196
- const second = path.join(root, 'components/astrale/pattern/chart/summary.tsx')
1535
+ await writeFile(
1536
+ path.join(root, 'components.json'),
1537
+ JSON.stringify({
1538
+ style: 'base-nova',
1539
+ tailwind: { css: 'src/index.css' },
1540
+ aliases: { components: '@/components' },
1541
+ }),
1542
+ )
1543
+ await writeFile(
1544
+ path.join(root, 'tsconfig.json'),
1545
+ JSON.stringify({ compilerOptions: { paths: { '@/*': ['./src/*'] } } }),
1546
+ )
1547
+ const first = path.join(root, 'src/components/astrale/pattern/chart/line-basic.tsx')
1548
+ const second = path.join(root, 'src/components/astrale/pattern/chart/summary.tsx')
1197
1549
  await mkdir(path.dirname(first), { recursive: true })
1198
1550
  await writeFile(first, 'consumer original\n')
1199
1551
  const twoFileRegistry: UiRegistry = {
package/src/ui/lock.ts CHANGED
@@ -32,7 +32,7 @@ export function parseUiLock(value: unknown): UiLock {
32
32
  }
33
33
  for (const [address, item] of Object.entries(lock.items)) {
34
34
  if (
35
- !/^(?:(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+|theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/u.test(
35
+ !/^(?:component\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*|(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+|theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/u.test(
36
36
  address,
37
37
  ) ||
38
38
  !isRecord(item) ||
@@ -17,6 +17,7 @@ import {
17
17
  assertSupportedUiProject,
18
18
  discoverUiProject,
19
19
  projectRelative,
20
+ resolveUiRegistryTarget,
20
21
  type UiProject,
21
22
  } from './project'
22
23
  import {
@@ -134,8 +135,45 @@ function activateTheme(current: string, statement: string): string {
134
135
  .replace(/^\n/u, '')
135
136
  }
136
137
 
137
- function manifestDependencies(manifest: Record<string, unknown>): Record<string, string> {
138
- return { ...(manifest.dependencies as Record<string, string> | undefined) }
138
+ type ManifestDependencySection = 'dependencies' | 'devDependencies'
139
+
140
+ function manifestDependencies(
141
+ manifest: Record<string, unknown>,
142
+ section: ManifestDependencySection = 'dependencies',
143
+ ): Record<string, string> {
144
+ return { ...(manifest[section] as Record<string, string> | undefined) }
145
+ }
146
+
147
+ function uiDependencySection(
148
+ manifest: Record<string, unknown>,
149
+ fallback: ManifestDependencySection = 'dependencies',
150
+ ): ManifestDependencySection {
151
+ const runtime = Object.hasOwn(manifestDependencies(manifest), UI_PACKAGE)
152
+ const development = Object.hasOwn(manifestDependencies(manifest, 'devDependencies'), UI_PACKAGE)
153
+ if (runtime && development) {
154
+ throw new UiError(
155
+ 'UI_PROJECT_UNSUPPORTED',
156
+ UI_PACKAGE + ' must be declared in exactly one dependency section.',
157
+ 'Remove it from either dependencies or devDependencies, then retry.',
158
+ )
159
+ }
160
+ if (development) return 'devDependencies'
161
+ if (runtime) return 'dependencies'
162
+ return fallback
163
+ }
164
+
165
+ function setUiDependency(
166
+ manifest: Record<string, unknown>,
167
+ section: ManifestDependencySection,
168
+ version: string,
169
+ ): void {
170
+ const other = section === 'dependencies' ? 'devDependencies' : 'dependencies'
171
+ manifest[section] = { ...manifestDependencies(manifest, section), [UI_PACKAGE]: version }
172
+ const otherDependencies = manifestDependencies(manifest, other)
173
+ if (Object.hasOwn(otherDependencies, UI_PACKAGE)) {
174
+ delete otherDependencies[UI_PACKAGE]
175
+ manifest[other] = otherDependencies
176
+ }
139
177
  }
140
178
 
141
179
  function domainRegistryPackagePath(project: UiProject): string {
@@ -294,6 +332,10 @@ export async function initUi(
294
332
  }
295
333
  if ((await exists(project.uiLockPath)) && !options.force) {
296
334
  const lock = await readUiLock(project.uiLockPath)
335
+ const dependencySection = uiDependencySection(project.packageJson)
336
+ const dependencyVersion = manifestDependencies(project.packageJson, dependencySection)[
337
+ UI_PACKAGE
338
+ ]
297
339
  const css = (await readOptional(project.cssPath)) ?? ''
298
340
  const components = await readOptional(project.componentsPath)
299
341
  .then((value) => (value ? (JSON.parse(value) as Record<string, unknown>) : undefined))
@@ -301,6 +343,7 @@ export async function initUi(
301
343
  const requestedVersion = options.version?.replace(/^v/u, '')
302
344
  const desired =
303
345
  (!requestedVersion || requestedVersion === lock.package.version) &&
346
+ dependencyVersion === lock.package.version &&
304
347
  (!options.preset || options.preset === lock.preset) &&
305
348
  css.includes(UI_PACKAGE + '/theme.css') &&
306
349
  css.includes(UI_PACKAGE + '/presets/' + lock.preset + '.css') &&
@@ -362,10 +405,7 @@ export async function initUi(
362
405
 
363
406
  try {
364
407
  const manifest = { ...project.packageJson }
365
- manifest.dependencies = {
366
- ...manifestDependencies(manifest),
367
- [UI_PACKAGE]: release.version,
368
- }
408
+ setUiDependency(manifest, uiDependencySection(manifest), release.version)
369
409
  await writeDomainRegistryWorkspace(project, manifest)
370
410
  await writeJson(project.packageJsonPath, manifest)
371
411
 
@@ -461,14 +501,17 @@ export async function initUi(
461
501
  async function pinUiDependency(
462
502
  project: UiProject,
463
503
  version: string,
504
+ section: ManifestDependencySection,
464
505
  runner: UiRunner,
465
506
  ): Promise<void> {
466
507
  const manifest = JSON.parse(await readFile(project.packageJsonPath, 'utf8')) as Record<
467
508
  string,
468
509
  unknown
469
510
  >
470
- if (manifestDependencies(manifest)[UI_PACKAGE] === version) return
471
- manifest.dependencies = { ...manifestDependencies(manifest), [UI_PACKAGE]: version }
511
+ const current = manifestDependencies(manifest, section)[UI_PACKAGE]
512
+ const other = section === 'dependencies' ? 'devDependencies' : 'dependencies'
513
+ if (current === version && manifestDependencies(manifest, other)[UI_PACKAGE] === undefined) return
514
+ setUiDependency(manifest, section, version)
472
515
  await writeJson(project.packageJsonPath, manifest)
473
516
  const result = await runner(project.manager, ['install'], project.root)
474
517
  if (result.code !== 0) {
@@ -663,6 +706,7 @@ export async function addUi(
663
706
  return addLocalTheme(localThemes[0]!, project, options)
664
707
  }
665
708
  const { lock, release } = await lockedRelease(project, dependencies.fetcher)
709
+ const dependencySection = uiDependencySection(project.packageJson)
666
710
  const items = addresses.map((address) => findItem(release, address))
667
711
  const themes = items.filter((item) => item.type === 'registry:theme')
668
712
  if (themes.length > 1) {
@@ -681,13 +725,19 @@ export async function addUi(
681
725
  return addReleasedTheme(themes[0]!, itemDocuments[0]!, project, options)
682
726
  }
683
727
  await rejectLocalChanges(project, lock, items, options.overwrite === true)
684
- const targets = (
728
+ const declaredTargets = items.flatMap((item) =>
729
+ item.files.flatMap((file) => (file.target ? [file.target] : [])),
730
+ )
731
+ const resolvedTargets = new Map(
685
732
  await Promise.all(
686
- items.flatMap((item) =>
687
- item.files
688
- .filter((file) => file.target)
689
- .map((file) => assertSafePlannedTarget(project, file.target!)),
733
+ declaredTargets.map(
734
+ async (target) => [target, await resolveUiRegistryTarget(project, target)] as const,
690
735
  ),
736
+ ),
737
+ )
738
+ const targets = (
739
+ await Promise.all(
740
+ [...resolvedTargets.values()].map((target) => assertSafePlannedTarget(project, target)),
691
741
  )
692
742
  ).filter((target, index, all) => all.indexOf(target) === index)
693
743
  const invocation = shadcnInvocation(project.manager, release.compatibility.shadcn, [
@@ -731,7 +781,12 @@ export async function addUi(
731
781
  throw new UiError('UI_TOOL_FAILED', 'The pinned shadcn operation omitted an item file.')
732
782
  }
733
783
  }
734
- await pinUiDependency(project, lock.package.version, dependencies.runner ?? defaultUiRunner)
784
+ await pinUiDependency(
785
+ project,
786
+ lock.package.version,
787
+ dependencySection,
788
+ dependencies.runner ?? defaultUiRunner,
789
+ )
735
790
  const theme = themes[0]
736
791
  const themeTarget = theme?.files[0]?.target
737
792
  if (themeTarget) {
@@ -754,7 +809,9 @@ export async function addUi(
754
809
  sources: itemDocuments.map((item) => ({
755
810
  address: item.meta.canonicalAddress,
756
811
  dependencies: item.dependencies ?? [],
757
- files: item.files.map((file) => file.target).filter(Boolean),
812
+ files: item.files
813
+ .map((file) => (file.target ? resolvedTargets.get(file.target) : undefined))
814
+ .filter(Boolean),
758
815
  })),
759
816
  command: [invocation.file, ...invocation.args],
760
817
  output: result.stdout,
@@ -776,7 +833,7 @@ export async function addUi(
776
833
  const files: Record<string, string> = {}
777
834
  for (const file of item.files) {
778
835
  if (!file.target) continue
779
- const target = await safeTarget(project, file.target)
836
+ const target = await safeTarget(project, resolvedTargets.get(file.target)!)
780
837
  files[projectRelative(project, target)] = digest(await readFile(target))
781
838
  }
782
839
  lock.items[item.meta.canonicalAddress] = {
@@ -821,11 +878,24 @@ export async function doctorUi(
821
878
  detail: error instanceof Error ? error.message : String(error),
822
879
  })
823
880
  }
824
- const dependencies = manifestDependencies(project.packageJson)
881
+ const runtimeVersion = manifestDependencies(project.packageJson)[UI_PACKAGE]
882
+ const developmentVersion = manifestDependencies(project.packageJson, 'devDependencies')[
883
+ UI_PACKAGE
884
+ ]
885
+ const declarations = [runtimeVersion, developmentVersion].filter(
886
+ (version): version is string => typeof version === 'string',
887
+ )
888
+ const packageVersion = declarations.length === 1 ? declarations[0] : undefined
825
889
  checks.push({
826
890
  check: 'package',
827
- ok: typeof dependencies[UI_PACKAGE] === 'string',
828
- detail: dependencies[UI_PACKAGE],
891
+ ok: declarations.length === 1 && (!lock || packageVersion === lock.package.version),
892
+ detail:
893
+ declarations.length > 1
894
+ ? 'declared in dependencies and devDependencies'
895
+ : packageVersion === undefined
896
+ ? 'missing'
897
+ : (runtimeVersion === packageVersion ? 'dependencies:' : 'devDependencies:') +
898
+ packageVersion,
829
899
  })
830
900
  const css = (await readOptional(project.cssPath)) ?? ''
831
901
  checks.push({ check: 'theme', ok: css.includes(UI_PACKAGE + '/theme.css') })