@astrale-os/cli 1.0.0-beta.23 → 1.0.0-beta.25

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.25",
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
  },
@@ -330,6 +330,97 @@ describe('UI release and runner contracts', () => {
330
330
  )
331
331
  })
332
332
 
333
+ test('resolves the immutable release commit when the anonymous GitHub API is rate limited', async () => {
334
+ const seen: string[] = []
335
+ const fallback = mockFetch(seen)
336
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
337
+ const url = String(input)
338
+ if (url.includes('/git/ref/tags/')) {
339
+ seen.push(url)
340
+ return Response.json({ message: 'API rate limit exceeded' }, { status: 403 })
341
+ }
342
+ if (url.includes('/releases/tag/')) {
343
+ seen.push(url)
344
+ return new Response(
345
+ `<a data-hovercard-type="commit" href="/astrale-os/ui/commit/${'b'.repeat(40)}">decoy</a>
346
+ <a href="/astrale-os/ui/tree/v0.3.0-beta.0">repository navigation</a>
347
+ ${'x'.repeat(35_000)}
348
+ <a href="/astrale-os/ui/tree/v0.3.0-beta.0">tag</a>
349
+ <a data-hovercard-type="commit" href="/astrale-os/ui/commit/${commit}">commit</a>`,
350
+ { headers: { 'content-type': 'text/html' } },
351
+ )
352
+ }
353
+ return fallback(input, init)
354
+ }) as typeof fetch
355
+
356
+ const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
357
+
358
+ expect(release.commit).toBe(commit)
359
+ expect(seen).toContain('https://github.com/astrale-os/ui/releases/tag/v0.3.0-beta.0')
360
+ expect(seen).toContain(
361
+ 'https://raw.githubusercontent.com/astrale-os/ui/' + commit + '/tooling/compatibility.json',
362
+ )
363
+ })
364
+
365
+ test('rejects a release page without an exact Astrale UI commit target', async () => {
366
+ const fetcher = (async (input: string | URL | Request) => {
367
+ const url = String(input)
368
+ if (url.includes('/git/ref/tags/')) return new Response('limited', { status: 403 })
369
+ if (url.includes('/releases/tag/')) return new Response('<main>release unavailable</main>')
370
+ throw new Error('release snapshot must not be fetched')
371
+ }) as typeof fetch
372
+
373
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
374
+ code: 'UI_REGISTRY_UNAVAILABLE',
375
+ message: 'UI ref v0.3.0-beta.0 did not resolve to a commit.',
376
+ })
377
+ })
378
+
379
+ test('does not replace an authoritative missing tag with release-page HTML', async () => {
380
+ const seen: string[] = []
381
+ const fetcher = (async (input: string | URL | Request) => {
382
+ const url = String(input)
383
+ seen.push(url)
384
+ if (url.includes('/git/ref/tags/')) return new Response('missing', { status: 404 })
385
+ throw new Error('release page must not be fetched')
386
+ }) as typeof fetch
387
+
388
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
389
+ code: 'UI_REGISTRY_UNAVAILABLE',
390
+ message: 'UI ref v0.3.0-beta.0 returned HTTP 404.',
391
+ })
392
+ expect(seen).toEqual(['https://api.github.com/repos/astrale-os/ui/git/ref/tags/v0.3.0-beta.0'])
393
+ })
394
+
395
+ test('bounds declared and streamed release-page HTML before reading a snapshot', async () => {
396
+ for (const oversized of [
397
+ new Response('large', { headers: { 'content-length': '1048577' } }),
398
+ new Response(
399
+ new ReadableStream({
400
+ start(controller) {
401
+ controller.enqueue(new Uint8Array(1_048_577))
402
+ controller.close()
403
+ },
404
+ }),
405
+ ),
406
+ ]) {
407
+ const seen: string[] = []
408
+ const fetcher = (async (input: string | URL | Request) => {
409
+ const url = String(input)
410
+ seen.push(url)
411
+ if (url.includes('/git/ref/tags/')) return new Response('limited', { status: 403 })
412
+ if (url.includes('/releases/tag/')) return oversized
413
+ throw new Error('release snapshot must not be fetched')
414
+ }) as typeof fetch
415
+
416
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
417
+ code: 'UI_REGISTRY_UNAVAILABLE',
418
+ message: 'UI release v0.3.0-beta.0 exceeds the supported response size.',
419
+ })
420
+ expect(seen).toHaveLength(2)
421
+ }
422
+ })
423
+
333
424
  test('admits a release theme as one canonical consumer-owned CSS target', async () => {
334
425
  const release = await resolveUiRelease('0.3.0-beta.0', themeFetch())
335
426
  expect(release.registry.items).toEqual([
@@ -1209,6 +1300,201 @@ describe('UI source operations', () => {
1209
1300
  ).rejects.toBeInstanceOf(UiError)
1210
1301
  })
1211
1302
 
1303
+ test('records multi-file component targets resolved through the consumer components alias', async () => {
1304
+ const root = await lockedFixture()
1305
+ await writeFile(
1306
+ path.join(root, 'components.json'),
1307
+ JSON.stringify({
1308
+ style: 'base-nova',
1309
+ tailwind: { css: 'src/index.css' },
1310
+ aliases: { components: '@/components' },
1311
+ }),
1312
+ )
1313
+ await writeFile(
1314
+ path.join(root, 'tsconfig.json'),
1315
+ JSON.stringify({
1316
+ compilerOptions: {
1317
+ paths: { '@/*': ['./src/*'], '@/components/*': ['./app/ui/*'] },
1318
+ },
1319
+ }),
1320
+ )
1321
+ await writeFile(
1322
+ path.join(root, 'src/index.css'),
1323
+ "@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
1324
+ )
1325
+ const sidebar = path.join(root, 'src/components/astrale/component/sidebar/sidebar.tsx')
1326
+ const hook = path.join(root, 'src/components/astrale/component/sidebar/use-mobile.ts')
1327
+
1328
+ const planned = await addUi(
1329
+ ['component/sidebar'],
1330
+ { project: root, dryRun: true, yes: true },
1331
+ {
1332
+ fetcher: mockFetch([], componentRegistry),
1333
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1334
+ },
1335
+ )
1336
+ expect(planned.sources).toEqual([
1337
+ expect.objectContaining({
1338
+ files: [
1339
+ 'src/components/astrale/component/sidebar/sidebar.tsx',
1340
+ 'src/components/astrale/component/sidebar/use-mobile.ts',
1341
+ ],
1342
+ }),
1343
+ ])
1344
+
1345
+ await addUi(
1346
+ ['component/sidebar'],
1347
+ { project: root, yes: true },
1348
+ {
1349
+ fetcher: mockFetch([], componentRegistry),
1350
+ runner: async () => {
1351
+ await mkdir(path.dirname(sidebar), { recursive: true })
1352
+ await writeFile(sidebar, 'export const Sidebar = true\n')
1353
+ await writeFile(hook, 'export const useMobile = true\n')
1354
+ return { code: 0, stdout: '', stderr: '' }
1355
+ },
1356
+ },
1357
+ )
1358
+
1359
+ const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
1360
+ expect(written.items['component/sidebar'].files).toEqual({
1361
+ 'src/components/astrale/component/sidebar/sidebar.tsx': digest(
1362
+ 'export const Sidebar = true\n',
1363
+ ),
1364
+ 'src/components/astrale/component/sidebar/use-mobile.ts': digest(
1365
+ 'export const useMobile = true\n',
1366
+ ),
1367
+ })
1368
+ expect((await doctorUi(root)).healthy).toBe(true)
1369
+ })
1370
+
1371
+ test('resolves a components alias through package imports before tsconfig paths', async () => {
1372
+ const root = await lockedFixture()
1373
+ const manifestPath = path.join(root, 'package.json')
1374
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
1375
+ manifest.imports = { '#app/*': './src/app/*' }
1376
+ await writeFile(manifestPath, JSON.stringify(manifest))
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: '#app/components' },
1383
+ }),
1384
+ )
1385
+
1386
+ const planned = await addUi(
1387
+ ['pattern/chart/line/basic'],
1388
+ { project: root, dryRun: true, yes: true },
1389
+ {
1390
+ fetcher: mockFetch(),
1391
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1392
+ },
1393
+ )
1394
+
1395
+ expect(planned.sources).toEqual([
1396
+ expect.objectContaining({
1397
+ files: ['src/app/components/astrale/pattern/chart/line-basic.tsx'],
1398
+ }),
1399
+ ])
1400
+ })
1401
+
1402
+ test('rejects an unresolved package-import alias before invoking shadcn', async () => {
1403
+ const root = await lockedFixture()
1404
+ await writeFile(
1405
+ path.join(root, 'components.json'),
1406
+ JSON.stringify({
1407
+ style: 'base-nova',
1408
+ tailwind: { css: 'src/index.css' },
1409
+ aliases: { components: '#missing/components' },
1410
+ }),
1411
+ )
1412
+ let invoked = false
1413
+
1414
+ await expect(
1415
+ addUi(
1416
+ ['pattern/chart/line/basic'],
1417
+ { project: root, yes: true },
1418
+ {
1419
+ fetcher: mockFetch(),
1420
+ runner: async () => {
1421
+ invoked = true
1422
+ return { code: 0, stdout: '', stderr: '' }
1423
+ },
1424
+ },
1425
+ ),
1426
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
1427
+ expect(invoked).toBe(false)
1428
+ })
1429
+
1430
+ test('resolves an extended JSONC baseUrl with the pinned shadcn config loader', async () => {
1431
+ const root = await lockedFixture()
1432
+ await writeFile(
1433
+ path.join(root, 'components.json'),
1434
+ JSON.stringify({
1435
+ style: 'base-nova',
1436
+ tailwind: { css: 'src/index.css' },
1437
+ aliases: { components: '@/components' },
1438
+ }),
1439
+ )
1440
+ await mkdir(path.join(root, 'config'), { recursive: true })
1441
+ await writeFile(
1442
+ path.join(root, 'tsconfig.json'),
1443
+ '{\n // shadcn loads this root config.\n "extends": "./config/base.json",\n}\n',
1444
+ )
1445
+ await writeFile(
1446
+ path.join(root, 'config/base.json'),
1447
+ '{\n "compilerOptions": {\n "baseUrl": "..",\n "paths": { "@/*": ["frontend/src/*"], },\n },\n}\n',
1448
+ )
1449
+
1450
+ const planned = await addUi(
1451
+ ['pattern/chart/line/basic'],
1452
+ { project: root, dryRun: true, yes: true },
1453
+ {
1454
+ fetcher: mockFetch(),
1455
+ runner: async () => ({ code: 0, stdout: 'planned', stderr: '' }),
1456
+ },
1457
+ )
1458
+
1459
+ expect(planned.sources).toEqual([
1460
+ expect.objectContaining({
1461
+ files: ['frontend/src/components/astrale/pattern/chart/line-basic.tsx'],
1462
+ }),
1463
+ ])
1464
+ })
1465
+
1466
+ test('rejects an alias mapping outside the project before invoking shadcn', async () => {
1467
+ const root = await lockedFixture()
1468
+ await writeFile(
1469
+ path.join(root, 'components.json'),
1470
+ JSON.stringify({
1471
+ style: 'base-nova',
1472
+ tailwind: { css: 'src/index.css' },
1473
+ aliases: { components: '@/components' },
1474
+ }),
1475
+ )
1476
+ await writeFile(
1477
+ path.join(root, 'tsconfig.json'),
1478
+ JSON.stringify({ compilerOptions: { paths: { '@/*': ['../outside/*'] } } }),
1479
+ )
1480
+ let invoked = false
1481
+
1482
+ await expect(
1483
+ addUi(
1484
+ ['pattern/chart/line/basic'],
1485
+ { project: root, yes: true },
1486
+ {
1487
+ fetcher: mockFetch(),
1488
+ runner: async () => {
1489
+ invoked = true
1490
+ return { code: 0, stdout: '', stderr: '' }
1491
+ },
1492
+ },
1493
+ ),
1494
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
1495
+ expect(invoked).toBe(false)
1496
+ })
1497
+
1212
1498
  test('restores the exact locked UI dependency after shadcn applies its compatible range', async () => {
1213
1499
  const root = await lockedFixture()
1214
1500
  const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
@@ -1335,10 +1621,22 @@ describe('UI source operations', () => {
1335
1621
  expect(invoked).toBe(false)
1336
1622
  })
1337
1623
 
1338
- test('restores declared files and package state after a partial shadcn failure', async () => {
1624
+ test('restores alias-resolved files and package state after a partial shadcn failure', async () => {
1339
1625
  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')
1626
+ await writeFile(
1627
+ path.join(root, 'components.json'),
1628
+ JSON.stringify({
1629
+ style: 'base-nova',
1630
+ tailwind: { css: 'src/index.css' },
1631
+ aliases: { components: '@/components' },
1632
+ }),
1633
+ )
1634
+ await writeFile(
1635
+ path.join(root, 'tsconfig.json'),
1636
+ JSON.stringify({ compilerOptions: { paths: { '@/*': ['./src/*'] } } }),
1637
+ )
1638
+ const first = path.join(root, 'src/components/astrale/pattern/chart/line-basic.tsx')
1639
+ const second = path.join(root, 'src/components/astrale/pattern/chart/summary.tsx')
1342
1640
  await mkdir(path.dirname(first), { recursive: true })
1343
1641
  await writeFile(first, 'consumer original\n')
1344
1642
  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),
package/src/ui/release.ts CHANGED
@@ -2,11 +2,17 @@ import { UiError, type UiCompatibility, type UiRegistry, type UiRelease } from '
2
2
 
3
3
  const NPM_PACKAGE = 'https://registry.npmjs.org/@astrale-os/ui'
4
4
  const GITHUB_API = 'https://api.github.com/repos/astrale-os/ui'
5
+ const GITHUB_WEB = 'https://github.com/astrale-os/ui'
5
6
  const RAW = 'https://raw.githubusercontent.com/astrale-os/ui'
6
7
  const MAX_DOCUMENT_BYTES = 1_048_576
7
8
  const MAX_REGISTRY_DOCUMENTS = 100
8
9
 
9
10
  type Fetch = typeof fetch
11
+ class HttpStatusError extends Error {
12
+ constructor(readonly status: number) {
13
+ super('HTTP ' + status)
14
+ }
15
+ }
10
16
  type RegistrySource = {
11
17
  name?: string
12
18
  homepage?: string
@@ -23,8 +29,24 @@ async function json<T>(fetcher: Fetch, url: string, label: string): Promise<T> {
23
29
  cause,
24
30
  })
25
31
  }
32
+ const body = await responseText(response, label)
33
+ try {
34
+ return JSON.parse(body) as T
35
+ } catch (cause) {
36
+ throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' returned malformed JSON.', undefined, {
37
+ cause,
38
+ })
39
+ }
40
+ }
41
+
42
+ async function responseText(response: Response, label: string): Promise<string> {
26
43
  if (!response.ok) {
27
- throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' returned HTTP ' + response.status + '.')
44
+ throw new UiError(
45
+ 'UI_REGISTRY_UNAVAILABLE',
46
+ label + ' returned HTTP ' + response.status + '.',
47
+ undefined,
48
+ { cause: new HttpStatusError(response.status) },
49
+ )
28
50
  }
29
51
  const declaredLength = Number(response.headers.get('content-length'))
30
52
  if (Number.isFinite(declaredLength) && declaredLength > MAX_DOCUMENT_BYTES) {
@@ -52,13 +74,7 @@ async function json<T>(fetcher: Fetch, url: string, label: string): Promise<T> {
52
74
  body.set(chunk, offset)
53
75
  offset += chunk.byteLength
54
76
  }
55
- try {
56
- return JSON.parse(new TextDecoder().decode(body)) as T
57
- } catch (cause) {
58
- throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' returned malformed JSON.', undefined, {
59
- cause,
60
- })
61
- }
77
+ return new TextDecoder().decode(body)
62
78
  }
63
79
 
64
80
  export async function resolveUiRelease(
@@ -76,11 +92,19 @@ export async function resolveUiRelease(
76
92
  throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Invalid UI beta release version: ' + version)
77
93
  }
78
94
  const ref = 'v' + version
79
- const reference = await json<{
80
- object: { type: 'commit' | 'tag'; sha: string; url: string }
81
- }>(fetcher, GITHUB_API + '/git/ref/tags/' + encodeURIComponent(ref), 'UI ref ' + ref)
82
- const commit =
83
- reference.object.type === 'commit'
95
+ const commit = await resolveReleaseCommit(ref, fetcher)
96
+ if (!/^[0-9a-f]{40}$/u.test(commit)) {
97
+ throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI ref ' + ref + ' did not resolve to a commit.')
98
+ }
99
+ return readUiReleaseSnapshot({ version, ref, commit }, fetcher)
100
+ }
101
+
102
+ async function resolveReleaseCommit(ref: string, fetcher: Fetch): Promise<string> {
103
+ try {
104
+ const reference = await json<{
105
+ object: { type: 'commit' | 'tag'; sha: string; url: string }
106
+ }>(fetcher, GITHUB_API + '/git/ref/tags/' + encodeURIComponent(ref), 'UI ref ' + ref)
107
+ return reference.object.type === 'commit'
84
108
  ? reference.object.sha
85
109
  : (
86
110
  await json<{ object: { sha: string } }>(
@@ -89,10 +113,41 @@ export async function resolveUiRelease(
89
113
  'annotated UI tag ' + ref,
90
114
  )
91
115
  ).object.sha
92
- if (!/^[0-9a-f]{40}$/u.test(commit)) {
116
+ } catch (cause) {
117
+ const status =
118
+ cause instanceof UiError && cause.cause instanceof HttpStatusError
119
+ ? cause.cause.status
120
+ : undefined
121
+ if (status !== 403 && status !== 429 && !(status !== undefined && status >= 500)) throw cause
122
+ }
123
+
124
+ const label = 'UI release ' + ref
125
+ let response: Response
126
+ try {
127
+ response = await fetcher(GITHUB_WEB + '/releases/tag/' + encodeURIComponent(ref), {
128
+ headers: { accept: 'text/html' },
129
+ })
130
+ } catch (cause) {
131
+ throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Unable to reach ' + label + '.', undefined, {
132
+ cause,
133
+ })
134
+ }
135
+ const html = await responseText(response, label)
136
+ const marker = 'href="/astrale-os/ui/tree/' + ref + '"'
137
+ const markerOffset = html.lastIndexOf(marker)
138
+ const releaseHeader = markerOffset < 0 ? '' : html.slice(markerOffset, markerOffset + 8_192)
139
+ const commits = new Set(
140
+ [
141
+ ...releaseHeader.matchAll(
142
+ /data-hovercard-type=["']commit["'][^>]+href=["']\/astrale-os\/ui\/commit\/([0-9a-f]{40})["']/gu,
143
+ ),
144
+ ].map((match) => match[1]),
145
+ )
146
+ const commit = commits.size === 1 ? commits.values().next().value : undefined
147
+ if (!commit) {
93
148
  throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI ref ' + ref + ' did not resolve to a commit.')
94
149
  }
95
- return readUiReleaseSnapshot({ version, ref, commit }, fetcher)
150
+ return commit
96
151
  }
97
152
 
98
153
  export async function readUiReleaseSnapshot(