@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,146 @@
|
|
|
1
|
+
import { access, lstat, readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { UiError, type PackageManager } from './model'
|
|
5
|
+
|
|
6
|
+
export type UiProject = {
|
|
7
|
+
root: string
|
|
8
|
+
packageJsonPath: string
|
|
9
|
+
packageJson: Record<string, unknown>
|
|
10
|
+
manager: PackageManager
|
|
11
|
+
lockPath?: string
|
|
12
|
+
cssPath: string
|
|
13
|
+
componentsPath: string
|
|
14
|
+
uiLockPath: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MANAGERS: readonly [string, PackageManager][] = [
|
|
18
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
19
|
+
['bun.lock', 'bun'],
|
|
20
|
+
['bun.lockb', 'bun'],
|
|
21
|
+
['yarn.lock', 'yarn'],
|
|
22
|
+
['package-lock.json', 'npm'],
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
async function exists(target: string): Promise<boolean> {
|
|
26
|
+
return access(target).then(
|
|
27
|
+
() => true,
|
|
28
|
+
() => false,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function discoverUiProject(input = process.cwd()): Promise<UiProject> {
|
|
33
|
+
let root = path.resolve(input)
|
|
34
|
+
if (!(await exists(root))) {
|
|
35
|
+
throw new UiError('UI_PROJECT_UNSUPPORTED', 'Project path does not exist: ' + root)
|
|
36
|
+
}
|
|
37
|
+
if (!(await lstat(root)).isDirectory()) root = path.dirname(root)
|
|
38
|
+
|
|
39
|
+
while (!(await exists(path.join(root, 'package.json')))) {
|
|
40
|
+
const parent = path.dirname(root)
|
|
41
|
+
if (parent === root) {
|
|
42
|
+
throw new UiError(
|
|
43
|
+
'UI_PROJECT_UNSUPPORTED',
|
|
44
|
+
'No package.json found from ' + path.resolve(input),
|
|
45
|
+
'Run the command inside an existing React application or pass its path.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
root = parent
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const packageJsonPath = path.join(root, 'package.json')
|
|
52
|
+
let packageJson: Record<string, unknown>
|
|
53
|
+
try {
|
|
54
|
+
packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as Record<string, unknown>
|
|
55
|
+
} catch (cause) {
|
|
56
|
+
throw new UiError('UI_PROJECT_UNSUPPORTED', 'package.json is not valid JSON.', undefined, {
|
|
57
|
+
cause,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let manager: PackageManager = 'npm'
|
|
62
|
+
let lockPath: string | undefined
|
|
63
|
+
for (const [file, candidate] of MANAGERS) {
|
|
64
|
+
const target = path.join(root, file)
|
|
65
|
+
if (await exists(target)) {
|
|
66
|
+
manager = candidate
|
|
67
|
+
lockPath = target
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const declared = packageJson.packageManager
|
|
72
|
+
if (!lockPath && typeof declared === 'string') {
|
|
73
|
+
const candidate = declared.split('@')[0]
|
|
74
|
+
if (
|
|
75
|
+
candidate === 'pnpm' ||
|
|
76
|
+
candidate === 'npm' ||
|
|
77
|
+
candidate === 'yarn' ||
|
|
78
|
+
candidate === 'bun'
|
|
79
|
+
) {
|
|
80
|
+
manager = candidate
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!lockPath) {
|
|
84
|
+
const expectedLock = {
|
|
85
|
+
pnpm: 'pnpm-lock.yaml',
|
|
86
|
+
npm: 'package-lock.json',
|
|
87
|
+
yarn: 'yarn.lock',
|
|
88
|
+
bun: 'bun.lock',
|
|
89
|
+
}[manager]
|
|
90
|
+
lockPath = path.join(root, expectedLock)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cssCandidates = ['src/index.css', 'src/app.css', 'app/globals.css', 'src/styles.css']
|
|
94
|
+
const resolvedCss = await Promise.all(
|
|
95
|
+
cssCandidates.map(async (file) => ((await exists(path.join(root, file))) ? file : undefined)),
|
|
96
|
+
)
|
|
97
|
+
const cssRelative = resolvedCss.find(Boolean) ?? 'src/astrale-ui.css'
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
root,
|
|
101
|
+
packageJsonPath,
|
|
102
|
+
packageJson,
|
|
103
|
+
manager,
|
|
104
|
+
lockPath,
|
|
105
|
+
cssPath: path.join(root, cssRelative),
|
|
106
|
+
componentsPath: path.join(root, 'components.json'),
|
|
107
|
+
uiLockPath: path.join(root, 'astrale-ui.lock.json'),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function assertSupportedUiProject(project: UiProject): void {
|
|
112
|
+
const manifest = project.packageJson
|
|
113
|
+
const dependencies = {
|
|
114
|
+
...(manifest.dependencies as Record<string, string> | undefined),
|
|
115
|
+
...(manifest.devDependencies as Record<string, string> | undefined),
|
|
116
|
+
...(manifest.peerDependencies as Record<string, string> | undefined),
|
|
117
|
+
}
|
|
118
|
+
if (!dependencies.react || !dependencies['react-dom']) {
|
|
119
|
+
throw new UiError(
|
|
120
|
+
'UI_PROJECT_UNSUPPORTED',
|
|
121
|
+
'Astrale UI requires an existing React application.',
|
|
122
|
+
'Install React first or choose a non-UI project scaffold.',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
const tailwind = dependencies.tailwindcss
|
|
126
|
+
if (!tailwind || !/(?:^|[^0-9])4(?:\.|$)/u.test(tailwind)) {
|
|
127
|
+
throw new UiError(
|
|
128
|
+
'UI_PROJECT_UNSUPPORTED',
|
|
129
|
+
'Astrale UI V1 requires Tailwind CSS 4.',
|
|
130
|
+
'Upgrade Tailwind to v4 before initializing Astrale UI.',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function projectRelative(project: UiProject, target: string): string {
|
|
136
|
+
const relative = path.relative(project.root, target)
|
|
137
|
+
if (
|
|
138
|
+
relative === '' ||
|
|
139
|
+
relative === '..' ||
|
|
140
|
+
relative.startsWith('..' + path.sep) ||
|
|
141
|
+
path.isAbsolute(relative)
|
|
142
|
+
) {
|
|
143
|
+
throw new UiError('UI_LOCK_INVALID', 'Path escapes the project root: ' + target)
|
|
144
|
+
}
|
|
145
|
+
return relative.split(path.sep).join('/')
|
|
146
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { UiError, type UiCompatibility, type UiRegistry, type UiRelease } from './model'
|
|
2
|
+
|
|
3
|
+
const NPM_PACKAGE = 'https://registry.npmjs.org/@astrale-os/ui'
|
|
4
|
+
const GITHUB_API = 'https://api.github.com/repos/astrale-os/ui'
|
|
5
|
+
const RAW = 'https://raw.githubusercontent.com/astrale-os/ui'
|
|
6
|
+
const MAX_DOCUMENT_BYTES = 1_048_576
|
|
7
|
+
const MAX_REGISTRY_DOCUMENTS = 100
|
|
8
|
+
|
|
9
|
+
type Fetch = typeof fetch
|
|
10
|
+
type RegistrySource = {
|
|
11
|
+
name?: string
|
|
12
|
+
homepage?: string
|
|
13
|
+
include?: string[]
|
|
14
|
+
items?: unknown[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function json<T>(fetcher: Fetch, url: string, label: string): Promise<T> {
|
|
18
|
+
let response: Response
|
|
19
|
+
try {
|
|
20
|
+
response = await fetcher(url, { headers: { accept: 'application/json' } })
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Unable to reach ' + label + '.', undefined, {
|
|
23
|
+
cause,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' returned HTTP ' + response.status + '.')
|
|
28
|
+
}
|
|
29
|
+
const declaredLength = Number(response.headers.get('content-length'))
|
|
30
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_DOCUMENT_BYTES) {
|
|
31
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' exceeds the supported response size.')
|
|
32
|
+
}
|
|
33
|
+
if (!response.body) {
|
|
34
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' returned an empty response.')
|
|
35
|
+
}
|
|
36
|
+
const reader = response.body.getReader()
|
|
37
|
+
const chunks: Uint8Array[] = []
|
|
38
|
+
let bytes = 0
|
|
39
|
+
while (true) {
|
|
40
|
+
const result = await reader.read()
|
|
41
|
+
if (result.done) break
|
|
42
|
+
bytes += result.value.byteLength
|
|
43
|
+
if (bytes > MAX_DOCUMENT_BYTES) {
|
|
44
|
+
await reader.cancel()
|
|
45
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' exceeds the supported response size.')
|
|
46
|
+
}
|
|
47
|
+
chunks.push(result.value)
|
|
48
|
+
}
|
|
49
|
+
const body = new Uint8Array(bytes)
|
|
50
|
+
let offset = 0
|
|
51
|
+
for (const chunk of chunks) {
|
|
52
|
+
body.set(chunk, offset)
|
|
53
|
+
offset += chunk.byteLength
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(new TextDecoder().decode(body)) as T
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', label + ' returned malformed JSON.', undefined, {
|
|
59
|
+
cause,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function resolveUiRelease(
|
|
65
|
+
requested?: string,
|
|
66
|
+
fetcher: Fetch = fetch,
|
|
67
|
+
): Promise<UiRelease> {
|
|
68
|
+
const versionDocument = requested
|
|
69
|
+
? { version: requested.replace(/^v/u, '') }
|
|
70
|
+
: await json<{ version: string }>(fetcher, NPM_PACKAGE + '/latest', 'npm UI release')
|
|
71
|
+
const version = versionDocument.version
|
|
72
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
|
|
73
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Invalid UI release version: ' + version)
|
|
74
|
+
}
|
|
75
|
+
const ref = 'v' + version
|
|
76
|
+
const reference = await json<{
|
|
77
|
+
object: { type: 'commit' | 'tag'; sha: string; url: string }
|
|
78
|
+
}>(fetcher, GITHUB_API + '/git/ref/tags/' + encodeURIComponent(ref), 'UI ref ' + ref)
|
|
79
|
+
const commit =
|
|
80
|
+
reference.object.type === 'commit'
|
|
81
|
+
? reference.object.sha
|
|
82
|
+
: (
|
|
83
|
+
await json<{ object: { sha: string } }>(
|
|
84
|
+
fetcher,
|
|
85
|
+
reference.object.url,
|
|
86
|
+
'annotated UI tag ' + ref,
|
|
87
|
+
)
|
|
88
|
+
).object.sha
|
|
89
|
+
if (!/^[0-9a-f]{40}$/u.test(commit)) {
|
|
90
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI ref ' + ref + ' did not resolve to a commit.')
|
|
91
|
+
}
|
|
92
|
+
return readUiReleaseSnapshot({ version, ref, commit }, fetcher)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function readUiReleaseSnapshot(
|
|
96
|
+
identity: Pick<UiRelease, 'version' | 'ref' | 'commit'>,
|
|
97
|
+
fetcher: Fetch = fetch,
|
|
98
|
+
): Promise<UiRelease> {
|
|
99
|
+
const [compatibility, registry] = await Promise.all([
|
|
100
|
+
json<UiCompatibility>(
|
|
101
|
+
fetcher,
|
|
102
|
+
RAW + '/' + identity.commit + '/tooling/compatibility.json',
|
|
103
|
+
'UI compatibility metadata',
|
|
104
|
+
),
|
|
105
|
+
readRegistry(identity.commit, fetcher),
|
|
106
|
+
])
|
|
107
|
+
if (
|
|
108
|
+
compatibility.version !== 1 ||
|
|
109
|
+
compatibility.base !== 'base' ||
|
|
110
|
+
compatibility.style !== 'nova' ||
|
|
111
|
+
!/^\d+\.\d+\.\d+$/u.test(compatibility.shadcn)
|
|
112
|
+
) {
|
|
113
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI compatibility metadata is invalid.')
|
|
114
|
+
}
|
|
115
|
+
return { ...identity, compatibility, registry }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readRegistry(commit: string, fetcher: Fetch): Promise<UiRegistry> {
|
|
119
|
+
const candidate = RAW + '/' + commit + '/registry.json'
|
|
120
|
+
const root = await json<RegistrySource>(fetcher, candidate, 'UI registry')
|
|
121
|
+
const items = await resolveRegistryItems(root, candidate, commit, fetcher, new Set([candidate]))
|
|
122
|
+
const publicItems = items.filter(
|
|
123
|
+
(item) =>
|
|
124
|
+
!(item && typeof item === 'object' && (item as { type?: unknown }).type === 'registry:base'),
|
|
125
|
+
)
|
|
126
|
+
if (!publicItems.every(isInstallableItem)) {
|
|
127
|
+
throw new UiError(
|
|
128
|
+
'UI_REGISTRY_UNAVAILABLE',
|
|
129
|
+
'UI registry contains an invalid installable item.',
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
const installable = publicItems
|
|
133
|
+
if (installable.length === 0) {
|
|
134
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry has no installable items.')
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
new Set(installable.map((item) => item.name)).size !== installable.length ||
|
|
138
|
+
new Set(installable.map((item) => item.meta.canonicalAddress)).size !== installable.length
|
|
139
|
+
) {
|
|
140
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry contains duplicate item identities.')
|
|
141
|
+
}
|
|
142
|
+
return { name: root.name ?? 'astrale-ui', homepage: root.homepage, items: installable }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveRegistryItems(
|
|
146
|
+
source: RegistrySource,
|
|
147
|
+
sourceUrl: string,
|
|
148
|
+
commit: string,
|
|
149
|
+
fetcher: Fetch,
|
|
150
|
+
visited: Set<string>,
|
|
151
|
+
): Promise<unknown[]> {
|
|
152
|
+
const direct = Array.isArray(source.items) ? source.items : []
|
|
153
|
+
const includes = source.include ?? []
|
|
154
|
+
if (!Array.isArray(includes)) {
|
|
155
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry include must be an array.')
|
|
156
|
+
}
|
|
157
|
+
if (visited.size + includes.length > MAX_REGISTRY_DOCUMENTS) {
|
|
158
|
+
throw new UiError(
|
|
159
|
+
'UI_REGISTRY_UNAVAILABLE',
|
|
160
|
+
'UI registry contains too many included documents.',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
const nested = await Promise.all(
|
|
164
|
+
includes.map(async (include) => {
|
|
165
|
+
if (
|
|
166
|
+
typeof include !== 'string' ||
|
|
167
|
+
include.startsWith('/') ||
|
|
168
|
+
!include.endsWith('registry.json') ||
|
|
169
|
+
include.split('/').includes('..')
|
|
170
|
+
) {
|
|
171
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry contains an unsafe include.')
|
|
172
|
+
}
|
|
173
|
+
const url = new URL(include, sourceUrl).toString()
|
|
174
|
+
const releaseRoot = RAW + '/' + commit + '/'
|
|
175
|
+
if (!url.startsWith(releaseRoot) || visited.has(url)) {
|
|
176
|
+
throw new UiError(
|
|
177
|
+
'UI_REGISTRY_UNAVAILABLE',
|
|
178
|
+
'UI registry include escaped or repeated the release snapshot.',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
visited.add(url)
|
|
182
|
+
const child = await json<RegistrySource>(fetcher, url, 'UI registry include')
|
|
183
|
+
return resolveRegistryItems(child, url, commit, fetcher, visited)
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
return direct.concat(nested.flat())
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
|
|
190
|
+
if (!item || typeof item !== 'object') return false
|
|
191
|
+
const candidate = item as Partial<UiRegistry['items'][number]>
|
|
192
|
+
return (
|
|
193
|
+
typeof candidate.name === 'string' &&
|
|
194
|
+
/^(?:pattern|block)-[a-z0-9-]+$/u.test(candidate.name) &&
|
|
195
|
+
candidate.type === 'registry:block' &&
|
|
196
|
+
(candidate.dependencies === undefined ||
|
|
197
|
+
(Array.isArray(candidate.dependencies) &&
|
|
198
|
+
candidate.dependencies.every(
|
|
199
|
+
(dependency) =>
|
|
200
|
+
typeof dependency === 'string' &&
|
|
201
|
+
/^(?:@[a-z0-9-]+\/)?[a-z0-9-]+@[~^<>=0-9A-Za-z.* -]+$/u.test(dependency),
|
|
202
|
+
))) &&
|
|
203
|
+
Array.isArray(candidate.files) &&
|
|
204
|
+
candidate.files.length > 0 &&
|
|
205
|
+
candidate.files.every(
|
|
206
|
+
(file) =>
|
|
207
|
+
file &&
|
|
208
|
+
typeof file.path === 'string' &&
|
|
209
|
+
isSafeRelative(file.path) &&
|
|
210
|
+
typeof file.type === 'string' &&
|
|
211
|
+
typeof file.target === 'string' &&
|
|
212
|
+
file.target.startsWith('components/astrale/') &&
|
|
213
|
+
isSafeRelative(file.target),
|
|
214
|
+
) &&
|
|
215
|
+
typeof candidate.meta?.canonicalAddress === 'string' &&
|
|
216
|
+
/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(candidate.meta.canonicalAddress)
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function readUiRegistryItem(
|
|
221
|
+
release: UiRelease,
|
|
222
|
+
expected: UiRegistry['items'][number],
|
|
223
|
+
fetcher: Fetch = fetch,
|
|
224
|
+
): Promise<UiRegistry['items'][number]> {
|
|
225
|
+
const item = await json<unknown>(
|
|
226
|
+
fetcher,
|
|
227
|
+
registryItemUrl(release, expected.name),
|
|
228
|
+
'UI registry item ' + expected.name,
|
|
229
|
+
)
|
|
230
|
+
if (
|
|
231
|
+
!isInstallableItem(item) ||
|
|
232
|
+
item.name !== expected.name ||
|
|
233
|
+
item.meta.canonicalAddress !== expected.meta.canonicalAddress ||
|
|
234
|
+
item.files.length !== expected.files.length ||
|
|
235
|
+
item.files.some((file, index) => {
|
|
236
|
+
const declared = expected.files[index]
|
|
237
|
+
return (
|
|
238
|
+
typeof file.content !== 'string' ||
|
|
239
|
+
file.content.length === 0 ||
|
|
240
|
+
!declared ||
|
|
241
|
+
file.path !== declared.path ||
|
|
242
|
+
file.type !== declared.type ||
|
|
243
|
+
file.target !== declared.target
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
) {
|
|
247
|
+
throw new UiError(
|
|
248
|
+
'UI_REGISTRY_UNAVAILABLE',
|
|
249
|
+
'UI registry item ' + expected.name + ' does not match the admitted release index.',
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
return item
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isSafeRelative(value: string): boolean {
|
|
256
|
+
return (
|
|
257
|
+
value.length > 0 &&
|
|
258
|
+
!value.startsWith('/') &&
|
|
259
|
+
!/^[A-Za-z]:[\\/]/u.test(value) &&
|
|
260
|
+
!value.includes('\\') &&
|
|
261
|
+
!value.split('/').includes('..')
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function registryItemUrl(release: UiRelease, itemName: string): string {
|
|
266
|
+
return RAW + '/' + release.commit + '/registry/public/r/' + encodeURIComponent(itemName) + '.json'
|
|
267
|
+
}
|
package/src/ui/runner.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PackageManager } from './model'
|
|
2
|
+
|
|
3
|
+
import { run, type RunResult } from '../lib/proc'
|
|
4
|
+
|
|
5
|
+
export type UiRunner = (file: string, args: string[], cwd: string) => Promise<RunResult>
|
|
6
|
+
|
|
7
|
+
export const defaultUiRunner: UiRunner = (file, args, cwd) => run(file, args, { cwd })
|
|
8
|
+
|
|
9
|
+
export function shadcnInvocation(
|
|
10
|
+
manager: PackageManager,
|
|
11
|
+
version: string,
|
|
12
|
+
args: string[],
|
|
13
|
+
): { file: string; args: string[] } {
|
|
14
|
+
if (manager === 'pnpm') return { file: 'pnpm', args: ['dlx', 'shadcn@' + version, ...args] }
|
|
15
|
+
if (manager === 'bun') return { file: 'bunx', args: ['shadcn@' + version, ...args] }
|
|
16
|
+
if (manager === 'yarn') return { file: 'yarn', args: ['dlx', 'shadcn@' + version, ...args] }
|
|
17
|
+
return { file: 'npx', args: ['--yes', 'shadcn@' + version, ...args] }
|
|
18
|
+
}
|
|
@@ -79,7 +79,7 @@ test('mints with the exact audience and instance, caches, and coalesces concurre
|
|
|
79
79
|
expect(await broker.acquireGatewayToken(config, audience)).toBe(token)
|
|
80
80
|
expect(calls).toEqual([
|
|
81
81
|
{
|
|
82
|
-
args: ['token', '--audience', audience, '--ttl', '
|
|
82
|
+
args: ['token', '--audience', audience, '--ttl', '240', '--raw', '-i', 'prod'],
|
|
83
83
|
timeoutMs: 12_000,
|
|
84
84
|
},
|
|
85
85
|
])
|
|
@@ -12,8 +12,8 @@ import type { HarnessGatewayConfig } from '../../../../shared/types'
|
|
|
12
12
|
|
|
13
13
|
import { captureCommand, type CapturedCommand, type CaptureOptions } from '../process'
|
|
14
14
|
|
|
15
|
-
const MINT_TTL_SECONDS =
|
|
16
|
-
const REFRESH_SKEW_MS =
|
|
15
|
+
const MINT_TTL_SECONDS = 4 * 60
|
|
16
|
+
const REFRESH_SKEW_MS = 60_000
|
|
17
17
|
|
|
18
18
|
interface CachedToken {
|
|
19
19
|
token: string
|
|
@@ -141,7 +141,7 @@ export class HarnessTokenBroker {
|
|
|
141
141
|
(claims.expiresAtMs !== undefined && claims.expiresAtMs <= this.now())
|
|
142
142
|
)
|
|
143
143
|
throw new HarnessTokenError(
|
|
144
|
-
'could not mint a delegation token — is the instance reachable and are you signed in? (try `astrale login` / `astrale use <instance>`)',
|
|
144
|
+
'could not mint a delegation token — is the instance reachable and are you signed in? (try `astrale auth login` / `astrale instance use <instance>`)',
|
|
145
145
|
'mint-failed',
|
|
146
146
|
)
|
|
147
147
|
this.mintCache.set(key, {
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { Input } from '@astrale-os/sdk/client';
|
|
2
|
-
import type { ClientSession } from '@astrale-os/sdk/client/session';
|
|
3
|
-
import type { ResolvedClass, ResolvedProperty } from '@astrale-os/sdk/schema';
|
|
4
|
-
import type { DomainBinding } from '@astrale-os/shell';
|
|
5
|
-
import { Path } from '@astrale-os/sdk/graph/path';
|
|
6
|
-
export type AdminBinding = DomainBinding;
|
|
7
|
-
/** Bind the exact Admin revision installed on this source Kernel. */
|
|
8
|
-
export declare function bindAdmin(session: ClientSession): Promise<AdminBinding>;
|
|
9
|
-
/** Admit an injected dynamic binding as the Admin Domain. */
|
|
10
|
-
export declare function requireAdminBinding(binding: AdminBinding): AdminBinding;
|
|
11
|
-
/** Resolve one required Admin Class without rebuilding its schema contract. */
|
|
12
|
-
export declare function requireAdminClass(binding: AdminBinding, name: string, kind: 'node'): ResolvedClass<'node'>;
|
|
13
|
-
export declare function requireAdminClass(binding: AdminBinding, name: string, kind: 'edge'): ResolvedClass<'edge'>;
|
|
14
|
-
/** Resolve one required Admin Core Node as its canonical projection Path. */
|
|
15
|
-
export declare function requireAdminCore(binding: AdminBinding, name: string): Path;
|
|
16
|
-
/** Resolve one effective Property by unambiguous member name. */
|
|
17
|
-
export declare function requireAdminProperty(owner: ResolvedClass, name: string): ResolvedProperty<unknown>;
|
|
18
|
-
/** Invoke one resolved executable instance Method through Kernel Client. */
|
|
19
|
-
export declare function invokeAdminMethod(session: ClientSession, binding: AdminBinding, owner: ResolvedClass<'node'>, name: string, receiver: Path, input: Input): Promise<unknown>;
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { ClientSession } from '@astrale-os/sdk/client/session'
|
|
2
|
-
|
|
3
|
-
import { bundle, defineSchema, schema as schemaApi } from '@astrale-os/sdk/schema'
|
|
4
|
-
import { expect, mock, test } from 'bun:test'
|
|
5
|
-
|
|
6
|
-
import { releaseFor } from '../../__tests__/fixtures/publication'
|
|
7
|
-
import { bindAdmin } from '../binding'
|
|
8
|
-
|
|
9
|
-
const schema = defineSchema('admin.astrale.ai', {})
|
|
10
|
-
const release = releaseFor(schema, 'https://admin.beta.astrale.ai')
|
|
11
|
-
|
|
12
|
-
test('binds the installed Admin Domain instead of the source Kernel publication', async () => {
|
|
13
|
-
const installation = mock(async () => ({
|
|
14
|
-
state: 'ready' as const,
|
|
15
|
-
target: 'sha256:admin-target' as const,
|
|
16
|
-
source: { kind: 'remote' as const, publication: release.publication },
|
|
17
|
-
bundle: bundle.create(schema),
|
|
18
|
-
readiness: 'sha256:admin-readiness' as const,
|
|
19
|
-
capabilities: { requested: [], materialized: [] },
|
|
20
|
-
}))
|
|
21
|
-
const session = {
|
|
22
|
-
installation,
|
|
23
|
-
bind: (domain: unknown) => ({ domain, graph: {} }),
|
|
24
|
-
} as unknown as ClientSession
|
|
25
|
-
|
|
26
|
-
const binding = await bindAdmin(session)
|
|
27
|
-
|
|
28
|
-
expect(installation).toHaveBeenCalledWith('admin.astrale.ai')
|
|
29
|
-
expect(binding.domain.origin).toBe('admin.astrale.ai')
|
|
30
|
-
expect(binding.domain.revision).toBe(schemaApi.revision(schema))
|
|
31
|
-
})
|
package/src/admin/binding.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import type { Input } from '@astrale-os/sdk/client'
|
|
2
|
-
import type { ClientSession } from '@astrale-os/sdk/client/session'
|
|
3
|
-
import type {
|
|
4
|
-
ResolvedClass,
|
|
5
|
-
ResolvedCoreDefinition,
|
|
6
|
-
ResolvedMethod,
|
|
7
|
-
ResolvedProperty,
|
|
8
|
-
} from '@astrale-os/sdk/schema'
|
|
9
|
-
import type { DomainBinding } from '@astrale-os/shell'
|
|
10
|
-
|
|
11
|
-
import { reference } from '@astrale-os/sdk/client/session'
|
|
12
|
-
import { Path } from '@astrale-os/sdk/graph/path'
|
|
13
|
-
import { bindDomain } from '@astrale-os/shell'
|
|
14
|
-
|
|
15
|
-
const ADMIN_ORIGIN = 'admin.astrale.ai'
|
|
16
|
-
|
|
17
|
-
export type AdminBinding = DomainBinding
|
|
18
|
-
|
|
19
|
-
/** Bind the exact Admin revision installed on this source Kernel. */
|
|
20
|
-
export async function bindAdmin(session: ClientSession): Promise<AdminBinding> {
|
|
21
|
-
const installed = await session.installation(ADMIN_ORIGIN)
|
|
22
|
-
return requireAdminBinding(await bindDomain(session, installed.bundle.root))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Admit an injected dynamic binding as the Admin Domain. */
|
|
26
|
-
export function requireAdminBinding(binding: AdminBinding): AdminBinding {
|
|
27
|
-
if (binding.domain.origin !== ADMIN_ORIGIN) {
|
|
28
|
-
throw new TypeError('Configured Admin target does not serve the Admin Domain.')
|
|
29
|
-
}
|
|
30
|
-
return binding
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Resolve one required Admin Class without rebuilding its schema contract. */
|
|
34
|
-
export function requireAdminClass(
|
|
35
|
-
binding: AdminBinding,
|
|
36
|
-
name: string,
|
|
37
|
-
kind: 'node',
|
|
38
|
-
): ResolvedClass<'node'>
|
|
39
|
-
export function requireAdminClass(
|
|
40
|
-
binding: AdminBinding,
|
|
41
|
-
name: string,
|
|
42
|
-
kind: 'edge',
|
|
43
|
-
): ResolvedClass<'edge'>
|
|
44
|
-
export function requireAdminClass(
|
|
45
|
-
binding: AdminBinding,
|
|
46
|
-
name: string,
|
|
47
|
-
kind: 'node' | 'edge',
|
|
48
|
-
): ResolvedClass<'node'> | ResolvedClass<'edge'> {
|
|
49
|
-
const selected = binding.domain.classes[name]
|
|
50
|
-
if (selected?.kind !== kind) throw new TypeError(`Admin ${name} is not a ${kind} Class.`)
|
|
51
|
-
return selected
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Resolve one required Admin Core Node as its canonical projection Path. */
|
|
55
|
-
export function requireAdminCore(binding: AdminBinding, name: string): Path {
|
|
56
|
-
const selected = binding.domain.core.nodes[name] as ResolvedCoreDefinition | undefined
|
|
57
|
-
if (selected === undefined) throw new TypeError(`Admin has no Core Node ${name}.`)
|
|
58
|
-
return Path.project(selected.ref)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Resolve one effective Property by unambiguous member name. */
|
|
62
|
-
export function requireAdminProperty(
|
|
63
|
-
owner: ResolvedClass,
|
|
64
|
-
name: string,
|
|
65
|
-
): ResolvedProperty<unknown> {
|
|
66
|
-
const selected = [...owner.properties].find((property) => property.name === name)
|
|
67
|
-
if (selected === undefined) {
|
|
68
|
-
throw new TypeError(`Admin ${owner.ref.name}.${name} Property is absent.`)
|
|
69
|
-
}
|
|
70
|
-
return selected
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Invoke one resolved executable instance Method through Kernel Client. */
|
|
74
|
-
export async function invokeAdminMethod(
|
|
75
|
-
session: ClientSession,
|
|
76
|
-
binding: AdminBinding,
|
|
77
|
-
owner: ResolvedClass<'node'>,
|
|
78
|
-
name: string,
|
|
79
|
-
receiver: Path,
|
|
80
|
-
input: Input,
|
|
81
|
-
): Promise<unknown> {
|
|
82
|
-
const method = [...owner.methods].find((candidate) => candidate.name === name)
|
|
83
|
-
if (method === undefined || !isExecutableInstanceMethod(method)) {
|
|
84
|
-
throw new TypeError(`Admin ${owner.ref.name}.${name} is not an executable instance Method.`)
|
|
85
|
-
}
|
|
86
|
-
return session.invoke(reference(binding.domain, method)(receiver), input)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
type ExecutableInstanceMethod = ResolvedMethod<
|
|
90
|
-
unknown,
|
|
91
|
-
unknown,
|
|
92
|
-
false,
|
|
93
|
-
Exclude<ResolvedMethod['inheritance'], 'abstract'>
|
|
94
|
-
>
|
|
95
|
-
|
|
96
|
-
function isExecutableInstanceMethod(method: ResolvedMethod): method is ExecutableInstanceMethod {
|
|
97
|
-
return method.static === false && method.inheritance !== 'abstract'
|
|
98
|
-
}
|