@astrale-os/cli 1.0.0-beta.14 → 1.0.0-beta.15
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
CHANGED
|
@@ -2578,7 +2578,7 @@ var package_default;
|
|
|
2578
2578
|
var init_package = __esm(() => {
|
|
2579
2579
|
package_default = {
|
|
2580
2580
|
name: "@astrale-os/cli",
|
|
2581
|
-
version: "1.0.0-beta.
|
|
2581
|
+
version: "1.0.0-beta.15",
|
|
2582
2582
|
description: "Astrale CLI — connect to existing Astrale kernels",
|
|
2583
2583
|
keywords: [
|
|
2584
2584
|
"astrale",
|
|
@@ -83655,7 +83655,10 @@ async function readRegistry(commit, fetcher) {
|
|
|
83655
83655
|
return { name: root.name ?? "astrale-ui", homepage: root.homepage, items: installable };
|
|
83656
83656
|
}
|
|
83657
83657
|
async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited) {
|
|
83658
|
-
const
|
|
83658
|
+
const releaseRoot = RAW + "/" + commit + "/";
|
|
83659
|
+
const sourceRelative = sourceUrl.slice(releaseRoot.length);
|
|
83660
|
+
const sourceDirectory = sourceRelative.slice(0, Math.max(0, sourceRelative.lastIndexOf("/")));
|
|
83661
|
+
const direct = Array.isArray(source2.items) ? source2.items.map((item) => qualifyRegistryItemPaths(item, sourceDirectory)) : [];
|
|
83659
83662
|
const includes = source2.include ?? [];
|
|
83660
83663
|
if (!Array.isArray(includes)) {
|
|
83661
83664
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include must be an array.");
|
|
@@ -83664,12 +83667,12 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
|
|
|
83664
83667
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains too many included documents.");
|
|
83665
83668
|
}
|
|
83666
83669
|
const nested = await Promise.all(includes.map(async (include) => {
|
|
83667
|
-
if (typeof include !== "string" || include.
|
|
83670
|
+
if (typeof include !== "string" || !isSafeRelative2(include) || !/^[A-Za-z0-9._/-]+$/u.test(include) || !include.endsWith("registry.json") || include === "registry.json") {
|
|
83668
83671
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an unsafe include.");
|
|
83669
83672
|
}
|
|
83673
|
+
const sourceDirectoryUrl = sourceUrl.slice(0, sourceUrl.lastIndexOf("/") + 1);
|
|
83670
83674
|
const url3 = new URL(include, sourceUrl).toString();
|
|
83671
|
-
|
|
83672
|
-
if (!url3.startsWith(releaseRoot) || visited.has(url3)) {
|
|
83675
|
+
if (url3 !== sourceDirectoryUrl + include || !url3.startsWith(releaseRoot) || !url3.startsWith(sourceDirectoryUrl) || visited.has(url3)) {
|
|
83673
83676
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include escaped or repeated the release snapshot.");
|
|
83674
83677
|
}
|
|
83675
83678
|
visited.add(url3);
|
|
@@ -83678,6 +83681,33 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
|
|
|
83678
83681
|
}));
|
|
83679
83682
|
return direct.concat(nested.flat());
|
|
83680
83683
|
}
|
|
83684
|
+
function qualifyRegistryItemPaths(item, sourceDirectory) {
|
|
83685
|
+
if (!item || typeof item !== "object")
|
|
83686
|
+
return item;
|
|
83687
|
+
const candidate2 = item;
|
|
83688
|
+
if (!Array.isArray(candidate2.files))
|
|
83689
|
+
return item;
|
|
83690
|
+
return {
|
|
83691
|
+
...candidate2,
|
|
83692
|
+
files: candidate2.files.map((file2) => {
|
|
83693
|
+
if (!file2 || typeof file2 !== "object")
|
|
83694
|
+
return file2;
|
|
83695
|
+
const candidateFile = file2;
|
|
83696
|
+
if (typeof candidateFile.path !== "string")
|
|
83697
|
+
return file2;
|
|
83698
|
+
if (!isSafeRelative2(candidateFile.path) || sourceDirectory !== "" && (candidateFile.path === sourceDirectory || candidateFile.path.startsWith(sourceDirectory + "/"))) {
|
|
83699
|
+
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
|
|
83700
|
+
}
|
|
83701
|
+
if (sourceDirectory === "")
|
|
83702
|
+
return file2;
|
|
83703
|
+
const qualified = sourceDirectory + "/" + candidateFile.path;
|
|
83704
|
+
if (!isSafeRelative2(qualified)) {
|
|
83705
|
+
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
|
|
83706
|
+
}
|
|
83707
|
+
return { ...candidateFile, path: qualified };
|
|
83708
|
+
})
|
|
83709
|
+
};
|
|
83710
|
+
}
|
|
83681
83711
|
function isInstallableItem(item) {
|
|
83682
83712
|
if (!item || typeof item !== "object")
|
|
83683
83713
|
return false;
|
|
@@ -83695,7 +83725,11 @@ async function readUiRegistryItem(release, expected, fetcher = fetch) {
|
|
|
83695
83725
|
return item;
|
|
83696
83726
|
}
|
|
83697
83727
|
function isSafeRelative2(value3) {
|
|
83698
|
-
|
|
83728
|
+
const segments = value3.split("/");
|
|
83729
|
+
return value3.length > 0 && !value3.startsWith("/") && !/^[A-Za-z]:[\\/]/u.test(value3) && !value3.includes("\\") && !/[?#%]/u.test(value3) && ![...value3].some((character) => {
|
|
83730
|
+
const code = character.codePointAt(0) ?? 0;
|
|
83731
|
+
return code <= 31 || code === 127;
|
|
83732
|
+
}) && segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
83699
83733
|
}
|
|
83700
83734
|
function registryItemUrl(release, itemName) {
|
|
83701
83735
|
return RAW + "/" + release.commit + "/registry/public/r/" + encodeURIComponent(itemName) + ".json";
|
package/package.json
CHANGED
|
@@ -135,7 +135,14 @@ function mockFetch(seen: string[] = []): typeof fetch {
|
|
|
135
135
|
})
|
|
136
136
|
}
|
|
137
137
|
if (url.endsWith('/registry/patterns/chart/registry.json')) {
|
|
138
|
-
return Response.json({
|
|
138
|
+
return Response.json({
|
|
139
|
+
items: [
|
|
140
|
+
{
|
|
141
|
+
...item,
|
|
142
|
+
files: item.files.map((file) => ({ ...file, path: 'line-basic.tsx' })),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
})
|
|
139
146
|
}
|
|
140
147
|
if (url.endsWith('/registry.json')) {
|
|
141
148
|
return Response.json({ include: ['registry/patterns/chart/registry.json'] })
|
|
@@ -19,7 +19,7 @@ const registry: UiRegistry = {
|
|
|
19
19
|
description: 'A controlled chart.',
|
|
20
20
|
files: [
|
|
21
21
|
{
|
|
22
|
-
path: '
|
|
22
|
+
path: 'line-basic.tsx',
|
|
23
23
|
type: 'registry:component',
|
|
24
24
|
target: 'components/astrale/pattern/chart/line-basic.tsx',
|
|
25
25
|
},
|
|
@@ -79,6 +79,7 @@ function builtItem(item: UiRegistry['items'][number]) {
|
|
|
79
79
|
...item,
|
|
80
80
|
files: item.files.map((file, index) => ({
|
|
81
81
|
...file,
|
|
82
|
+
path: `registry/patterns/chart/${file.path}`,
|
|
82
83
|
content: index === 0 ? 'export const Chart = true\n' : 'export const Summary = true\n',
|
|
83
84
|
})),
|
|
84
85
|
}
|
|
@@ -152,6 +153,7 @@ describe('UI release and runner contracts', () => {
|
|
|
152
153
|
expect(release.commit).toBe(commit)
|
|
153
154
|
expect(release.compatibility.base).toBe('base')
|
|
154
155
|
expect(release.registry.items).toHaveLength(1)
|
|
156
|
+
expect(release.registry.items[0]?.files[0]?.path).toBe('registry/patterns/chart/line-basic.tsx')
|
|
155
157
|
expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
|
|
156
158
|
expect.arrayContaining([
|
|
157
159
|
expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
|
|
@@ -193,6 +195,114 @@ describe('UI release and runner contracts', () => {
|
|
|
193
195
|
expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
|
|
194
196
|
})
|
|
195
197
|
|
|
198
|
+
test('rejects noncanonical and encoded registry includes before fetching them', async () => {
|
|
199
|
+
const unsafeIncludes = [
|
|
200
|
+
'%2e%2e/other/registry.json',
|
|
201
|
+
'registry/%2Fother/registry.json',
|
|
202
|
+
'registry/other/registry.json?raw=1',
|
|
203
|
+
'registry/other/registry.json#item',
|
|
204
|
+
'https://example.invalid/registry.json',
|
|
205
|
+
'./registry/other/registry.json',
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
for (const include of unsafeIncludes) {
|
|
209
|
+
const seen: string[] = []
|
|
210
|
+
const fallback = mockFetch(seen)
|
|
211
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
212
|
+
const url = String(input)
|
|
213
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
214
|
+
return Response.json({ include: [include] })
|
|
215
|
+
}
|
|
216
|
+
return fallback(input, init)
|
|
217
|
+
}) as typeof fetch
|
|
218
|
+
|
|
219
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
|
|
220
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
221
|
+
})
|
|
222
|
+
expect(seen.some((url) => url.includes('example.invalid') || url.includes('/other/'))).toBe(
|
|
223
|
+
false,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('rejects malformed and already-qualified family-local item paths', async () => {
|
|
229
|
+
const unsafePaths = [
|
|
230
|
+
'',
|
|
231
|
+
'/absolute.tsx',
|
|
232
|
+
'C:/windows.tsx',
|
|
233
|
+
'./line-basic.tsx',
|
|
234
|
+
'../line-basic.tsx',
|
|
235
|
+
'%2e%2e/line-basic.tsx',
|
|
236
|
+
'registry/patterns/chart/line-basic.tsx',
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
for (const unsafePath of unsafePaths) {
|
|
240
|
+
const supplied = structuredClone(registry)
|
|
241
|
+
supplied.items[0]!.files[0]!.path = unsafePath
|
|
242
|
+
await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], supplied))).rejects.toMatchObject(
|
|
243
|
+
{
|
|
244
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('qualifies every item file relative to its declaring nested registry', async () => {
|
|
251
|
+
const rootItem = {
|
|
252
|
+
...structuredClone(registry.items[0]!),
|
|
253
|
+
name: 'pattern-chart-root',
|
|
254
|
+
files: [
|
|
255
|
+
{
|
|
256
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
257
|
+
path: 'registry/root.tsx',
|
|
258
|
+
target: 'components/astrale/pattern/chart/root.tsx',
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
meta: { canonicalAddress: 'pattern/chart/root' },
|
|
262
|
+
}
|
|
263
|
+
const chartItem = {
|
|
264
|
+
...structuredClone(registry.items[0]!),
|
|
265
|
+
files: [
|
|
266
|
+
structuredClone(registry.items[0]!.files[0]!),
|
|
267
|
+
{
|
|
268
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
269
|
+
path: 'parts/legend.tsx',
|
|
270
|
+
target: 'components/astrale/pattern/chart/parts/legend.tsx',
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
}
|
|
274
|
+
const nestedItem = {
|
|
275
|
+
...structuredClone(registry.items[0]!),
|
|
276
|
+
name: 'pattern-chart-nested-line',
|
|
277
|
+
files: [structuredClone(registry.items[0]!.files[0]!)],
|
|
278
|
+
meta: { canonicalAddress: 'pattern/chart/nested-line' },
|
|
279
|
+
}
|
|
280
|
+
const fallback = mockFetch()
|
|
281
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
282
|
+
const url = String(input)
|
|
283
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
284
|
+
return Response.json({
|
|
285
|
+
items: [rootItem],
|
|
286
|
+
include: ['registry/patterns/chart/registry.json'],
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
if (url.endsWith('/registry/patterns/chart/registry.json')) {
|
|
290
|
+
return Response.json({ items: [chartItem], include: ['nested/registry.json'] })
|
|
291
|
+
}
|
|
292
|
+
if (url.endsWith('/registry/patterns/chart/nested/registry.json')) {
|
|
293
|
+
return Response.json({ items: [nestedItem] })
|
|
294
|
+
}
|
|
295
|
+
return fallback(input, init)
|
|
296
|
+
}) as typeof fetch
|
|
297
|
+
|
|
298
|
+
const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
|
|
299
|
+
expect(release.registry.items.map((item) => item.files.map((file) => file.path))).toEqual([
|
|
300
|
+
['registry/root.tsx'],
|
|
301
|
+
['registry/patterns/chart/line-basic.tsx', 'registry/patterns/chart/parts/legend.tsx'],
|
|
302
|
+
['registry/patterns/chart/nested/line-basic.tsx'],
|
|
303
|
+
])
|
|
304
|
+
})
|
|
305
|
+
|
|
196
306
|
/** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
|
|
197
307
|
test('bounds and normalizes malformed registry responses', async () => {
|
|
198
308
|
const fallback = mockFetch()
|
|
@@ -537,7 +647,7 @@ describe('UI source operations', () => {
|
|
|
537
647
|
files: [
|
|
538
648
|
registry.items[0]!.files[0]!,
|
|
539
649
|
{
|
|
540
|
-
path: '
|
|
650
|
+
path: 'summary.tsx',
|
|
541
651
|
type: 'registry:component',
|
|
542
652
|
target: 'components/astrale/pattern/chart/summary.tsx',
|
|
543
653
|
},
|
package/src/ui/release.ts
CHANGED
|
@@ -152,7 +152,12 @@ async function resolveRegistryItems(
|
|
|
152
152
|
fetcher: Fetch,
|
|
153
153
|
visited: Set<string>,
|
|
154
154
|
): Promise<unknown[]> {
|
|
155
|
-
const
|
|
155
|
+
const releaseRoot = RAW + '/' + commit + '/'
|
|
156
|
+
const sourceRelative = sourceUrl.slice(releaseRoot.length)
|
|
157
|
+
const sourceDirectory = sourceRelative.slice(0, Math.max(0, sourceRelative.lastIndexOf('/')))
|
|
158
|
+
const direct = Array.isArray(source.items)
|
|
159
|
+
? source.items.map((item) => qualifyRegistryItemPaths(item, sourceDirectory))
|
|
160
|
+
: []
|
|
156
161
|
const includes = source.include ?? []
|
|
157
162
|
if (!Array.isArray(includes)) {
|
|
158
163
|
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry include must be an array.')
|
|
@@ -167,15 +172,21 @@ async function resolveRegistryItems(
|
|
|
167
172
|
includes.map(async (include) => {
|
|
168
173
|
if (
|
|
169
174
|
typeof include !== 'string' ||
|
|
170
|
-
include
|
|
175
|
+
!isSafeRelative(include) ||
|
|
176
|
+
!/^[A-Za-z0-9._/-]+$/u.test(include) ||
|
|
171
177
|
!include.endsWith('registry.json') ||
|
|
172
|
-
include
|
|
178
|
+
include === 'registry.json'
|
|
173
179
|
) {
|
|
174
180
|
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry contains an unsafe include.')
|
|
175
181
|
}
|
|
182
|
+
const sourceDirectoryUrl = sourceUrl.slice(0, sourceUrl.lastIndexOf('/') + 1)
|
|
176
183
|
const url = new URL(include, sourceUrl).toString()
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
if (
|
|
185
|
+
url !== sourceDirectoryUrl + include ||
|
|
186
|
+
!url.startsWith(releaseRoot) ||
|
|
187
|
+
!url.startsWith(sourceDirectoryUrl) ||
|
|
188
|
+
visited.has(url)
|
|
189
|
+
) {
|
|
179
190
|
throw new UiError(
|
|
180
191
|
'UI_REGISTRY_UNAVAILABLE',
|
|
181
192
|
'UI registry include escaped or repeated the release snapshot.',
|
|
@@ -189,6 +200,40 @@ async function resolveRegistryItems(
|
|
|
189
200
|
return direct.concat(nested.flat())
|
|
190
201
|
}
|
|
191
202
|
|
|
203
|
+
function qualifyRegistryItemPaths(item: unknown, sourceDirectory: string): unknown {
|
|
204
|
+
if (!item || typeof item !== 'object') return item
|
|
205
|
+
const candidate = item as { files?: unknown }
|
|
206
|
+
if (!Array.isArray(candidate.files)) return item
|
|
207
|
+
return {
|
|
208
|
+
...candidate,
|
|
209
|
+
files: candidate.files.map((file) => {
|
|
210
|
+
if (!file || typeof file !== 'object') return file
|
|
211
|
+
const candidateFile = file as { path?: unknown }
|
|
212
|
+
if (typeof candidateFile.path !== 'string') return file
|
|
213
|
+
if (
|
|
214
|
+
!isSafeRelative(candidateFile.path) ||
|
|
215
|
+
(sourceDirectory !== '' &&
|
|
216
|
+
(candidateFile.path === sourceDirectory ||
|
|
217
|
+
candidateFile.path.startsWith(sourceDirectory + '/')))
|
|
218
|
+
) {
|
|
219
|
+
throw new UiError(
|
|
220
|
+
'UI_REGISTRY_UNAVAILABLE',
|
|
221
|
+
'UI registry contains an invalid installable item path.',
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
if (sourceDirectory === '') return file
|
|
225
|
+
const qualified = sourceDirectory + '/' + candidateFile.path
|
|
226
|
+
if (!isSafeRelative(qualified)) {
|
|
227
|
+
throw new UiError(
|
|
228
|
+
'UI_REGISTRY_UNAVAILABLE',
|
|
229
|
+
'UI registry contains an invalid installable item path.',
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
return { ...candidateFile, path: qualified }
|
|
233
|
+
}),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
192
237
|
function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
|
|
193
238
|
if (!item || typeof item !== 'object') return false
|
|
194
239
|
const candidate = item as Partial<UiRegistry['items'][number]>
|
|
@@ -256,12 +301,18 @@ export async function readUiRegistryItem(
|
|
|
256
301
|
}
|
|
257
302
|
|
|
258
303
|
function isSafeRelative(value: string): boolean {
|
|
304
|
+
const segments = value.split('/')
|
|
259
305
|
return (
|
|
260
306
|
value.length > 0 &&
|
|
261
307
|
!value.startsWith('/') &&
|
|
262
308
|
!/^[A-Za-z]:[\\/]/u.test(value) &&
|
|
263
309
|
!value.includes('\\') &&
|
|
264
|
-
|
|
310
|
+
!/[?#%]/u.test(value) &&
|
|
311
|
+
![...value].some((character) => {
|
|
312
|
+
const code = character.codePointAt(0) ?? 0
|
|
313
|
+
return code <= 31 || code === 127
|
|
314
|
+
}) &&
|
|
315
|
+
segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..')
|
|
265
316
|
)
|
|
266
317
|
}
|
|
267
318
|
|