@astrale-os/cli 1.0.0-beta.12 → 1.0.0-beta.14
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 +15520 -14749
- 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 +145 -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 +126 -3
- package/src/program/argv.ts +14 -0
- package/src/program/build.ts +24 -2
- package/src/program/command.ts +1 -0
- package/src/program/index.ts +1 -0
- package/src/program/registry.ts +2 -1
- package/src/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/recorder.ts +9 -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 +634 -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 +214 -0
- package/src/ui/release.ts +270 -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,16 @@
|
|
|
1
|
+
import { defineLayout } from '@astrale-os/spec/authoring'
|
|
2
|
+
|
|
3
|
+
export default defineLayout({
|
|
4
|
+
entries: [
|
|
5
|
+
'.spec/',
|
|
6
|
+
'__tests__/',
|
|
7
|
+
'index.ts',
|
|
8
|
+
'lock.ts',
|
|
9
|
+
'model.ts',
|
|
10
|
+
'operations.ts',
|
|
11
|
+
'project.ts',
|
|
12
|
+
'release.ts',
|
|
13
|
+
'runner.ts',
|
|
14
|
+
],
|
|
15
|
+
exact: true,
|
|
16
|
+
})
|
|
@@ -0,0 +1,634 @@
|
|
|
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
|
+
test('resolves the default release from the public beta channel', async () => {
|
|
118
|
+
const seen: string[] = []
|
|
119
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
120
|
+
const url = String(input)
|
|
121
|
+
if (url.endsWith('/@astrale-os/ui/beta')) {
|
|
122
|
+
seen.push(url)
|
|
123
|
+
return Response.json({ version: '0.3.0-beta.1' })
|
|
124
|
+
}
|
|
125
|
+
return mockFetch(seen)(input, init)
|
|
126
|
+
}) as typeof fetch
|
|
127
|
+
|
|
128
|
+
const release = await resolveUiRelease(undefined, fetcher)
|
|
129
|
+
|
|
130
|
+
expect(release.version).toBe('0.3.0-beta.1')
|
|
131
|
+
expect(seen[0]).toBe('https://registry.npmjs.org/@astrale-os/ui/beta')
|
|
132
|
+
expect(seen).not.toContain('https://registry.npmjs.org/@astrale-os/ui/latest')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('rejects a public beta channel that does not resolve to a beta release', async () => {
|
|
136
|
+
const fetcher = (async (input: string | URL | Request) => {
|
|
137
|
+
const url = String(input)
|
|
138
|
+
if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0' })
|
|
139
|
+
throw new Error('release snapshot must not be fetched')
|
|
140
|
+
}) as typeof fetch
|
|
141
|
+
|
|
142
|
+
await expect(resolveUiRelease(undefined, fetcher)).rejects.toMatchObject({
|
|
143
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
144
|
+
message: 'Invalid UI beta release version: 0.3.0',
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
|
|
149
|
+
test('resolves one commit and reads the full release snapshot from it', async () => {
|
|
150
|
+
const seen: string[] = []
|
|
151
|
+
const release = await resolveUiRelease('0.3.0-beta.0', mockFetch(seen))
|
|
152
|
+
expect(release.commit).toBe(commit)
|
|
153
|
+
expect(release.compatibility.base).toBe('base')
|
|
154
|
+
expect(release.registry.items).toHaveLength(1)
|
|
155
|
+
expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
|
|
156
|
+
expect.arrayContaining([
|
|
157
|
+
expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
|
|
158
|
+
expect.stringContaining('/' + commit + '/registry'),
|
|
159
|
+
]),
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('constructs exact on-demand commands for every supported package manager', () => {
|
|
164
|
+
expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
|
|
165
|
+
file: 'pnpm',
|
|
166
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
167
|
+
})
|
|
168
|
+
expect(shadcnInvocation('npm', '4.18.0', ['add', 'item'])).toEqual({
|
|
169
|
+
file: 'npx',
|
|
170
|
+
args: ['--yes', 'shadcn@4.18.0', 'add', 'item'],
|
|
171
|
+
})
|
|
172
|
+
expect(shadcnInvocation('yarn', '4.18.0', ['add', 'item'])).toEqual({
|
|
173
|
+
file: 'yarn',
|
|
174
|
+
args: ['dlx', 'shadcn@4.18.0', 'add', 'item'],
|
|
175
|
+
})
|
|
176
|
+
expect(shadcnInvocation('bun', '4.18.0', ['add', 'item'])).toEqual({
|
|
177
|
+
file: 'bunx',
|
|
178
|
+
args: ['shadcn@4.18.0', 'add', 'item'],
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('rejects an unsafe authoritative root without falling back to a legacy registry', async () => {
|
|
183
|
+
const seen: string[] = []
|
|
184
|
+
const fallback = mockFetch(seen)
|
|
185
|
+
const unsafe = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
186
|
+
const url = String(input)
|
|
187
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
188
|
+
return Response.json({ name: 'astrale-ui', include: ['../registry.json'] })
|
|
189
|
+
}
|
|
190
|
+
return fallback(input, init)
|
|
191
|
+
}) as typeof fetch
|
|
192
|
+
await expect(resolveUiRelease('0.3.0-beta.0', unsafe)).rejects.toThrow()
|
|
193
|
+
expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
/** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
|
|
197
|
+
test('bounds and normalizes malformed registry responses', async () => {
|
|
198
|
+
const fallback = mockFetch()
|
|
199
|
+
const oversized = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
200
|
+
const url = String(input)
|
|
201
|
+
if (url.endsWith('/tooling/compatibility.json')) {
|
|
202
|
+
return new Response('x'.repeat(1_048_577), {
|
|
203
|
+
headers: { 'content-type': 'application/json' },
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
return fallback(input, init)
|
|
207
|
+
}) as typeof fetch
|
|
208
|
+
await expect(resolveUiRelease('0.3.0-beta.0', oversized)).rejects.toMatchObject({
|
|
209
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
213
|
+
const url = String(input)
|
|
214
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
215
|
+
return new Response('{', { headers: { 'content-type': 'application/json' } })
|
|
216
|
+
}
|
|
217
|
+
return fallback(input, init)
|
|
218
|
+
}) as typeof fetch
|
|
219
|
+
await expect(resolveUiRelease('0.3.0-beta.0', malformed)).rejects.toMatchObject({
|
|
220
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('rejects include fan-out before fetching an unbounded registry graph', async () => {
|
|
225
|
+
const fallback = mockFetch()
|
|
226
|
+
const fanOut = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
227
|
+
const url = String(input)
|
|
228
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
229
|
+
return Response.json({
|
|
230
|
+
include: Array.from(
|
|
231
|
+
{ length: 100 },
|
|
232
|
+
(_, index) => 'registry/patterns/family-' + index + '/registry.json',
|
|
233
|
+
),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
return fallback(input, init)
|
|
237
|
+
}) as typeof fetch
|
|
238
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fanOut)).rejects.toMatchObject({
|
|
239
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('UI initialization transaction', () => {
|
|
245
|
+
test('initializes the generated Domain frontend stylesheet instead of an unused fallback', async () => {
|
|
246
|
+
const root = await fixture()
|
|
247
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
248
|
+
await writeFile(
|
|
249
|
+
path.join(root, 'frontend/package.json'),
|
|
250
|
+
JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
|
|
251
|
+
)
|
|
252
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
253
|
+
const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
254
|
+
|
|
255
|
+
await initUi(
|
|
256
|
+
{ path: path.join(root, 'frontend'), version: '0.3.0-beta.0', install: false },
|
|
257
|
+
{ fetcher: mockFetch() },
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
const css = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
|
|
261
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
262
|
+
expect(css).toContain("@import '@astrale-os/ui/theme.css';")
|
|
263
|
+
expect(css).toContain('/* Domain frontend */')
|
|
264
|
+
expect(components.tailwind.css).toBe('frontend/src/styles.css')
|
|
265
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
|
|
266
|
+
expect(
|
|
267
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
|
|
268
|
+
).toHaveProperty('@astrale-os/ui')
|
|
269
|
+
expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
|
|
270
|
+
|
|
271
|
+
await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
|
|
272
|
+
const repeated = await initUi(
|
|
273
|
+
{ path: root, version: '0.3.0-beta.0', install: false },
|
|
274
|
+
{ fetcher: mockFetch() },
|
|
275
|
+
)
|
|
276
|
+
expect(repeated.status).toBe('unchanged')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
|
|
280
|
+
const root = await fixture()
|
|
281
|
+
const outside = await fixture()
|
|
282
|
+
await symlink(outside, path.join(root, 'escaped'), 'dir')
|
|
283
|
+
await writeFile(
|
|
284
|
+
path.join(root, 'components.json'),
|
|
285
|
+
JSON.stringify({ tailwind: { css: 'escaped/styles.css' } }),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
await expect(
|
|
289
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
290
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
291
|
+
expect(await Bun.file(path.join(outside, 'styles.css')).exists()).toBe(false)
|
|
292
|
+
|
|
293
|
+
const automaticRoot = await fixture()
|
|
294
|
+
await rm(path.join(automaticRoot, 'src/index.css'))
|
|
295
|
+
const outsideCss = await readFile(path.join(outside, 'src/index.css'), 'utf8')
|
|
296
|
+
await symlink(outside, path.join(automaticRoot, 'frontend'), 'dir')
|
|
297
|
+
await expect(
|
|
298
|
+
initUi(
|
|
299
|
+
{ path: automaticRoot, version: '0.3.0-beta.0', install: false },
|
|
300
|
+
{ fetcher: mockFetch() },
|
|
301
|
+
),
|
|
302
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
303
|
+
expect(await readFile(path.join(outside, 'src/index.css'), 'utf8')).toBe(outsideCss)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('dry-run reports every mutation and writes nothing', async () => {
|
|
307
|
+
const root = await fixture()
|
|
308
|
+
const before = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
309
|
+
const result = await initUi(
|
|
310
|
+
{ path: root, version: '0.3.0-beta.0', dryRun: true },
|
|
311
|
+
{ fetcher: mockFetch() },
|
|
312
|
+
)
|
|
313
|
+
expect(result.status).toBe('planned')
|
|
314
|
+
expect(result.files).toEqual(
|
|
315
|
+
expect.arrayContaining([
|
|
316
|
+
'package.json',
|
|
317
|
+
'src/index.css',
|
|
318
|
+
'components.json',
|
|
319
|
+
'astrale-ui.lock.json',
|
|
320
|
+
]),
|
|
321
|
+
)
|
|
322
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(before)
|
|
323
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
/** @evidence TEST-CLI-UI-LOCK-AFTER-SUCCESS */
|
|
327
|
+
test('restores all files and leaves no lock when dependency installation fails', async () => {
|
|
328
|
+
const root = await fixture()
|
|
329
|
+
const packageBefore = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
330
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
331
|
+
await expect(
|
|
332
|
+
initUi(
|
|
333
|
+
{ path: root, version: '0.3.0-beta.0' },
|
|
334
|
+
{
|
|
335
|
+
fetcher: mockFetch(),
|
|
336
|
+
runner: async () => ({ code: 1, stdout: '', stderr: 'registry unavailable' }),
|
|
337
|
+
},
|
|
338
|
+
),
|
|
339
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
340
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(packageBefore)
|
|
341
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
342
|
+
expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
|
|
343
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
344
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('writes Base Nova config and advances the lock only after configuration succeeds', async () => {
|
|
348
|
+
const root = await fixture()
|
|
349
|
+
await initUi(
|
|
350
|
+
{ path: root, version: '0.3.0-beta.0', preset: 'compact', install: false },
|
|
351
|
+
{ fetcher: mockFetch() },
|
|
352
|
+
)
|
|
353
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
354
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
355
|
+
expect(components.style).toBe('base-nova')
|
|
356
|
+
expect(written.tooling).toMatchObject({ base: 'base', style: 'nova', baseUi: '1.7.0' })
|
|
357
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
358
|
+
'@astrale-os/ui/presets/compact.css',
|
|
359
|
+
)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('repeated exact init performs no release fetch while requested drift rejects', async () => {
|
|
363
|
+
const root = await fixture()
|
|
364
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
365
|
+
await writeFile(
|
|
366
|
+
path.join(root, 'components.json'),
|
|
367
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
368
|
+
)
|
|
369
|
+
await writeFile(
|
|
370
|
+
path.join(root, 'src/index.css'),
|
|
371
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
372
|
+
)
|
|
373
|
+
let fetched = false
|
|
374
|
+
const result = await initUi(
|
|
375
|
+
{ path: root, preset: 'astrale' },
|
|
376
|
+
{
|
|
377
|
+
fetcher: (async () => {
|
|
378
|
+
fetched = true
|
|
379
|
+
throw new Error('must not fetch')
|
|
380
|
+
}) as unknown as typeof fetch,
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
expect(result.status).toBe('unchanged')
|
|
384
|
+
expect(fetched).toBe(false)
|
|
385
|
+
await expect(initUi({ path: root, preset: 'compact' })).rejects.toMatchObject({
|
|
386
|
+
code: 'UI_ITEM_CONFLICT',
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
describe('UI source operations', () => {
|
|
392
|
+
test('add rejects an empty programmatic request before project discovery or tool execution', async () => {
|
|
393
|
+
await expect(addUi([], {})).rejects.toMatchObject({ code: 'UI_ITEM_NOT_FOUND' })
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('rejects hostile lock file records before an operation can escape the project', () => {
|
|
397
|
+
for (const items of [
|
|
398
|
+
[],
|
|
399
|
+
{
|
|
400
|
+
'pattern/chart/line/basic': {
|
|
401
|
+
address: 'pattern/chart/line/basic',
|
|
402
|
+
sourceDigest: 'bad',
|
|
403
|
+
files: {},
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
'pattern/chart/line/basic': {
|
|
408
|
+
address: 'pattern/chart/line/basic',
|
|
409
|
+
sourceDigest: 'b'.repeat(64),
|
|
410
|
+
files: { '../../outside': 'c'.repeat(64) },
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
]) {
|
|
414
|
+
expect(() => parseUiLock({ ...lock(), items })).toThrow(UiError)
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('add dry-run invokes the exact shadcn version and does not advance the lock', async () => {
|
|
419
|
+
const root = await fixture()
|
|
420
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
421
|
+
const before = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
422
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
423
|
+
const result = await addUi(
|
|
424
|
+
['pattern/chart/line/basic'],
|
|
425
|
+
{ project: root, dryRun: true },
|
|
426
|
+
{
|
|
427
|
+
fetcher: mockFetch(),
|
|
428
|
+
runner: async (file, args) => {
|
|
429
|
+
calls.push({ file, args })
|
|
430
|
+
return { code: 0, stdout: 'planned', stderr: '' }
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
expect(result.status).toBe('planned')
|
|
435
|
+
expect(result.sources).toEqual([
|
|
436
|
+
{
|
|
437
|
+
address: 'pattern/chart/line/basic',
|
|
438
|
+
dependencies: [],
|
|
439
|
+
files: ['components/astrale/pattern/chart/line-basic.tsx'],
|
|
440
|
+
},
|
|
441
|
+
])
|
|
442
|
+
expect(calls[0]).toMatchObject({ file: 'pnpm' })
|
|
443
|
+
expect(calls[0]?.args).toEqual(expect.arrayContaining(['dlx', 'shadcn@4.18.0', '--dry-run']))
|
|
444
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('successful add records installed file digests and doctor detects later edits', async () => {
|
|
448
|
+
const root = await fixture()
|
|
449
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
450
|
+
await writeFile(
|
|
451
|
+
path.join(root, 'components.json'),
|
|
452
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
453
|
+
)
|
|
454
|
+
await writeFile(
|
|
455
|
+
path.join(root, 'package.json'),
|
|
456
|
+
JSON.stringify({
|
|
457
|
+
name: 'fixture',
|
|
458
|
+
dependencies: {
|
|
459
|
+
react: '19.2.8',
|
|
460
|
+
'react-dom': '19.2.8',
|
|
461
|
+
tailwindcss: '4.3.3',
|
|
462
|
+
'@astrale-os/ui': '0.3.0-beta.0',
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
)
|
|
466
|
+
await writeFile(
|
|
467
|
+
path.join(root, 'src/index.css'),
|
|
468
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
469
|
+
)
|
|
470
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
471
|
+
await addUi(
|
|
472
|
+
['pattern/chart/line/basic'],
|
|
473
|
+
{ project: root, yes: true },
|
|
474
|
+
{
|
|
475
|
+
fetcher: mockFetch(),
|
|
476
|
+
runner: async () => {
|
|
477
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
478
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
479
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
)
|
|
483
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
484
|
+
expect(written.items['pattern/chart/line/basic'].files).toEqual({
|
|
485
|
+
'components/astrale/pattern/chart/line-basic.tsx': digest('export const Chart = true\n'),
|
|
486
|
+
})
|
|
487
|
+
expect(written.items['pattern/chart/line/basic'].sourceDigest).toBe(
|
|
488
|
+
digest(JSON.stringify(builtItem(registry.items[0]!))),
|
|
489
|
+
)
|
|
490
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
491
|
+
await writeFile(installed, 'consumer edit\n')
|
|
492
|
+
expect((await doctorUi(root)).healthy).toBe(false)
|
|
493
|
+
await expect(
|
|
494
|
+
addUi(
|
|
495
|
+
['pattern/chart/line/basic'],
|
|
496
|
+
{ project: root },
|
|
497
|
+
{ fetcher: mockFetch(), runner: async () => ({ code: 0, stdout: '', stderr: '' }) },
|
|
498
|
+
),
|
|
499
|
+
).rejects.toBeInstanceOf(UiError)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
test('preflights symlink targets without invoking shadcn', async () => {
|
|
503
|
+
const root = await fixture()
|
|
504
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
505
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-outside-'))
|
|
506
|
+
temporary.push(outside)
|
|
507
|
+
await symlink(outside, path.join(root, 'components'))
|
|
508
|
+
let invoked = false
|
|
509
|
+
await expect(
|
|
510
|
+
addUi(
|
|
511
|
+
['pattern/chart/line/basic'],
|
|
512
|
+
{ project: root },
|
|
513
|
+
{
|
|
514
|
+
fetcher: mockFetch(),
|
|
515
|
+
runner: async () => {
|
|
516
|
+
invoked = true
|
|
517
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
),
|
|
521
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
522
|
+
expect(invoked).toBe(false)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
test('restores declared files and package state after a partial shadcn failure', async () => {
|
|
526
|
+
const root = await fixture()
|
|
527
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
528
|
+
const first = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
529
|
+
const second = path.join(root, 'components/astrale/pattern/chart/summary.tsx')
|
|
530
|
+
await mkdir(path.dirname(first), { recursive: true })
|
|
531
|
+
await writeFile(first, 'consumer original\n')
|
|
532
|
+
const twoFileRegistry: UiRegistry = {
|
|
533
|
+
...registry,
|
|
534
|
+
items: [
|
|
535
|
+
{
|
|
536
|
+
...registry.items[0]!,
|
|
537
|
+
files: [
|
|
538
|
+
registry.items[0]!.files[0]!,
|
|
539
|
+
{
|
|
540
|
+
path: 'registry/patterns/chart/summary.tsx',
|
|
541
|
+
type: 'registry:component',
|
|
542
|
+
target: 'components/astrale/pattern/chart/summary.tsx',
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
}
|
|
548
|
+
const lockBefore = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
549
|
+
await expect(
|
|
550
|
+
addUi(
|
|
551
|
+
['pattern/chart/line/basic'],
|
|
552
|
+
{ project: root },
|
|
553
|
+
{
|
|
554
|
+
fetcher: mockFetch([], twoFileRegistry),
|
|
555
|
+
runner: async () => {
|
|
556
|
+
await writeFile(first, 'partial overwrite\n')
|
|
557
|
+
await writeFile(second, 'partial create\n')
|
|
558
|
+
return { code: 1, stdout: '', stderr: 'interrupted' }
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
),
|
|
562
|
+
).rejects.toMatchObject({ code: 'UI_TOOL_FAILED' })
|
|
563
|
+
expect(await readFile(first, 'utf8')).toBe('consumer original\n')
|
|
564
|
+
expect(await Bun.file(second).exists()).toBe(false)
|
|
565
|
+
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(lockBefore)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
test('overwrite requires explicit yes confirmation before invoking shadcn', async () => {
|
|
569
|
+
const root = await fixture()
|
|
570
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
571
|
+
let invoked = false
|
|
572
|
+
await expect(
|
|
573
|
+
addUi(
|
|
574
|
+
['pattern/chart/line/basic'],
|
|
575
|
+
{ project: root, overwrite: true },
|
|
576
|
+
{
|
|
577
|
+
fetcher: mockFetch(),
|
|
578
|
+
runner: async () => {
|
|
579
|
+
invoked = true
|
|
580
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
),
|
|
584
|
+
).rejects.toMatchObject({ code: 'UI_LOCAL_CHANGES' })
|
|
585
|
+
expect(invoked).toBe(false)
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
/** @evidence TEST-CLI-UI-EXACT-ITEM-SOURCE */
|
|
589
|
+
test('rejects a built item that differs from the admitted release index before invoking shadcn', async () => {
|
|
590
|
+
const root = await fixture()
|
|
591
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
592
|
+
const fallback = mockFetch()
|
|
593
|
+
let invoked = false
|
|
594
|
+
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
595
|
+
const url = String(input)
|
|
596
|
+
if (url.endsWith('/registry/public/r/pattern-chart-line-basic.json')) {
|
|
597
|
+
return Response.json(registry.items[0])
|
|
598
|
+
}
|
|
599
|
+
return fallback(input, init)
|
|
600
|
+
}) as typeof fetch
|
|
601
|
+
await expect(
|
|
602
|
+
addUi(
|
|
603
|
+
['pattern/chart/line/basic'],
|
|
604
|
+
{ project: root },
|
|
605
|
+
{
|
|
606
|
+
fetcher: malformed,
|
|
607
|
+
runner: async () => {
|
|
608
|
+
invoked = true
|
|
609
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
),
|
|
613
|
+
).rejects.toMatchObject({ code: 'UI_REGISTRY_UNAVAILABLE' })
|
|
614
|
+
expect(invoked).toBe(false)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
test('preset dry-run is read-only and apply changes CSS plus lock without source rewrites', async () => {
|
|
618
|
+
const root = await fixture()
|
|
619
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
620
|
+
await writeFile(
|
|
621
|
+
path.join(root, 'src/index.css'),
|
|
622
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
623
|
+
)
|
|
624
|
+
const before = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
625
|
+
await applyPreset('expressive', { project: root, dryRun: true })
|
|
626
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(before)
|
|
627
|
+
await applyPreset('expressive', { project: root })
|
|
628
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
629
|
+
'@astrale-os/ui/presets/expressive.css',
|
|
630
|
+
)
|
|
631
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
632
|
+
expect(written.preset).toBe('expressive')
|
|
633
|
+
})
|
|
634
|
+
})
|
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'
|