@astrale-os/cli 1.0.0-beta.12 → 1.0.0-beta.13
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 +15432 -14745
- package/dist/types/admin/contract.d.ts +26 -0
- package/dist/types/admin/instance/client.d.ts +2 -4
- package/dist/types/admin/instance/model.d.ts +3 -0
- package/package.json +1 -1
- package/src/admin/.spec/architecture.md +12 -5
- package/src/admin/__tests__/fixture.ts +19 -95
- package/src/admin/catalog/.spec/api.d.ts +0 -2
- package/src/admin/catalog/.spec/architecture.md +5 -4
- package/src/admin/catalog/__tests__/client.test.ts +136 -33
- package/src/admin/catalog/client.ts +38 -61
- package/src/admin/contract.ts +46 -0
- package/src/admin/instance/.spec/api.d.ts +4 -8
- package/src/admin/instance/.spec/architecture.md +7 -7
- package/src/admin/instance/__tests__/client.test.ts +138 -31
- package/src/admin/instance/client.ts +32 -37
- package/src/admin/instance/model.ts +3 -0
- package/src/commands/__tests__/call.test.ts +34 -0
- package/src/commands/__tests__/read-commands.test.ts +27 -0
- package/src/commands/__tests__/token-ttl.test.ts +49 -4
- package/src/commands/call.ts +28 -3
- package/src/commands/query.ts +8 -2
- package/src/commands/token.ts +34 -20
- package/src/commands/ui/__tests__/commands.test.ts +129 -0
- package/src/commands/ui/add.ts +51 -0
- package/src/commands/ui/doctor.ts +13 -0
- package/src/commands/ui/init.ts +38 -0
- package/src/commands/ui/list.ts +26 -0
- package/src/commands/ui/preset-apply.ts +19 -0
- package/src/commands/ui/preset-list.ts +12 -0
- package/src/commands/ui/shared.ts +25 -0
- package/src/lib/__tests__/binary.test.ts +16 -1
- package/src/lib/binary.ts +22 -5
- package/src/lib/proc.ts +7 -2
- package/src/program/.spec/api.d.ts +1 -0
- package/src/program/__tests__/program.test.ts +32 -2
- package/src/program/build.ts +23 -1
- package/src/program/command.ts +1 -0
- package/src/program/registry.ts +2 -1
- package/src/ui/.spec/api.d.ts +14 -0
- package/src/ui/.spec/architecture.md +10 -0
- package/src/ui/.spec/laws.ts +36 -0
- package/src/ui/.spec/layout.ts +16 -0
- package/src/ui/__tests__/ui.test.ts +542 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/lock.ts +87 -0
- package/src/ui/model.ts +83 -0
- package/src/ui/operations.ts +539 -0
- package/src/ui/project.ts +146 -0
- package/src/ui/release.ts +267 -0
- package/src/ui/runner.ts +18 -0
- package/studio/server/agent/harness/gateway/token.test.ts +1 -1
- package/studio/server/agent/harness/gateway/token.ts +3 -3
- package/dist/types/admin/binding.d.ts +0 -19
- package/src/admin/__tests__/binding.test.ts +0 -31
- package/src/admin/binding.ts +0 -98
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { digest, parseUiLock } from '../lock'
|
|
7
|
+
import { UiError, type UiLock, type UiRegistry } from '../model'
|
|
8
|
+
import { addUi, applyPreset, doctorUi, initUi } from '../operations'
|
|
9
|
+
import { resolveUiRelease } from '../release'
|
|
10
|
+
import { shadcnInvocation } from '../runner'
|
|
11
|
+
|
|
12
|
+
const commit = 'a'.repeat(40)
|
|
13
|
+
const registry: UiRegistry = {
|
|
14
|
+
name: 'astrale-ui',
|
|
15
|
+
items: [
|
|
16
|
+
{
|
|
17
|
+
name: 'pattern-chart-line-basic',
|
|
18
|
+
type: 'registry:block',
|
|
19
|
+
description: 'A controlled chart.',
|
|
20
|
+
files: [
|
|
21
|
+
{
|
|
22
|
+
path: 'registry/patterns/chart/line-basic.tsx',
|
|
23
|
+
type: 'registry:component',
|
|
24
|
+
target: 'components/astrale/pattern/chart/line-basic.tsx',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
meta: { canonicalAddress: 'pattern/chart/line/basic' },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
const compatibility = {
|
|
32
|
+
version: 1,
|
|
33
|
+
shadcn: '4.18.0',
|
|
34
|
+
base: 'base',
|
|
35
|
+
style: 'nova',
|
|
36
|
+
baseUi: '1.7.0',
|
|
37
|
+
react: '^18.3.1 || ^19.0.0',
|
|
38
|
+
tailwind: '^4.3.3',
|
|
39
|
+
presets: ['astrale', 'compact', 'expressive'],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const temporary: string[] = []
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await Promise.all(
|
|
46
|
+
temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function mockFetch(seen: string[] = [], suppliedRegistry: UiRegistry = registry): typeof fetch {
|
|
51
|
+
return (async (input: string | URL | Request) => {
|
|
52
|
+
const url = String(input)
|
|
53
|
+
seen.push(url)
|
|
54
|
+
if (url.includes('/git/ref/tags/')) {
|
|
55
|
+
return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
|
|
56
|
+
}
|
|
57
|
+
if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
|
|
58
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
59
|
+
return Response.json({
|
|
60
|
+
name: 'astrale-ui',
|
|
61
|
+
include: ['registry/base/registry.json', 'registry/patterns/chart/registry.json'],
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
if (url.endsWith('/registry/base/registry.json')) {
|
|
65
|
+
return Response.json({ items: [{ name: 'astrale-base', type: 'registry:base' }] })
|
|
66
|
+
}
|
|
67
|
+
if (url.endsWith('/registry/patterns/chart/registry.json'))
|
|
68
|
+
return Response.json(suppliedRegistry)
|
|
69
|
+
const item = suppliedRegistry.items.find((candidate) =>
|
|
70
|
+
url.endsWith('/registry/public/r/' + candidate.name + '.json'),
|
|
71
|
+
)
|
|
72
|
+
if (item) return Response.json(builtItem(item))
|
|
73
|
+
return new Response('not found', { status: 404 })
|
|
74
|
+
}) as typeof fetch
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function builtItem(item: UiRegistry['items'][number]) {
|
|
78
|
+
return {
|
|
79
|
+
...item,
|
|
80
|
+
files: item.files.map((file, index) => ({
|
|
81
|
+
...file,
|
|
82
|
+
content: index === 0 ? 'export const Chart = true\n' : 'export const Summary = true\n',
|
|
83
|
+
})),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function fixture(): Promise<string> {
|
|
88
|
+
const root = await mkdtemp(path.join(tmpdir(), 'astrale-ui-cli-'))
|
|
89
|
+
temporary.push(root)
|
|
90
|
+
await mkdir(path.join(root, 'src'), { recursive: true })
|
|
91
|
+
await writeFile(
|
|
92
|
+
path.join(root, 'package.json'),
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
name: 'fixture',
|
|
95
|
+
private: true,
|
|
96
|
+
dependencies: { react: '19.2.8', 'react-dom': '19.2.8', tailwindcss: '4.3.3' },
|
|
97
|
+
packageManager: 'pnpm@11.13.1',
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
await writeFile(path.join(root, 'src/index.css'), '/* consumer css */\n')
|
|
101
|
+
return root
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function lock(): UiLock {
|
|
105
|
+
return {
|
|
106
|
+
$schema: 'https://example.invalid/ui-lock.schema.json',
|
|
107
|
+
version: 1,
|
|
108
|
+
package: { name: '@astrale-os/ui', version: '0.3.0-beta.0' },
|
|
109
|
+
registry: { repository: 'astrale-os/ui', ref: 'v0.3.0-beta.0', commit },
|
|
110
|
+
tooling: { shadcn: '4.18.0', base: 'base', style: 'nova', baseUi: '1.7.0' },
|
|
111
|
+
preset: 'astrale',
|
|
112
|
+
items: {},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('UI release and runner contracts', () => {
|
|
117
|
+
/** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
|
|
118
|
+
test('resolves one commit and reads the full release snapshot from it', async () => {
|
|
119
|
+
const seen: string[] = []
|
|
120
|
+
const release = await resolveUiRelease('0.3.0-beta.0', mockFetch(seen))
|
|
121
|
+
expect(release.commit).toBe(commit)
|
|
122
|
+
expect(release.compatibility.base).toBe('base')
|
|
123
|
+
expect(release.registry.items).toHaveLength(1)
|
|
124
|
+
expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
|
|
125
|
+
expect.arrayContaining([
|
|
126
|
+
expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
|
|
127
|
+
expect.stringContaining('/' + commit + '/registry'),
|
|
128
|
+
]),
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('constructs exact on-demand commands for every supported package manager', () => {
|
|
133
|
+
expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
|
|
134
|
+
file: 'pnpm',
|
|
135
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
136
|
+
})
|
|
137
|
+
expect(shadcnInvocation('npm', '4.18.0', ['add', 'item'])).toEqual({
|
|
138
|
+
file: 'npx',
|
|
139
|
+
args: ['--yes', 'shadcn@4.18.0', 'add', 'item'],
|
|
140
|
+
})
|
|
141
|
+
expect(shadcnInvocation('yarn', '4.18.0', ['add', 'item'])).toEqual({
|
|
142
|
+
file: 'yarn',
|
|
143
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
144
|
+
})
|
|
145
|
+
expect(shadcnInvocation('bun', '4.18.0', ['add', 'item'])).toEqual({
|
|
146
|
+
file: 'bunx',
|
|
147
|
+
args: ['shadcn@4.18.0', 'add', 'item'],
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('rejects an unsafe authoritative root without falling back to a legacy registry', async () => {
|
|
152
|
+
const seen: string[] = []
|
|
153
|
+
const fallback = mockFetch(seen)
|
|
154
|
+
const unsafe = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
155
|
+
const url = String(input)
|
|
156
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
157
|
+
return Response.json({ name: 'astrale-ui', include: ['../registry.json'] })
|
|
158
|
+
}
|
|
159
|
+
return fallback(input, init)
|
|
160
|
+
}) as typeof fetch
|
|
161
|
+
await expect(resolveUiRelease('0.3.0-beta.0', unsafe)).rejects.toThrow()
|
|
162
|
+
expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
|
|
166
|
+
test('bounds and normalizes malformed registry responses', async () => {
|
|
167
|
+
const fallback = mockFetch()
|
|
168
|
+
const oversized = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
169
|
+
const url = String(input)
|
|
170
|
+
if (url.endsWith('/tooling/compatibility.json')) {
|
|
171
|
+
return new Response('x'.repeat(1_048_577), {
|
|
172
|
+
headers: { 'content-type': 'application/json' },
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
return fallback(input, init)
|
|
176
|
+
}) as typeof fetch
|
|
177
|
+
await expect(resolveUiRelease('0.3.0-beta.0', oversized)).rejects.toMatchObject({
|
|
178
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
182
|
+
const url = String(input)
|
|
183
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
184
|
+
return new Response('{', { headers: { 'content-type': 'application/json' } })
|
|
185
|
+
}
|
|
186
|
+
return fallback(input, init)
|
|
187
|
+
}) as typeof fetch
|
|
188
|
+
await expect(resolveUiRelease('0.3.0-beta.0', malformed)).rejects.toMatchObject({
|
|
189
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('rejects include fan-out before fetching an unbounded registry graph', async () => {
|
|
194
|
+
const fallback = mockFetch()
|
|
195
|
+
const fanOut = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
196
|
+
const url = String(input)
|
|
197
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
198
|
+
return Response.json({
|
|
199
|
+
include: Array.from(
|
|
200
|
+
{ length: 100 },
|
|
201
|
+
(_, index) => 'registry/patterns/family-' + index + '/registry.json',
|
|
202
|
+
),
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
return fallback(input, init)
|
|
206
|
+
}) as typeof fetch
|
|
207
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fanOut)).rejects.toMatchObject({
|
|
208
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('UI initialization transaction', () => {
|
|
214
|
+
test('dry-run reports every mutation and writes nothing', async () => {
|
|
215
|
+
const root = await fixture()
|
|
216
|
+
const before = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
217
|
+
const result = await initUi(
|
|
218
|
+
{ path: root, version: '0.3.0-beta.0', dryRun: true },
|
|
219
|
+
{ fetcher: mockFetch() },
|
|
220
|
+
)
|
|
221
|
+
expect(result.status).toBe('planned')
|
|
222
|
+
expect(result.files).toEqual(
|
|
223
|
+
expect.arrayContaining([
|
|
224
|
+
'package.json',
|
|
225
|
+
'src/index.css',
|
|
226
|
+
'components.json',
|
|
227
|
+
'astrale-ui.lock.json',
|
|
228
|
+
]),
|
|
229
|
+
)
|
|
230
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(before)
|
|
231
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/** @evidence TEST-CLI-UI-LOCK-AFTER-SUCCESS */
|
|
235
|
+
test('restores all files and leaves no lock when dependency installation fails', async () => {
|
|
236
|
+
const root = await fixture()
|
|
237
|
+
const packageBefore = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
238
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
239
|
+
await expect(
|
|
240
|
+
initUi(
|
|
241
|
+
{ path: root, version: '0.3.0-beta.0' },
|
|
242
|
+
{
|
|
243
|
+
fetcher: mockFetch(),
|
|
244
|
+
runner: async () => ({ code: 1, stdout: '', stderr: 'registry unavailable' }),
|
|
245
|
+
},
|
|
246
|
+
),
|
|
247
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
248
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(packageBefore)
|
|
249
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
250
|
+
expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
|
|
251
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
252
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('writes Base Nova config and advances the lock only after configuration succeeds', async () => {
|
|
256
|
+
const root = await fixture()
|
|
257
|
+
await initUi(
|
|
258
|
+
{ path: root, version: '0.3.0-beta.0', preset: 'compact', install: false },
|
|
259
|
+
{ fetcher: mockFetch() },
|
|
260
|
+
)
|
|
261
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
262
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
263
|
+
expect(components.style).toBe('base-nova')
|
|
264
|
+
expect(written.tooling).toMatchObject({ base: 'base', style: 'nova', baseUi: '1.7.0' })
|
|
265
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
266
|
+
'@astrale-os/ui/presets/compact.css',
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('repeated exact init performs no release fetch while requested drift rejects', async () => {
|
|
271
|
+
const root = await fixture()
|
|
272
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
273
|
+
await writeFile(
|
|
274
|
+
path.join(root, 'components.json'),
|
|
275
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
276
|
+
)
|
|
277
|
+
await writeFile(
|
|
278
|
+
path.join(root, 'src/index.css'),
|
|
279
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
280
|
+
)
|
|
281
|
+
let fetched = false
|
|
282
|
+
const result = await initUi(
|
|
283
|
+
{ path: root, preset: 'astrale' },
|
|
284
|
+
{
|
|
285
|
+
fetcher: (async () => {
|
|
286
|
+
fetched = true
|
|
287
|
+
throw new Error('must not fetch')
|
|
288
|
+
}) as unknown as typeof fetch,
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
expect(result.status).toBe('unchanged')
|
|
292
|
+
expect(fetched).toBe(false)
|
|
293
|
+
await expect(initUi({ path: root, preset: 'compact' })).rejects.toMatchObject({
|
|
294
|
+
code: 'UI_ITEM_CONFLICT',
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('UI source operations', () => {
|
|
300
|
+
test('add rejects an empty programmatic request before project discovery or tool execution', async () => {
|
|
301
|
+
await expect(addUi([], {})).rejects.toMatchObject({ code: 'UI_ITEM_NOT_FOUND' })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('rejects hostile lock file records before an operation can escape the project', () => {
|
|
305
|
+
for (const items of [
|
|
306
|
+
[],
|
|
307
|
+
{
|
|
308
|
+
'pattern/chart/line/basic': {
|
|
309
|
+
address: 'pattern/chart/line/basic',
|
|
310
|
+
sourceDigest: 'bad',
|
|
311
|
+
files: {},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
'pattern/chart/line/basic': {
|
|
316
|
+
address: 'pattern/chart/line/basic',
|
|
317
|
+
sourceDigest: 'b'.repeat(64),
|
|
318
|
+
files: { '../../outside': 'c'.repeat(64) },
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
]) {
|
|
322
|
+
expect(() => parseUiLock({ ...lock(), items })).toThrow(UiError)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('add dry-run invokes the exact shadcn version and does not advance the lock', async () => {
|
|
327
|
+
const root = await fixture()
|
|
328
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
329
|
+
const before = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
330
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
331
|
+
const result = await addUi(
|
|
332
|
+
['pattern/chart/line/basic'],
|
|
333
|
+
{ project: root, dryRun: true },
|
|
334
|
+
{
|
|
335
|
+
fetcher: mockFetch(),
|
|
336
|
+
runner: async (file, args) => {
|
|
337
|
+
calls.push({ file, args })
|
|
338
|
+
return { code: 0, stdout: 'planned', stderr: '' }
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
expect(result.status).toBe('planned')
|
|
343
|
+
expect(result.sources).toEqual([
|
|
344
|
+
{
|
|
345
|
+
address: 'pattern/chart/line/basic',
|
|
346
|
+
dependencies: [],
|
|
347
|
+
files: ['components/astrale/pattern/chart/line-basic.tsx'],
|
|
348
|
+
},
|
|
349
|
+
])
|
|
350
|
+
expect(calls[0]).toMatchObject({ file: 'pnpm' })
|
|
351
|
+
expect(calls[0]?.args).toEqual(expect.arrayContaining(['dlx', 'shadcn@4.18.0', '--dry-run']))
|
|
352
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('successful add records installed file digests and doctor detects later edits', async () => {
|
|
356
|
+
const root = await fixture()
|
|
357
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
358
|
+
await writeFile(
|
|
359
|
+
path.join(root, 'components.json'),
|
|
360
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
361
|
+
)
|
|
362
|
+
await writeFile(
|
|
363
|
+
path.join(root, 'package.json'),
|
|
364
|
+
JSON.stringify({
|
|
365
|
+
name: 'fixture',
|
|
366
|
+
dependencies: {
|
|
367
|
+
react: '19.2.8',
|
|
368
|
+
'react-dom': '19.2.8',
|
|
369
|
+
tailwindcss: '4.3.3',
|
|
370
|
+
'@astrale-os/ui': '0.3.0-beta.0',
|
|
371
|
+
},
|
|
372
|
+
}),
|
|
373
|
+
)
|
|
374
|
+
await writeFile(
|
|
375
|
+
path.join(root, 'src/index.css'),
|
|
376
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
377
|
+
)
|
|
378
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
379
|
+
await addUi(
|
|
380
|
+
['pattern/chart/line/basic'],
|
|
381
|
+
{ project: root, yes: true },
|
|
382
|
+
{
|
|
383
|
+
fetcher: mockFetch(),
|
|
384
|
+
runner: async () => {
|
|
385
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
386
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
387
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
)
|
|
391
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
392
|
+
expect(written.items['pattern/chart/line/basic'].files).toEqual({
|
|
393
|
+
'components/astrale/pattern/chart/line-basic.tsx': digest('export const Chart = true\n'),
|
|
394
|
+
})
|
|
395
|
+
expect(written.items['pattern/chart/line/basic'].sourceDigest).toBe(
|
|
396
|
+
digest(JSON.stringify(builtItem(registry.items[0]!))),
|
|
397
|
+
)
|
|
398
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
399
|
+
await writeFile(installed, 'consumer edit\n')
|
|
400
|
+
expect((await doctorUi(root)).healthy).toBe(false)
|
|
401
|
+
await expect(
|
|
402
|
+
addUi(
|
|
403
|
+
['pattern/chart/line/basic'],
|
|
404
|
+
{ project: root },
|
|
405
|
+
{ fetcher: mockFetch(), runner: async () => ({ code: 0, stdout: '', stderr: '' }) },
|
|
406
|
+
),
|
|
407
|
+
).rejects.toBeInstanceOf(UiError)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('preflights symlink targets without invoking shadcn', async () => {
|
|
411
|
+
const root = await fixture()
|
|
412
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
413
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-outside-'))
|
|
414
|
+
temporary.push(outside)
|
|
415
|
+
await symlink(outside, path.join(root, 'components'))
|
|
416
|
+
let invoked = false
|
|
417
|
+
await expect(
|
|
418
|
+
addUi(
|
|
419
|
+
['pattern/chart/line/basic'],
|
|
420
|
+
{ project: root },
|
|
421
|
+
{
|
|
422
|
+
fetcher: mockFetch(),
|
|
423
|
+
runner: async () => {
|
|
424
|
+
invoked = true
|
|
425
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
),
|
|
429
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
430
|
+
expect(invoked).toBe(false)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('restores declared files and package state after a partial shadcn failure', async () => {
|
|
434
|
+
const root = await fixture()
|
|
435
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
436
|
+
const first = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
437
|
+
const second = path.join(root, 'components/astrale/pattern/chart/summary.tsx')
|
|
438
|
+
await mkdir(path.dirname(first), { recursive: true })
|
|
439
|
+
await writeFile(first, 'consumer original\n')
|
|
440
|
+
const twoFileRegistry: UiRegistry = {
|
|
441
|
+
...registry,
|
|
442
|
+
items: [
|
|
443
|
+
{
|
|
444
|
+
...registry.items[0]!,
|
|
445
|
+
files: [
|
|
446
|
+
registry.items[0]!.files[0]!,
|
|
447
|
+
{
|
|
448
|
+
path: 'registry/patterns/chart/summary.tsx',
|
|
449
|
+
type: 'registry:component',
|
|
450
|
+
target: 'components/astrale/pattern/chart/summary.tsx',
|
|
451
|
+
},
|
|
452
|
+
],
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
}
|
|
456
|
+
const lockBefore = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
457
|
+
await expect(
|
|
458
|
+
addUi(
|
|
459
|
+
['pattern/chart/line/basic'],
|
|
460
|
+
{ project: root },
|
|
461
|
+
{
|
|
462
|
+
fetcher: mockFetch([], twoFileRegistry),
|
|
463
|
+
runner: async () => {
|
|
464
|
+
await writeFile(first, 'partial overwrite\n')
|
|
465
|
+
await writeFile(second, 'partial create\n')
|
|
466
|
+
return { code: 1, stdout: '', stderr: 'interrupted' }
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
),
|
|
470
|
+
).rejects.toMatchObject({ code: 'UI_TOOL_FAILED' })
|
|
471
|
+
expect(await readFile(first, 'utf8')).toBe('consumer original\n')
|
|
472
|
+
expect(await Bun.file(second).exists()).toBe(false)
|
|
473
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(lockBefore)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test('overwrite requires explicit yes confirmation before invoking shadcn', async () => {
|
|
477
|
+
const root = await fixture()
|
|
478
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
479
|
+
let invoked = false
|
|
480
|
+
await expect(
|
|
481
|
+
addUi(
|
|
482
|
+
['pattern/chart/line/basic'],
|
|
483
|
+
{ project: root, overwrite: true },
|
|
484
|
+
{
|
|
485
|
+
fetcher: mockFetch(),
|
|
486
|
+
runner: async () => {
|
|
487
|
+
invoked = true
|
|
488
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
),
|
|
492
|
+
).rejects.toMatchObject({ code: 'UI_LOCAL_CHANGES' })
|
|
493
|
+
expect(invoked).toBe(false)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
/** @evidence TEST-CLI-UI-EXACT-ITEM-SOURCE */
|
|
497
|
+
test('rejects a built item that differs from the admitted release index before invoking shadcn', async () => {
|
|
498
|
+
const root = await fixture()
|
|
499
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
500
|
+
const fallback = mockFetch()
|
|
501
|
+
let invoked = false
|
|
502
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
503
|
+
const url = String(input)
|
|
504
|
+
if (url.endsWith('/registry/public/r/pattern-chart-line-basic.json')) {
|
|
505
|
+
return Response.json(registry.items[0])
|
|
506
|
+
}
|
|
507
|
+
return fallback(input, init)
|
|
508
|
+
}) as typeof fetch
|
|
509
|
+
await expect(
|
|
510
|
+
addUi(
|
|
511
|
+
['pattern/chart/line/basic'],
|
|
512
|
+
{ project: root },
|
|
513
|
+
{
|
|
514
|
+
fetcher: malformed,
|
|
515
|
+
runner: async () => {
|
|
516
|
+
invoked = true
|
|
517
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
),
|
|
521
|
+
).rejects.toMatchObject({ code: 'UI_REGISTRY_UNAVAILABLE' })
|
|
522
|
+
expect(invoked).toBe(false)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
test('preset dry-run is read-only and apply changes CSS plus lock without source rewrites', async () => {
|
|
526
|
+
const root = await fixture()
|
|
527
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
528
|
+
await writeFile(
|
|
529
|
+
path.join(root, 'src/index.css'),
|
|
530
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
531
|
+
)
|
|
532
|
+
const before = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
533
|
+
await applyPreset('expressive', { project: root, dryRun: true })
|
|
534
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(before)
|
|
535
|
+
await applyPreset('expressive', { project: root })
|
|
536
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
537
|
+
'@astrale-os/ui/presets/expressive.css',
|
|
538
|
+
)
|
|
539
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
540
|
+
expect(written.preset).toBe('expressive')
|
|
541
|
+
})
|
|
542
|
+
})
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
addUi,
|
|
3
|
+
applyPreset,
|
|
4
|
+
doctorUi,
|
|
5
|
+
initUi,
|
|
6
|
+
listLockedUi,
|
|
7
|
+
listUi,
|
|
8
|
+
type InitUiOptions,
|
|
9
|
+
} from './operations'
|
|
10
|
+
export { UI_PRESETS, UiError, type UiLock, type UiPreset } from './model'
|
|
11
|
+
export { discoverUiProject, type UiProject } from './project'
|
|
12
|
+
export { resolveUiRelease } from './release'
|
|
13
|
+
export { shadcnInvocation, type UiRunner } from './runner'
|
package/src/ui/lock.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { UI_LOCK_FILE, UI_PACKAGE, UI_PRESETS, UI_REPOSITORY, UiError, type UiLock } from './model'
|
|
5
|
+
|
|
6
|
+
export function digest(value: string | Uint8Array): string {
|
|
7
|
+
return createHash('sha256').update(value).digest('hex')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseUiLock(value: unknown): UiLock {
|
|
11
|
+
const lock = value as Partial<UiLock> | null
|
|
12
|
+
if (
|
|
13
|
+
!lock ||
|
|
14
|
+
lock.version !== 1 ||
|
|
15
|
+
lock.package?.name !== UI_PACKAGE ||
|
|
16
|
+
typeof lock.package.version !== 'string' ||
|
|
17
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(lock.package.version) ||
|
|
18
|
+
lock.registry?.repository !== UI_REPOSITORY ||
|
|
19
|
+
lock.registry.ref !== 'v' + lock.package.version ||
|
|
20
|
+
!/^[0-9a-f]{40}$/u.test(lock.registry.commit ?? '') ||
|
|
21
|
+
typeof lock.tooling?.shadcn !== 'string' ||
|
|
22
|
+
!/^\d+\.\d+\.\d+$/u.test(lock.tooling.shadcn) ||
|
|
23
|
+
lock.tooling.base !== 'base' ||
|
|
24
|
+
lock.tooling.style !== 'nova' ||
|
|
25
|
+
typeof lock.tooling.baseUi !== 'string' ||
|
|
26
|
+
!/^\d+\.\d+\.\d+$/u.test(lock.tooling.baseUi) ||
|
|
27
|
+
!UI_PRESETS.includes(lock.preset as (typeof UI_PRESETS)[number]) ||
|
|
28
|
+
!lock.items ||
|
|
29
|
+
!isRecord(lock.items)
|
|
30
|
+
) {
|
|
31
|
+
throw new UiError('UI_LOCK_INVALID', UI_LOCK_FILE + ' is structurally invalid.')
|
|
32
|
+
}
|
|
33
|
+
for (const [address, item] of Object.entries(lock.items)) {
|
|
34
|
+
if (
|
|
35
|
+
!/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(address) ||
|
|
36
|
+
!isRecord(item) ||
|
|
37
|
+
item.address !== address ||
|
|
38
|
+
!isDigest(item.sourceDigest) ||
|
|
39
|
+
!isRecord(item.files)
|
|
40
|
+
) {
|
|
41
|
+
throw new UiError('UI_LOCK_INVALID', UI_LOCK_FILE + ' contains an invalid item record.')
|
|
42
|
+
}
|
|
43
|
+
for (const [file, expected] of Object.entries(item.files)) {
|
|
44
|
+
if (!isSafeRelative(file) || !isDigest(expected)) {
|
|
45
|
+
throw new UiError('UI_LOCK_INVALID', UI_LOCK_FILE + ' contains an unsafe file record.')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return lock as UiLock
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
54
|
+
const prototype = Object.getPrototypeOf(value)
|
|
55
|
+
return prototype === Object.prototype || prototype === null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isDigest(value: unknown): value is string {
|
|
59
|
+
return typeof value === 'string' && /^[0-9a-f]{64}$/u.test(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSafeRelative(value: string): boolean {
|
|
63
|
+
return (
|
|
64
|
+
value.length > 0 &&
|
|
65
|
+
!pathIsAbsolute(value) &&
|
|
66
|
+
!value.split(/[\\/]/u).includes('..') &&
|
|
67
|
+
!value.includes('\\')
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pathIsAbsolute(value: string): boolean {
|
|
72
|
+
return value.startsWith('/') || /^[A-Za-z]:[\\/]/u.test(value)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readUiLock(target: string): Promise<UiLock> {
|
|
76
|
+
try {
|
|
77
|
+
return parseUiLock(JSON.parse(await readFile(target, 'utf8')))
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
if (cause instanceof UiError) throw cause
|
|
80
|
+
throw new UiError(
|
|
81
|
+
'UI_CONFIG_MISSING',
|
|
82
|
+
'Unable to read ' + UI_LOCK_FILE + '.',
|
|
83
|
+
'Run astrale ui init.',
|
|
84
|
+
{ cause },
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|