@astrale-os/cli 1.0.0-beta.23 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.23",
3
+ "version": "1.0.0-beta.24",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -57,6 +57,7 @@
57
57
  "jose": "^6.1.3",
58
58
  "ora": "^9.0.0",
59
59
  "ts-morph": "^25.0.1",
60
+ "tsconfig-paths": "4.2.0",
60
61
  "yaml": "^2.8.2",
61
62
  "zod": "^4.4.3"
62
63
  },
@@ -1209,6 +1209,201 @@ describe('UI source operations', () => {
1209
1209
  ).rejects.toBeInstanceOf(UiError)
1210
1210
  })
1211
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
+
1212
1407
  test('restores the exact locked UI dependency after shadcn applies its compatible range', async () => {
1213
1408
  const root = await lockedFixture()
1214
1409
  const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
@@ -1335,10 +1530,22 @@ describe('UI source operations', () => {
1335
1530
  expect(invoked).toBe(false)
1336
1531
  })
1337
1532
 
1338
- 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 () => {
1339
1534
  const root = await lockedFixture()
1340
- const first = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
1341
- 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')
1342
1549
  await mkdir(path.dirname(first), { recursive: true })
1343
1550
  await writeFile(first, 'consumer original\n')
1344
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 {
@@ -724,13 +725,19 @@ export async function addUi(
724
725
  return addReleasedTheme(themes[0]!, itemDocuments[0]!, project, options)
725
726
  }
726
727
  await rejectLocalChanges(project, lock, items, options.overwrite === true)
727
- const targets = (
728
+ const declaredTargets = items.flatMap((item) =>
729
+ item.files.flatMap((file) => (file.target ? [file.target] : [])),
730
+ )
731
+ const resolvedTargets = new Map(
728
732
  await Promise.all(
729
- items.flatMap((item) =>
730
- item.files
731
- .filter((file) => file.target)
732
- .map((file) => assertSafePlannedTarget(project, file.target!)),
733
+ declaredTargets.map(
734
+ async (target) => [target, await resolveUiRegistryTarget(project, target)] as const,
733
735
  ),
736
+ ),
737
+ )
738
+ const targets = (
739
+ await Promise.all(
740
+ [...resolvedTargets.values()].map((target) => assertSafePlannedTarget(project, target)),
734
741
  )
735
742
  ).filter((target, index, all) => all.indexOf(target) === index)
736
743
  const invocation = shadcnInvocation(project.manager, release.compatibility.shadcn, [
@@ -802,7 +809,9 @@ export async function addUi(
802
809
  sources: itemDocuments.map((item) => ({
803
810
  address: item.meta.canonicalAddress,
804
811
  dependencies: item.dependencies ?? [],
805
- 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),
806
815
  })),
807
816
  command: [invocation.file, ...invocation.args],
808
817
  output: result.stdout,
@@ -824,7 +833,7 @@ export async function addUi(
824
833
  const files: Record<string, string> = {}
825
834
  for (const file of item.files) {
826
835
  if (!file.target) continue
827
- const target = await safeTarget(project, file.target)
836
+ const target = await safeTarget(project, resolvedTargets.get(file.target)!)
828
837
  files[projectRelative(project, target)] = digest(await readFile(target))
829
838
  }
830
839
  lock.items[item.meta.canonicalAddress] = {
package/src/ui/project.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { access, lstat, readFile, realpath } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
+ import { loadConfig } from 'tsconfig-paths'
3
4
 
4
5
  import { UiError, type PackageManager } from './model'
5
6
 
@@ -40,6 +41,119 @@ async function readManifest(target: string): Promise<Record<string, unknown>> {
40
41
  }
41
42
  }
42
43
 
44
+ function pathPatternMatch(pattern: string, candidate: string): string | undefined {
45
+ const wildcard = pattern.indexOf('*')
46
+ if (wildcard === -1) return pattern === candidate ? '' : undefined
47
+ const prefix = pattern.slice(0, wildcard)
48
+ const suffix = pattern.slice(wildcard + 1)
49
+ if (!candidate.startsWith(prefix) || !candidate.endsWith(suffix)) return undefined
50
+ return candidate.slice(prefix.length, candidate.length - suffix.length)
51
+ }
52
+
53
+ function resolvePackageImport(project: UiProject, candidate: string): string | undefined {
54
+ const imports = project.packageJson.imports
55
+ if (!imports || typeof imports !== 'object' || Array.isArray(imports)) return undefined
56
+ const matches = Object.entries(imports as Record<string, unknown>)
57
+ .flatMap(([pattern, target]) => {
58
+ const wildcard = pathPatternMatch(pattern, candidate)
59
+ if (wildcard === undefined || typeof target !== 'string' || !target.startsWith('./'))
60
+ return []
61
+ const wildcardIndex = pattern.indexOf('*')
62
+ return [
63
+ {
64
+ exact: wildcardIndex === -1,
65
+ prefixLength: wildcardIndex === -1 ? pattern.length : wildcardIndex,
66
+ suffixLength: wildcardIndex === -1 ? 0 : pattern.length - wildcardIndex - 1,
67
+ target: target.replaceAll('*', wildcard),
68
+ },
69
+ ]
70
+ })
71
+ .sort(
72
+ (left, right) =>
73
+ Number(right.exact) - Number(left.exact) ||
74
+ right.prefixLength - left.prefixLength ||
75
+ right.suffixLength - left.suffixLength,
76
+ )
77
+ return matches[0] ? path.resolve(project.root, matches[0].target) : undefined
78
+ }
79
+
80
+ async function resolveAlias(project: UiProject, candidate: string): Promise<string | undefined> {
81
+ const config = loadConfig(project.root)
82
+ if (config.resultType === 'failed') return undefined
83
+ const matches: Array<{
84
+ exact: boolean
85
+ prefixLength: number
86
+ suffixLength: number
87
+ resolved: string
88
+ }> = []
89
+ for (const [pattern, replacements] of Object.entries(config.paths)) {
90
+ const wildcard = pathPatternMatch(pattern, candidate)
91
+ if (wildcard === undefined) continue
92
+ const replacement = replacements?.[0]
93
+ if (!replacement) continue
94
+ const wildcardIndex = pattern.indexOf('*')
95
+ matches.push({
96
+ exact: wildcardIndex === -1,
97
+ prefixLength: wildcardIndex === -1 ? pattern.length : wildcardIndex,
98
+ suffixLength: wildcardIndex === -1 ? 0 : pattern.length - wildcardIndex - 1,
99
+ resolved: path.resolve(config.absoluteBaseUrl, replacement.replaceAll('*', wildcard)),
100
+ })
101
+ }
102
+ matches.sort(
103
+ (left, right) =>
104
+ Number(right.exact) - Number(left.exact) ||
105
+ right.prefixLength - left.prefixLength ||
106
+ right.suffixLength - left.suffixLength,
107
+ )
108
+ const best = matches[0]
109
+ if (!best) return undefined
110
+ const ambiguous = matches.some(
111
+ (match) =>
112
+ match.exact === best.exact &&
113
+ match.prefixLength === best.prefixLength &&
114
+ match.suffixLength === best.suffixLength &&
115
+ match.resolved !== best.resolved,
116
+ )
117
+ if (ambiguous) {
118
+ throw new UiError(
119
+ 'UI_PROJECT_UNSUPPORTED',
120
+ 'The components alias resolves to conflicting project paths.',
121
+ 'Keep one authoritative compilerOptions.paths mapping for the components alias.',
122
+ )
123
+ }
124
+ return best.resolved
125
+ }
126
+
127
+ export async function resolveUiRegistryTarget(
128
+ project: UiProject,
129
+ declaredTarget: string,
130
+ ): Promise<string> {
131
+ if (!declaredTarget.startsWith('components/')) return declaredTarget
132
+ const components = await readFile(project.componentsPath, 'utf8')
133
+ .then((value) => JSON.parse(value) as { aliases?: { components?: unknown } })
134
+ .catch(() => undefined)
135
+ const componentsAlias = components?.aliases?.components
136
+ if (typeof componentsAlias !== 'string' || componentsAlias.length === 0) return declaredTarget
137
+ const suffix = declaredTarget.slice('components/'.length)
138
+ const directAlias = /^(?:\.?\.?\/|src\/|app\/|frontend\/|components\/)/u.test(componentsAlias)
139
+ const resolved =
140
+ resolvePackageImport(project, componentsAlias) ??
141
+ (await resolveAlias(project, componentsAlias)) ??
142
+ (directAlias ? path.resolve(project.root, componentsAlias) : undefined)
143
+ if (!resolved) {
144
+ throw new UiError(
145
+ 'UI_PROJECT_UNSUPPORTED',
146
+ 'components.json alias cannot be resolved through tsconfig.json or jsconfig.json.',
147
+ 'Define a matching compilerOptions.paths entry for ' + componentsAlias + '.',
148
+ )
149
+ }
150
+ const relative = path.relative(project.root, path.join(resolved, suffix))
151
+ if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
152
+ throw new UiError('UI_PROJECT_UNSUPPORTED', 'components.json alias escapes the project.')
153
+ }
154
+ return relative.split(path.sep).join('/')
155
+ }
156
+
43
157
  function hasReactTailwind(manifest: Record<string, unknown>): boolean {
44
158
  const dependencies = {
45
159
  ...(manifest.dependencies as Record<string, string> | undefined),