@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/dist/astrale.js +2164 -17
- package/package.json +2 -1
- package/src/ui/__tests__/ui.test.ts +301 -3
- package/src/ui/lock.ts +1 -1
- package/src/ui/operations.ts +16 -7
- package/src/ui/project.ts +114 -0
- package/src/ui/release.ts +70 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrale-os/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
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
|
|
1624
|
+
test('restores alias-resolved files and package state after a partial shadcn failure', async () => {
|
|
1339
1625
|
const root = await lockedFixture()
|
|
1340
|
-
|
|
1341
|
-
|
|
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) ||
|
package/src/ui/operations.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
730
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
150
|
+
return commit
|
|
96
151
|
}
|
|
97
152
|
|
|
98
153
|
export async function readUiReleaseSnapshot(
|