@astrale-os/cli 1.0.0-beta.14 → 1.0.0-beta.16
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 +169 -8
- package/package.json +1 -1
- package/src/commands/ui/__tests__/commands.test.ts +8 -1
- package/src/ui/__tests__/ui.test.ts +363 -16
- package/src/ui/operations.ts +172 -1
- package/src/ui/project.ts +5 -0
- package/src/ui/release.ts +57 -6
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.16",
|
|
2582
2582
|
description: "Astrale CLI — connect to existing Astrale kernels",
|
|
2583
2583
|
keywords: [
|
|
2584
2584
|
"astrale",
|
|
@@ -83525,7 +83525,8 @@ async function discoverUiProject(input = process.cwd()) {
|
|
|
83525
83525
|
lockPath,
|
|
83526
83526
|
cssPath: path5.join(root, cssRelative),
|
|
83527
83527
|
componentsPath,
|
|
83528
|
-
uiLockPath: path5.join(root, "astrale-ui.lock.json")
|
|
83528
|
+
uiLockPath: path5.join(root, "astrale-ui.lock.json"),
|
|
83529
|
+
isAstraleDomain: await exists(path5.join(root, "astrale.config.ts")) && await exists(path5.join(root, "ui/index.ts")) && await exists(path5.join(root, "frontend/package.json"))
|
|
83529
83530
|
};
|
|
83530
83531
|
}
|
|
83531
83532
|
function assertSupportedUiProject(project2) {
|
|
@@ -83655,7 +83656,10 @@ async function readRegistry(commit, fetcher) {
|
|
|
83655
83656
|
return { name: root.name ?? "astrale-ui", homepage: root.homepage, items: installable };
|
|
83656
83657
|
}
|
|
83657
83658
|
async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited) {
|
|
83658
|
-
const
|
|
83659
|
+
const releaseRoot = RAW + "/" + commit + "/";
|
|
83660
|
+
const sourceRelative = sourceUrl.slice(releaseRoot.length);
|
|
83661
|
+
const sourceDirectory = sourceRelative.slice(0, Math.max(0, sourceRelative.lastIndexOf("/")));
|
|
83662
|
+
const direct = Array.isArray(source2.items) ? source2.items.map((item) => qualifyRegistryItemPaths(item, sourceDirectory)) : [];
|
|
83659
83663
|
const includes = source2.include ?? [];
|
|
83660
83664
|
if (!Array.isArray(includes)) {
|
|
83661
83665
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include must be an array.");
|
|
@@ -83664,12 +83668,12 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
|
|
|
83664
83668
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains too many included documents.");
|
|
83665
83669
|
}
|
|
83666
83670
|
const nested = await Promise.all(includes.map(async (include) => {
|
|
83667
|
-
if (typeof include !== "string" || include.
|
|
83671
|
+
if (typeof include !== "string" || !isSafeRelative2(include) || !/^[A-Za-z0-9._/-]+$/u.test(include) || !include.endsWith("registry.json") || include === "registry.json") {
|
|
83668
83672
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an unsafe include.");
|
|
83669
83673
|
}
|
|
83674
|
+
const sourceDirectoryUrl = sourceUrl.slice(0, sourceUrl.lastIndexOf("/") + 1);
|
|
83670
83675
|
const url3 = new URL(include, sourceUrl).toString();
|
|
83671
|
-
|
|
83672
|
-
if (!url3.startsWith(releaseRoot) || visited.has(url3)) {
|
|
83676
|
+
if (url3 !== sourceDirectoryUrl + include || !url3.startsWith(releaseRoot) || !url3.startsWith(sourceDirectoryUrl) || visited.has(url3)) {
|
|
83673
83677
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include escaped or repeated the release snapshot.");
|
|
83674
83678
|
}
|
|
83675
83679
|
visited.add(url3);
|
|
@@ -83678,6 +83682,33 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
|
|
|
83678
83682
|
}));
|
|
83679
83683
|
return direct.concat(nested.flat());
|
|
83680
83684
|
}
|
|
83685
|
+
function qualifyRegistryItemPaths(item, sourceDirectory) {
|
|
83686
|
+
if (!item || typeof item !== "object")
|
|
83687
|
+
return item;
|
|
83688
|
+
const candidate2 = item;
|
|
83689
|
+
if (!Array.isArray(candidate2.files))
|
|
83690
|
+
return item;
|
|
83691
|
+
return {
|
|
83692
|
+
...candidate2,
|
|
83693
|
+
files: candidate2.files.map((file2) => {
|
|
83694
|
+
if (!file2 || typeof file2 !== "object")
|
|
83695
|
+
return file2;
|
|
83696
|
+
const candidateFile = file2;
|
|
83697
|
+
if (typeof candidateFile.path !== "string")
|
|
83698
|
+
return file2;
|
|
83699
|
+
if (!isSafeRelative2(candidateFile.path) || sourceDirectory !== "" && (candidateFile.path === sourceDirectory || candidateFile.path.startsWith(sourceDirectory + "/"))) {
|
|
83700
|
+
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
|
|
83701
|
+
}
|
|
83702
|
+
if (sourceDirectory === "")
|
|
83703
|
+
return file2;
|
|
83704
|
+
const qualified = sourceDirectory + "/" + candidateFile.path;
|
|
83705
|
+
if (!isSafeRelative2(qualified)) {
|
|
83706
|
+
throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
|
|
83707
|
+
}
|
|
83708
|
+
return { ...candidateFile, path: qualified };
|
|
83709
|
+
})
|
|
83710
|
+
};
|
|
83711
|
+
}
|
|
83681
83712
|
function isInstallableItem(item) {
|
|
83682
83713
|
if (!item || typeof item !== "object")
|
|
83683
83714
|
return false;
|
|
@@ -83695,7 +83726,11 @@ async function readUiRegistryItem(release, expected, fetcher = fetch) {
|
|
|
83695
83726
|
return item;
|
|
83696
83727
|
}
|
|
83697
83728
|
function isSafeRelative2(value3) {
|
|
83698
|
-
|
|
83729
|
+
const segments = value3.split("/");
|
|
83730
|
+
return value3.length > 0 && !value3.startsWith("/") && !/^[A-Za-z]:[\\/]/u.test(value3) && !value3.includes("\\") && !/[?#%]/u.test(value3) && ![...value3].some((character) => {
|
|
83731
|
+
const code = character.codePointAt(0) ?? 0;
|
|
83732
|
+
return code <= 31 || code === 127;
|
|
83733
|
+
}) && segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
83699
83734
|
}
|
|
83700
83735
|
function registryItemUrl(release, itemName) {
|
|
83701
83736
|
return RAW + "/" + release.commit + "/registry/public/r/" + encodeURIComponent(itemName) + ".json";
|
|
@@ -83734,6 +83769,108 @@ async function readOptional(target2) {
|
|
|
83734
83769
|
function manifestDependencies(manifest) {
|
|
83735
83770
|
return { ...manifest.dependencies };
|
|
83736
83771
|
}
|
|
83772
|
+
function domainRegistryPackagePath(project2) {
|
|
83773
|
+
return path6.join(project2.root, "components/package.json");
|
|
83774
|
+
}
|
|
83775
|
+
function pnpmWorkspacePath(project2) {
|
|
83776
|
+
return path6.join(project2.root, "pnpm-workspace.yaml");
|
|
83777
|
+
}
|
|
83778
|
+
function domainRegistryPackageName(manifest) {
|
|
83779
|
+
const name = typeof manifest.name === "string" ? manifest.name : "astrale-domain";
|
|
83780
|
+
return name + "-ui-registry";
|
|
83781
|
+
}
|
|
83782
|
+
function appendWorkspace(manifest, workspace) {
|
|
83783
|
+
const configured = manifest.workspaces;
|
|
83784
|
+
if (configured === undefined) {
|
|
83785
|
+
manifest.workspaces = [workspace];
|
|
83786
|
+
return;
|
|
83787
|
+
}
|
|
83788
|
+
if (Array.isArray(configured) && configured.every((value3) => typeof value3 === "string")) {
|
|
83789
|
+
if (!configured.includes(workspace))
|
|
83790
|
+
manifest.workspaces = [...configured, workspace];
|
|
83791
|
+
return;
|
|
83792
|
+
}
|
|
83793
|
+
if (configured && typeof configured === "object") {
|
|
83794
|
+
const candidate2 = configured;
|
|
83795
|
+
if (Array.isArray(candidate2.packages) && candidate2.packages.every((value3) => typeof value3 === "string")) {
|
|
83796
|
+
if (!candidate2.packages.includes(workspace)) {
|
|
83797
|
+
manifest.workspaces = { ...candidate2, packages: [...candidate2.packages, workspace] };
|
|
83798
|
+
}
|
|
83799
|
+
return;
|
|
83800
|
+
}
|
|
83801
|
+
}
|
|
83802
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "package.json has an invalid workspaces field.");
|
|
83803
|
+
}
|
|
83804
|
+
async function appendPnpmWorkspace(project2, workspace) {
|
|
83805
|
+
const target2 = pnpmWorkspacePath(project2);
|
|
83806
|
+
const source2 = await readOptional(target2);
|
|
83807
|
+
if (source2 === undefined) {
|
|
83808
|
+
await writeFile6(target2, `packages:
|
|
83809
|
+
- '` + workspace + `'
|
|
83810
|
+
`, "utf8");
|
|
83811
|
+
return;
|
|
83812
|
+
}
|
|
83813
|
+
const document = $parseDocument(source2);
|
|
83814
|
+
if (document.errors.length > 0) {
|
|
83815
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "pnpm-workspace.yaml is not valid YAML.");
|
|
83816
|
+
}
|
|
83817
|
+
const packages = document.get("packages", true);
|
|
83818
|
+
if (!$isSeq(packages)) {
|
|
83819
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "pnpm-workspace.yaml must declare a packages sequence.");
|
|
83820
|
+
}
|
|
83821
|
+
const values = packages.toJSON();
|
|
83822
|
+
if (!Array.isArray(values) || !values.every((value3) => typeof value3 === "string")) {
|
|
83823
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "pnpm-workspace.yaml must declare a packages sequence.");
|
|
83824
|
+
}
|
|
83825
|
+
if (!values.includes(workspace))
|
|
83826
|
+
packages.add(workspace);
|
|
83827
|
+
await writeFile6(target2, String(document), "utf8");
|
|
83828
|
+
}
|
|
83829
|
+
async function hasDomainRegistryWorkspace(project2) {
|
|
83830
|
+
if (!project2.isAstraleDomain || !await exists2(domainRegistryPackagePath(project2)))
|
|
83831
|
+
return false;
|
|
83832
|
+
const registryManifest = JSON.parse(await readFile18(domainRegistryPackagePath(project2), "utf8"));
|
|
83833
|
+
if (registryManifest.private !== true || typeof registryManifest.name !== "string" || registryManifest.name === UI_PACKAGE || registryManifest.name === project2.packageJson.name) {
|
|
83834
|
+
return false;
|
|
83835
|
+
}
|
|
83836
|
+
if (project2.manager === "pnpm") {
|
|
83837
|
+
const source2 = await readOptional(pnpmWorkspacePath(project2));
|
|
83838
|
+
if (source2 === undefined)
|
|
83839
|
+
return false;
|
|
83840
|
+
const document = $parseDocument(source2);
|
|
83841
|
+
if (document.errors.length > 0)
|
|
83842
|
+
return false;
|
|
83843
|
+
const packages = document.get("packages", true);
|
|
83844
|
+
const values = $isSeq(packages) ? packages.toJSON() : undefined;
|
|
83845
|
+
return Array.isArray(values) && values.includes("components");
|
|
83846
|
+
}
|
|
83847
|
+
const configured = project2.packageJson.workspaces;
|
|
83848
|
+
const workspaces = Array.isArray(configured) ? configured : configured && typeof configured === "object" ? configured.packages : undefined;
|
|
83849
|
+
return Array.isArray(workspaces) && workspaces.includes("components");
|
|
83850
|
+
}
|
|
83851
|
+
async function writeDomainRegistryWorkspace(project2, manifest) {
|
|
83852
|
+
if (!project2.isAstraleDomain)
|
|
83853
|
+
return;
|
|
83854
|
+
if (project2.manager === "pnpm")
|
|
83855
|
+
await appendPnpmWorkspace(project2, "components");
|
|
83856
|
+
else
|
|
83857
|
+
appendWorkspace(manifest, "components");
|
|
83858
|
+
const target2 = domainRegistryPackagePath(project2);
|
|
83859
|
+
const existing = await readOptional(target2);
|
|
83860
|
+
const registryManifest = existing ? JSON.parse(existing) : { name: domainRegistryPackageName(manifest) };
|
|
83861
|
+
if (registryManifest.private === false) {
|
|
83862
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "The Astrale registry source workspace must remain private.");
|
|
83863
|
+
}
|
|
83864
|
+
if (registryManifest.name === UI_PACKAGE || registryManifest.name === manifest.name || registryManifest.name !== undefined && typeof registryManifest.name !== "string") {
|
|
83865
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "The Astrale registry source workspace must have a distinct package name.");
|
|
83866
|
+
}
|
|
83867
|
+
await writeJson(target2, {
|
|
83868
|
+
...registryManifest,
|
|
83869
|
+
name: registryManifest.name ?? domainRegistryPackageName(manifest),
|
|
83870
|
+
private: true,
|
|
83871
|
+
type: registryManifest.type ?? "module"
|
|
83872
|
+
});
|
|
83873
|
+
}
|
|
83737
83874
|
async function initUi(options, dependencies = {}) {
|
|
83738
83875
|
const project2 = await discoverUiProject(options.path);
|
|
83739
83876
|
assertSupportedUiProject(project2);
|
|
@@ -83748,7 +83885,7 @@ async function initUi(options, dependencies = {}) {
|
|
|
83748
83885
|
return;
|
|
83749
83886
|
});
|
|
83750
83887
|
const requestedVersion = options.version?.replace(/^v/u, "");
|
|
83751
|
-
const desired = (!requestedVersion || requestedVersion === lock.package.version) && (!options.preset || options.preset === lock.preset) && css.includes(UI_PACKAGE + "/theme.css") && css.includes(UI_PACKAGE + "/presets/" + lock.preset + ".css") && components?.style === "base-nova";
|
|
83888
|
+
const desired = (!requestedVersion || requestedVersion === lock.package.version) && (!options.preset || options.preset === lock.preset) && css.includes(UI_PACKAGE + "/theme.css") && css.includes(UI_PACKAGE + "/presets/" + lock.preset + ".css") && components?.style === "base-nova" && (!project2.isAstraleDomain || await hasDomainRegistryWorkspace(project2));
|
|
83752
83889
|
if (!desired) {
|
|
83753
83890
|
throw new UiError("UI_ITEM_CONFLICT", "Existing Astrale UI initialization differs from the requested state.", "Run astrale ui doctor, then repeat init with --force after reviewing the changes.");
|
|
83754
83891
|
}
|
|
@@ -83767,6 +83904,8 @@ async function initUi(options, dependencies = {}) {
|
|
|
83767
83904
|
cssRelative,
|
|
83768
83905
|
"components.json",
|
|
83769
83906
|
"astrale-ui.lock.json",
|
|
83907
|
+
...project2.isAstraleDomain ? ["components/package.json"] : [],
|
|
83908
|
+
...project2.isAstraleDomain && project2.manager === "pnpm" ? ["pnpm-workspace.yaml"] : [],
|
|
83770
83909
|
...project2.lockPath ? [projectRelative(project2, project2.lockPath)] : []
|
|
83771
83910
|
],
|
|
83772
83911
|
tooling: {
|
|
@@ -83778,11 +83917,19 @@ async function initUi(options, dependencies = {}) {
|
|
|
83778
83917
|
};
|
|
83779
83918
|
if (options.dryRun)
|
|
83780
83919
|
return plan;
|
|
83920
|
+
if (project2.isAstraleDomain) {
|
|
83921
|
+
await assertSafePlannedTarget(project2, "components/package.json");
|
|
83922
|
+
if (project2.manager === "pnpm") {
|
|
83923
|
+
await assertSafePlannedTarget(project2, "pnpm-workspace.yaml");
|
|
83924
|
+
}
|
|
83925
|
+
}
|
|
83781
83926
|
const mutationPaths = [
|
|
83782
83927
|
project2.packageJsonPath,
|
|
83783
83928
|
project2.cssPath,
|
|
83784
83929
|
project2.componentsPath,
|
|
83785
83930
|
project2.uiLockPath,
|
|
83931
|
+
...project2.isAstraleDomain ? [domainRegistryPackagePath(project2)] : [],
|
|
83932
|
+
...project2.isAstraleDomain && project2.manager === "pnpm" ? [pnpmWorkspacePath(project2)] : [],
|
|
83786
83933
|
...project2.lockPath ? [project2.lockPath] : []
|
|
83787
83934
|
];
|
|
83788
83935
|
const snapshots = new Map;
|
|
@@ -83794,6 +83941,7 @@ async function initUi(options, dependencies = {}) {
|
|
|
83794
83941
|
...manifestDependencies(manifest),
|
|
83795
83942
|
[UI_PACKAGE]: release.version
|
|
83796
83943
|
};
|
|
83944
|
+
await writeDomainRegistryWorkspace(project2, manifest);
|
|
83797
83945
|
await writeJson(project2.packageJsonPath, manifest);
|
|
83798
83946
|
const currentCss = await readOptional(project2.cssPath) ?? "";
|
|
83799
83947
|
const imports = [
|
|
@@ -83866,6 +84014,17 @@ async function initUi(options, dependencies = {}) {
|
|
|
83866
84014
|
throw error52;
|
|
83867
84015
|
}
|
|
83868
84016
|
}
|
|
84017
|
+
async function pinUiDependency(project2, version2, runner) {
|
|
84018
|
+
const manifest = JSON.parse(await readFile18(project2.packageJsonPath, "utf8"));
|
|
84019
|
+
if (manifestDependencies(manifest)[UI_PACKAGE] === version2)
|
|
84020
|
+
return;
|
|
84021
|
+
manifest.dependencies = { ...manifestDependencies(manifest), [UI_PACKAGE]: version2 };
|
|
84022
|
+
await writeJson(project2.packageJsonPath, manifest);
|
|
84023
|
+
const result = await runner(project2.manager, ["install"], project2.root);
|
|
84024
|
+
if (result.code !== 0) {
|
|
84025
|
+
throw new UiError("UI_DEPENDENCY_INSTALL_FAILED", project2.manager + " install failed while restoring the locked UI release.", result.stderr.trim() || undefined);
|
|
84026
|
+
}
|
|
84027
|
+
}
|
|
83869
84028
|
async function listUi(query4, options, dependencies = {}) {
|
|
83870
84029
|
const release = await resolveUiRelease(options.version, dependencies.fetcher);
|
|
83871
84030
|
const needle = query4?.trim().toLowerCase();
|
|
@@ -83931,6 +84090,7 @@ async function addUi(addresses, options, dependencies = {}) {
|
|
|
83931
84090
|
throw new UiError("UI_TOOL_FAILED", "The pinned shadcn operation omitted an item file.");
|
|
83932
84091
|
}
|
|
83933
84092
|
}
|
|
84093
|
+
await pinUiDependency(project2, lock.package.version, dependencies.runner ?? defaultUiRunner);
|
|
83934
84094
|
}
|
|
83935
84095
|
} catch (error52) {
|
|
83936
84096
|
for (const [target2, previous] of snapshots) {
|
|
@@ -84114,6 +84274,7 @@ async function writeJson(target2, value3) {
|
|
|
84114
84274
|
`, "utf8");
|
|
84115
84275
|
}
|
|
84116
84276
|
var init_operations3 = __esm(() => {
|
|
84277
|
+
init_dist();
|
|
84117
84278
|
init_lock();
|
|
84118
84279
|
init_model7();
|
|
84119
84280
|
init_project3();
|
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
|
}
|
|
@@ -93,7 +94,11 @@ async function fixture(): Promise<string> {
|
|
|
93
94
|
JSON.stringify({
|
|
94
95
|
name: 'fixture',
|
|
95
96
|
private: true,
|
|
96
|
-
dependencies: {
|
|
97
|
+
dependencies: {
|
|
98
|
+
react: '19.2.8',
|
|
99
|
+
'react-dom': '19.2.8',
|
|
100
|
+
tailwindcss: '4.3.3',
|
|
101
|
+
},
|
|
97
102
|
packageManager: 'pnpm@11.13.1',
|
|
98
103
|
}),
|
|
99
104
|
)
|
|
@@ -101,6 +106,16 @@ async function fixture(): Promise<string> {
|
|
|
101
106
|
return root
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
async function lockedFixture(): Promise<string> {
|
|
110
|
+
const root = await fixture()
|
|
111
|
+
const manifestPath = path.join(root, 'package.json')
|
|
112
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
113
|
+
manifest.dependencies['@astrale-os/ui'] = '0.3.0-beta.0'
|
|
114
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
115
|
+
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
116
|
+
return root
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
function lock(): UiLock {
|
|
105
120
|
return {
|
|
106
121
|
$schema: 'https://example.invalid/ui-lock.schema.json',
|
|
@@ -152,6 +167,7 @@ describe('UI release and runner contracts', () => {
|
|
|
152
167
|
expect(release.commit).toBe(commit)
|
|
153
168
|
expect(release.compatibility.base).toBe('base')
|
|
154
169
|
expect(release.registry.items).toHaveLength(1)
|
|
170
|
+
expect(release.registry.items[0]?.files[0]?.path).toBe('registry/patterns/chart/line-basic.tsx')
|
|
155
171
|
expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
|
|
156
172
|
expect.arrayContaining([
|
|
157
173
|
expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
|
|
@@ -193,6 +209,114 @@ describe('UI release and runner contracts', () => {
|
|
|
193
209
|
expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
|
|
194
210
|
})
|
|
195
211
|
|
|
212
|
+
test('rejects noncanonical and encoded registry includes before fetching them', async () => {
|
|
213
|
+
const unsafeIncludes = [
|
|
214
|
+
'%2e%2e/other/registry.json',
|
|
215
|
+
'registry/%2Fother/registry.json',
|
|
216
|
+
'registry/other/registry.json?raw=1',
|
|
217
|
+
'registry/other/registry.json#item',
|
|
218
|
+
'https://example.invalid/registry.json',
|
|
219
|
+
'./registry/other/registry.json',
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
for (const include of unsafeIncludes) {
|
|
223
|
+
const seen: string[] = []
|
|
224
|
+
const fallback = mockFetch(seen)
|
|
225
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
226
|
+
const url = String(input)
|
|
227
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
228
|
+
return Response.json({ include: [include] })
|
|
229
|
+
}
|
|
230
|
+
return fallback(input, init)
|
|
231
|
+
}) as typeof fetch
|
|
232
|
+
|
|
233
|
+
await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
|
|
234
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
235
|
+
})
|
|
236
|
+
expect(seen.some((url) => url.includes('example.invalid') || url.includes('/other/'))).toBe(
|
|
237
|
+
false,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('rejects malformed and already-qualified family-local item paths', async () => {
|
|
243
|
+
const unsafePaths = [
|
|
244
|
+
'',
|
|
245
|
+
'/absolute.tsx',
|
|
246
|
+
'C:/windows.tsx',
|
|
247
|
+
'./line-basic.tsx',
|
|
248
|
+
'../line-basic.tsx',
|
|
249
|
+
'%2e%2e/line-basic.tsx',
|
|
250
|
+
'registry/patterns/chart/line-basic.tsx',
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
for (const unsafePath of unsafePaths) {
|
|
254
|
+
const supplied = structuredClone(registry)
|
|
255
|
+
supplied.items[0]!.files[0]!.path = unsafePath
|
|
256
|
+
await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], supplied))).rejects.toMatchObject(
|
|
257
|
+
{
|
|
258
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('qualifies every item file relative to its declaring nested registry', async () => {
|
|
265
|
+
const rootItem = {
|
|
266
|
+
...structuredClone(registry.items[0]!),
|
|
267
|
+
name: 'pattern-chart-root',
|
|
268
|
+
files: [
|
|
269
|
+
{
|
|
270
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
271
|
+
path: 'registry/root.tsx',
|
|
272
|
+
target: 'components/astrale/pattern/chart/root.tsx',
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
meta: { canonicalAddress: 'pattern/chart/root' },
|
|
276
|
+
}
|
|
277
|
+
const chartItem = {
|
|
278
|
+
...structuredClone(registry.items[0]!),
|
|
279
|
+
files: [
|
|
280
|
+
structuredClone(registry.items[0]!.files[0]!),
|
|
281
|
+
{
|
|
282
|
+
...structuredClone(registry.items[0]!.files[0]!),
|
|
283
|
+
path: 'parts/legend.tsx',
|
|
284
|
+
target: 'components/astrale/pattern/chart/parts/legend.tsx',
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
const nestedItem = {
|
|
289
|
+
...structuredClone(registry.items[0]!),
|
|
290
|
+
name: 'pattern-chart-nested-line',
|
|
291
|
+
files: [structuredClone(registry.items[0]!.files[0]!)],
|
|
292
|
+
meta: { canonicalAddress: 'pattern/chart/nested-line' },
|
|
293
|
+
}
|
|
294
|
+
const fallback = mockFetch()
|
|
295
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
296
|
+
const url = String(input)
|
|
297
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
298
|
+
return Response.json({
|
|
299
|
+
items: [rootItem],
|
|
300
|
+
include: ['registry/patterns/chart/registry.json'],
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
if (url.endsWith('/registry/patterns/chart/registry.json')) {
|
|
304
|
+
return Response.json({ items: [chartItem], include: ['nested/registry.json'] })
|
|
305
|
+
}
|
|
306
|
+
if (url.endsWith('/registry/patterns/chart/nested/registry.json')) {
|
|
307
|
+
return Response.json({ items: [nestedItem] })
|
|
308
|
+
}
|
|
309
|
+
return fallback(input, init)
|
|
310
|
+
}) as typeof fetch
|
|
311
|
+
|
|
312
|
+
const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
|
|
313
|
+
expect(release.registry.items.map((item) => item.files.map((file) => file.path))).toEqual([
|
|
314
|
+
['registry/root.tsx'],
|
|
315
|
+
['registry/patterns/chart/line-basic.tsx', 'registry/patterns/chart/parts/legend.tsx'],
|
|
316
|
+
['registry/patterns/chart/nested/line-basic.tsx'],
|
|
317
|
+
])
|
|
318
|
+
})
|
|
319
|
+
|
|
196
320
|
/** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
|
|
197
321
|
test('bounds and normalizes malformed registry responses', async () => {
|
|
198
322
|
const fallback = mockFetch()
|
|
@@ -250,6 +374,10 @@ describe('UI initialization transaction', () => {
|
|
|
250
374
|
JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
|
|
251
375
|
)
|
|
252
376
|
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
377
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
378
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
379
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
380
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
253
381
|
const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
254
382
|
|
|
255
383
|
await initUi(
|
|
@@ -265,8 +393,14 @@ describe('UI initialization transaction', () => {
|
|
|
265
393
|
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
|
|
266
394
|
expect(
|
|
267
395
|
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
|
|
268
|
-
).toHaveProperty('@astrale-os/ui')
|
|
396
|
+
).toHaveProperty('@astrale-os/ui', '0.3.0-beta.0')
|
|
269
397
|
expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
|
|
398
|
+
expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
|
|
399
|
+
name: 'fixture-ui-registry',
|
|
400
|
+
private: true,
|
|
401
|
+
type: 'module',
|
|
402
|
+
})
|
|
403
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toContain('components')
|
|
270
404
|
|
|
271
405
|
await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
|
|
272
406
|
const repeated = await initUi(
|
|
@@ -276,6 +410,153 @@ describe('UI initialization transaction', () => {
|
|
|
276
410
|
expect(repeated.status).toBe('unchanged')
|
|
277
411
|
})
|
|
278
412
|
|
|
413
|
+
test('adds the private registry workspace to an npm-authored Domain manifest', async () => {
|
|
414
|
+
const root = await fixture()
|
|
415
|
+
const manifestPath = path.join(root, 'package.json')
|
|
416
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
417
|
+
manifest.packageManager = 'npm@11.16.0'
|
|
418
|
+
manifest.workspaces = ['frontend']
|
|
419
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
420
|
+
await writeFile(path.join(root, 'package-lock.json'), '{}')
|
|
421
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
422
|
+
await writeFile(
|
|
423
|
+
path.join(root, 'frontend/package.json'),
|
|
424
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
425
|
+
)
|
|
426
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
427
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
428
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
429
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
430
|
+
|
|
431
|
+
const result = await initUi(
|
|
432
|
+
{ path: root, version: '0.3.0-beta.0', install: false },
|
|
433
|
+
{ fetcher: mockFetch() },
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
expect(result.files).toEqual(expect.arrayContaining(['components/package.json']))
|
|
437
|
+
expect(JSON.parse(await readFile(manifestPath, 'utf8')).workspaces).toEqual([
|
|
438
|
+
'frontend',
|
|
439
|
+
'components',
|
|
440
|
+
])
|
|
441
|
+
expect(JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8'))).toEqual({
|
|
442
|
+
name: 'fixture-ui-registry',
|
|
443
|
+
private: true,
|
|
444
|
+
type: 'module',
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
test('rejects a Domain registry workspace whose physical parent escapes the project', async () => {
|
|
449
|
+
const root = await fixture()
|
|
450
|
+
const outside = await fixture()
|
|
451
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
452
|
+
await writeFile(
|
|
453
|
+
path.join(root, 'frontend/package.json'),
|
|
454
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
455
|
+
)
|
|
456
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
457
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
458
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
459
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
460
|
+
await symlink(outside, path.join(root, 'components'), 'dir')
|
|
461
|
+
const outsideManifest = await readFile(path.join(outside, 'package.json'), 'utf8')
|
|
462
|
+
const rootManifest = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
463
|
+
|
|
464
|
+
await expect(
|
|
465
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
466
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
467
|
+
expect(await readFile(path.join(outside, 'package.json'), 'utf8')).toBe(outsideManifest)
|
|
468
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(rootManifest)
|
|
469
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('rejects a symlinked pnpm workspace manifest before any Domain mutation', async () => {
|
|
473
|
+
const root = await fixture()
|
|
474
|
+
const outside = await fixture()
|
|
475
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
476
|
+
await writeFile(
|
|
477
|
+
path.join(root, 'frontend/package.json'),
|
|
478
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
479
|
+
)
|
|
480
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
481
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
482
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
483
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
484
|
+
const outsideWorkspace = path.join(outside, 'workspace.yaml')
|
|
485
|
+
await writeFile(outsideWorkspace, "packages:\n - 'outside'\n")
|
|
486
|
+
await symlink(outsideWorkspace, path.join(root, 'pnpm-workspace.yaml'), 'file')
|
|
487
|
+
const outsideBefore = await readFile(outsideWorkspace, 'utf8')
|
|
488
|
+
|
|
489
|
+
await expect(
|
|
490
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
491
|
+
).rejects.toMatchObject({ code: 'UI_LOCK_INVALID' })
|
|
492
|
+
expect(await readFile(outsideWorkspace, 'utf8')).toBe(outsideBefore)
|
|
493
|
+
expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
|
|
494
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('restores every Domain workspace mutation when dependency installation fails', async () => {
|
|
498
|
+
const root = await fixture()
|
|
499
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
500
|
+
await writeFile(
|
|
501
|
+
path.join(root, 'frontend/package.json'),
|
|
502
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
503
|
+
)
|
|
504
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
505
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
506
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
507
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
508
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
509
|
+
const manifestBefore = await readFile(path.join(root, 'package.json'), 'utf8')
|
|
510
|
+
const cssBefore = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
|
|
511
|
+
const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
|
|
512
|
+
|
|
513
|
+
await expect(
|
|
514
|
+
initUi(
|
|
515
|
+
{ path: root, version: '0.3.0-beta.0' },
|
|
516
|
+
{
|
|
517
|
+
fetcher: mockFetch(),
|
|
518
|
+
runner: async () => ({ code: 1, stdout: '', stderr: 'install failed' }),
|
|
519
|
+
},
|
|
520
|
+
),
|
|
521
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
522
|
+
|
|
523
|
+
expect(await readFile(path.join(root, 'package.json'), 'utf8')).toBe(manifestBefore)
|
|
524
|
+
expect(await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')).toBe(cssBefore)
|
|
525
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
|
|
526
|
+
expect(await Bun.file(path.join(root, 'components/package.json')).exists()).toBe(false)
|
|
527
|
+
expect(await Bun.file(path.join(root, 'components.json')).exists()).toBe(false)
|
|
528
|
+
expect(await Bun.file(path.join(root, 'astrale-ui.lock.json')).exists()).toBe(false)
|
|
529
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
test('rejects a registry workspace that could shadow the public UI package', async () => {
|
|
533
|
+
const root = await fixture()
|
|
534
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
535
|
+
await writeFile(
|
|
536
|
+
path.join(root, 'frontend/package.json'),
|
|
537
|
+
JSON.stringify({ name: 'astrale-frontend', private: true }),
|
|
538
|
+
)
|
|
539
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
540
|
+
await mkdir(path.join(root, 'ui'), { recursive: true })
|
|
541
|
+
await writeFile(path.join(root, 'ui/index.ts'), 'export {}\n')
|
|
542
|
+
await writeFile(path.join(root, 'astrale.config.ts'), 'export default {}\n')
|
|
543
|
+
await writeFile(path.join(root, 'pnpm-workspace.yaml'), "packages:\n - 'frontend'\n")
|
|
544
|
+
await mkdir(path.join(root, 'components'), { recursive: true })
|
|
545
|
+
await writeFile(
|
|
546
|
+
path.join(root, 'components/package.json'),
|
|
547
|
+
JSON.stringify({ name: '@astrale-os/ui', private: true, version: '0.3.0-beta.0' }),
|
|
548
|
+
)
|
|
549
|
+
const workspaceBefore = await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')
|
|
550
|
+
|
|
551
|
+
await expect(
|
|
552
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
553
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
554
|
+
expect(await readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8')).toBe(workspaceBefore)
|
|
555
|
+
expect(
|
|
556
|
+
JSON.parse(await readFile(path.join(root, 'components/package.json'), 'utf8')).name,
|
|
557
|
+
).toBe('@astrale-os/ui')
|
|
558
|
+
})
|
|
559
|
+
|
|
279
560
|
test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
|
|
280
561
|
const root = await fixture()
|
|
281
562
|
const outside = await fixture()
|
|
@@ -416,8 +697,7 @@ describe('UI source operations', () => {
|
|
|
416
697
|
})
|
|
417
698
|
|
|
418
699
|
test('add dry-run invokes the exact shadcn version and does not advance the lock', async () => {
|
|
419
|
-
const root = await
|
|
420
|
-
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
700
|
+
const root = await lockedFixture()
|
|
421
701
|
const before = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
|
|
422
702
|
const calls: Array<{ file: string; args: string[] }> = []
|
|
423
703
|
const result = await addUi(
|
|
@@ -445,8 +725,7 @@ describe('UI source operations', () => {
|
|
|
445
725
|
})
|
|
446
726
|
|
|
447
727
|
test('successful add records installed file digests and doctor detects later edits', async () => {
|
|
448
|
-
const root = await
|
|
449
|
-
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
728
|
+
const root = await lockedFixture()
|
|
450
729
|
await writeFile(
|
|
451
730
|
path.join(root, 'components.json'),
|
|
452
731
|
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
@@ -499,9 +778,80 @@ describe('UI source operations', () => {
|
|
|
499
778
|
).rejects.toBeInstanceOf(UiError)
|
|
500
779
|
})
|
|
501
780
|
|
|
781
|
+
test('restores the exact locked UI dependency after shadcn applies its compatible range', async () => {
|
|
782
|
+
const root = await lockedFixture()
|
|
783
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
784
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
785
|
+
|
|
786
|
+
await addUi(
|
|
787
|
+
['pattern/chart/line/basic'],
|
|
788
|
+
{ project: root, yes: true },
|
|
789
|
+
{
|
|
790
|
+
fetcher: mockFetch(),
|
|
791
|
+
runner: async (file, args) => {
|
|
792
|
+
calls.push({ file, args })
|
|
793
|
+
if (args[0] === 'dlx') {
|
|
794
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
795
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
796
|
+
const manifestPath = path.join(root, 'package.json')
|
|
797
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
798
|
+
manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
|
|
799
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
800
|
+
}
|
|
801
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
expect(calls).toEqual([
|
|
807
|
+
expect.objectContaining({ file: 'pnpm', args: expect.arrayContaining(['dlx']) }),
|
|
808
|
+
{ file: 'pnpm', args: ['install'] },
|
|
809
|
+
])
|
|
810
|
+
expect(
|
|
811
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies[
|
|
812
|
+
'@astrale-os/ui'
|
|
813
|
+
],
|
|
814
|
+
).toBe('0.3.0-beta.0')
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
test('rolls back item and package state when restoring the locked dependency fails', async () => {
|
|
818
|
+
const root = await lockedFixture()
|
|
819
|
+
const manifestPath = path.join(root, 'package.json')
|
|
820
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
821
|
+
const installed = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
822
|
+
const manifestBefore = await readFile(manifestPath, 'utf8')
|
|
823
|
+
const lockBefore = await readFile(lockPath, 'utf8')
|
|
824
|
+
|
|
825
|
+
await expect(
|
|
826
|
+
addUi(
|
|
827
|
+
['pattern/chart/line/basic'],
|
|
828
|
+
{ project: root, yes: true },
|
|
829
|
+
{
|
|
830
|
+
fetcher: mockFetch(),
|
|
831
|
+
runner: async (_file, args) => {
|
|
832
|
+
if (args[0] === 'dlx') {
|
|
833
|
+
await mkdir(path.dirname(installed), { recursive: true })
|
|
834
|
+
await writeFile(installed, 'export const Chart = true\n')
|
|
835
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
836
|
+
manifest.dependencies['@astrale-os/ui'] = '^0.3.0-beta.0'
|
|
837
|
+
await writeFile(manifestPath, JSON.stringify(manifest))
|
|
838
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
839
|
+
}
|
|
840
|
+
await writeFile(path.join(root, 'pnpm-lock.yaml'), 'partial lock\n')
|
|
841
|
+
return { code: 1, stdout: '', stderr: 'registry timeout' }
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
),
|
|
845
|
+
).rejects.toMatchObject({ code: 'UI_DEPENDENCY_INSTALL_FAILED' })
|
|
846
|
+
|
|
847
|
+
expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore)
|
|
848
|
+
expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
|
|
849
|
+
expect(await Bun.file(installed).exists()).toBe(false)
|
|
850
|
+
expect(await Bun.file(path.join(root, 'pnpm-lock.yaml')).exists()).toBe(false)
|
|
851
|
+
})
|
|
852
|
+
|
|
502
853
|
test('preflights symlink targets without invoking shadcn', async () => {
|
|
503
|
-
const root = await
|
|
504
|
-
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
854
|
+
const root = await lockedFixture()
|
|
505
855
|
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-outside-'))
|
|
506
856
|
temporary.push(outside)
|
|
507
857
|
await symlink(outside, path.join(root, 'components'))
|
|
@@ -523,8 +873,7 @@ describe('UI source operations', () => {
|
|
|
523
873
|
})
|
|
524
874
|
|
|
525
875
|
test('restores declared files and package state after a partial shadcn failure', async () => {
|
|
526
|
-
const root = await
|
|
527
|
-
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
876
|
+
const root = await lockedFixture()
|
|
528
877
|
const first = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
|
|
529
878
|
const second = path.join(root, 'components/astrale/pattern/chart/summary.tsx')
|
|
530
879
|
await mkdir(path.dirname(first), { recursive: true })
|
|
@@ -537,7 +886,7 @@ describe('UI source operations', () => {
|
|
|
537
886
|
files: [
|
|
538
887
|
registry.items[0]!.files[0]!,
|
|
539
888
|
{
|
|
540
|
-
path: '
|
|
889
|
+
path: 'summary.tsx',
|
|
541
890
|
type: 'registry:component',
|
|
542
891
|
target: 'components/astrale/pattern/chart/summary.tsx',
|
|
543
892
|
},
|
|
@@ -566,8 +915,7 @@ describe('UI source operations', () => {
|
|
|
566
915
|
})
|
|
567
916
|
|
|
568
917
|
test('overwrite requires explicit yes confirmation before invoking shadcn', async () => {
|
|
569
|
-
const root = await
|
|
570
|
-
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
918
|
+
const root = await lockedFixture()
|
|
571
919
|
let invoked = false
|
|
572
920
|
await expect(
|
|
573
921
|
addUi(
|
|
@@ -587,8 +935,7 @@ describe('UI source operations', () => {
|
|
|
587
935
|
|
|
588
936
|
/** @evidence TEST-CLI-UI-EXACT-ITEM-SOURCE */
|
|
589
937
|
test('rejects a built item that differs from the admitted release index before invoking shadcn', async () => {
|
|
590
|
-
const root = await
|
|
591
|
-
await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
|
|
938
|
+
const root = await lockedFixture()
|
|
592
939
|
const fallback = mockFetch()
|
|
593
940
|
let invoked = false
|
|
594
941
|
const malformed = (async (input: string | URL | Request, init?: RequestInit) => {
|
package/src/ui/operations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access, lstat, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { isSeq, parseDocument } from 'yaml'
|
|
3
4
|
|
|
4
5
|
import { digest, parseUiLock, readUiLock } from './lock'
|
|
5
6
|
import {
|
|
@@ -42,6 +43,140 @@ function manifestDependencies(manifest: Record<string, unknown>): Record<string,
|
|
|
42
43
|
return { ...(manifest.dependencies as Record<string, string> | undefined) }
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function domainRegistryPackagePath(project: UiProject): string {
|
|
47
|
+
return path.join(project.root, 'components/package.json')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pnpmWorkspacePath(project: UiProject): string {
|
|
51
|
+
return path.join(project.root, 'pnpm-workspace.yaml')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function domainRegistryPackageName(manifest: Record<string, unknown>): string {
|
|
55
|
+
const name = typeof manifest.name === 'string' ? manifest.name : 'astrale-domain'
|
|
56
|
+
return name + '-ui-registry'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appendWorkspace(manifest: Record<string, unknown>, workspace: string): void {
|
|
60
|
+
const configured = manifest.workspaces
|
|
61
|
+
if (configured === undefined) {
|
|
62
|
+
manifest.workspaces = [workspace]
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(configured) && configured.every((value) => typeof value === 'string')) {
|
|
66
|
+
if (!configured.includes(workspace)) manifest.workspaces = [...configured, workspace]
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (configured && typeof configured === 'object') {
|
|
70
|
+
const candidate = configured as { packages?: unknown }
|
|
71
|
+
if (
|
|
72
|
+
Array.isArray(candidate.packages) &&
|
|
73
|
+
candidate.packages.every((value) => typeof value === 'string')
|
|
74
|
+
) {
|
|
75
|
+
if (!candidate.packages.includes(workspace)) {
|
|
76
|
+
manifest.workspaces = { ...candidate, packages: [...candidate.packages, workspace] }
|
|
77
|
+
}
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new UiError('UI_PROJECT_UNSUPPORTED', 'package.json has an invalid workspaces field.')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function appendPnpmWorkspace(project: UiProject, workspace: string): Promise<void> {
|
|
85
|
+
const target = pnpmWorkspacePath(project)
|
|
86
|
+
const source = await readOptional(target)
|
|
87
|
+
if (source === undefined) {
|
|
88
|
+
await writeFile(target, "packages:\n - '" + workspace + "'\n", 'utf8')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const document = parseDocument(source)
|
|
92
|
+
if (document.errors.length > 0) {
|
|
93
|
+
throw new UiError('UI_PROJECT_UNSUPPORTED', 'pnpm-workspace.yaml is not valid YAML.')
|
|
94
|
+
}
|
|
95
|
+
const packages = document.get('packages', true)
|
|
96
|
+
if (!isSeq(packages)) {
|
|
97
|
+
throw new UiError(
|
|
98
|
+
'UI_PROJECT_UNSUPPORTED',
|
|
99
|
+
'pnpm-workspace.yaml must declare a packages sequence.',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const values = packages.toJSON()
|
|
103
|
+
if (!Array.isArray(values) || !values.every((value) => typeof value === 'string')) {
|
|
104
|
+
throw new UiError(
|
|
105
|
+
'UI_PROJECT_UNSUPPORTED',
|
|
106
|
+
'pnpm-workspace.yaml must declare a packages sequence.',
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
if (!values.includes(workspace)) packages.add(workspace)
|
|
110
|
+
await writeFile(target, String(document), 'utf8')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function hasDomainRegistryWorkspace(project: UiProject): Promise<boolean> {
|
|
114
|
+
if (!project.isAstraleDomain || !(await exists(domainRegistryPackagePath(project)))) return false
|
|
115
|
+
const registryManifest = JSON.parse(
|
|
116
|
+
await readFile(domainRegistryPackagePath(project), 'utf8'),
|
|
117
|
+
) as Record<string, unknown>
|
|
118
|
+
if (
|
|
119
|
+
registryManifest.private !== true ||
|
|
120
|
+
typeof registryManifest.name !== 'string' ||
|
|
121
|
+
registryManifest.name === UI_PACKAGE ||
|
|
122
|
+
registryManifest.name === project.packageJson.name
|
|
123
|
+
) {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
if (project.manager === 'pnpm') {
|
|
127
|
+
const source = await readOptional(pnpmWorkspacePath(project))
|
|
128
|
+
if (source === undefined) return false
|
|
129
|
+
const document = parseDocument(source)
|
|
130
|
+
if (document.errors.length > 0) return false
|
|
131
|
+
const packages = document.get('packages', true)
|
|
132
|
+
const values = isSeq(packages) ? packages.toJSON() : undefined
|
|
133
|
+
return Array.isArray(values) && values.includes('components')
|
|
134
|
+
}
|
|
135
|
+
const configured = project.packageJson.workspaces
|
|
136
|
+
const workspaces = Array.isArray(configured)
|
|
137
|
+
? configured
|
|
138
|
+
: configured && typeof configured === 'object'
|
|
139
|
+
? (configured as { packages?: unknown }).packages
|
|
140
|
+
: undefined
|
|
141
|
+
return Array.isArray(workspaces) && workspaces.includes('components')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function writeDomainRegistryWorkspace(
|
|
145
|
+
project: UiProject,
|
|
146
|
+
manifest: Record<string, unknown>,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
if (!project.isAstraleDomain) return
|
|
149
|
+
if (project.manager === 'pnpm') await appendPnpmWorkspace(project, 'components')
|
|
150
|
+
else appendWorkspace(manifest, 'components')
|
|
151
|
+
const target = domainRegistryPackagePath(project)
|
|
152
|
+
const existing = await readOptional(target)
|
|
153
|
+
const registryManifest = existing
|
|
154
|
+
? (JSON.parse(existing) as Record<string, unknown>)
|
|
155
|
+
: { name: domainRegistryPackageName(manifest) }
|
|
156
|
+
if (registryManifest.private === false) {
|
|
157
|
+
throw new UiError(
|
|
158
|
+
'UI_PROJECT_UNSUPPORTED',
|
|
159
|
+
'The Astrale registry source workspace must remain private.',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
registryManifest.name === UI_PACKAGE ||
|
|
164
|
+
registryManifest.name === manifest.name ||
|
|
165
|
+
(registryManifest.name !== undefined && typeof registryManifest.name !== 'string')
|
|
166
|
+
) {
|
|
167
|
+
throw new UiError(
|
|
168
|
+
'UI_PROJECT_UNSUPPORTED',
|
|
169
|
+
'The Astrale registry source workspace must have a distinct package name.',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
await writeJson(target, {
|
|
173
|
+
...registryManifest,
|
|
174
|
+
name: registryManifest.name ?? domainRegistryPackageName(manifest),
|
|
175
|
+
private: true,
|
|
176
|
+
type: registryManifest.type ?? 'module',
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
45
180
|
export type InitUiOptions = {
|
|
46
181
|
path?: string
|
|
47
182
|
preset?: UiPreset
|
|
@@ -73,7 +208,8 @@ export async function initUi(
|
|
|
73
208
|
(!options.preset || options.preset === lock.preset) &&
|
|
74
209
|
css.includes(UI_PACKAGE + '/theme.css') &&
|
|
75
210
|
css.includes(UI_PACKAGE + '/presets/' + lock.preset + '.css') &&
|
|
76
|
-
components?.style === 'base-nova'
|
|
211
|
+
components?.style === 'base-nova' &&
|
|
212
|
+
(!project.isAstraleDomain || (await hasDomainRegistryWorkspace(project)))
|
|
77
213
|
if (!desired) {
|
|
78
214
|
throw new UiError(
|
|
79
215
|
'UI_ITEM_CONFLICT',
|
|
@@ -96,6 +232,8 @@ export async function initUi(
|
|
|
96
232
|
cssRelative,
|
|
97
233
|
'components.json',
|
|
98
234
|
'astrale-ui.lock.json',
|
|
235
|
+
...(project.isAstraleDomain ? ['components/package.json'] : []),
|
|
236
|
+
...(project.isAstraleDomain && project.manager === 'pnpm' ? ['pnpm-workspace.yaml'] : []),
|
|
99
237
|
...(project.lockPath ? [projectRelative(project, project.lockPath)] : []),
|
|
100
238
|
],
|
|
101
239
|
tooling: {
|
|
@@ -107,11 +245,20 @@ export async function initUi(
|
|
|
107
245
|
}
|
|
108
246
|
if (options.dryRun) return plan
|
|
109
247
|
|
|
248
|
+
if (project.isAstraleDomain) {
|
|
249
|
+
await assertSafePlannedTarget(project, 'components/package.json')
|
|
250
|
+
if (project.manager === 'pnpm') {
|
|
251
|
+
await assertSafePlannedTarget(project, 'pnpm-workspace.yaml')
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
110
255
|
const mutationPaths = [
|
|
111
256
|
project.packageJsonPath,
|
|
112
257
|
project.cssPath,
|
|
113
258
|
project.componentsPath,
|
|
114
259
|
project.uiLockPath,
|
|
260
|
+
...(project.isAstraleDomain ? [domainRegistryPackagePath(project)] : []),
|
|
261
|
+
...(project.isAstraleDomain && project.manager === 'pnpm' ? [pnpmWorkspacePath(project)] : []),
|
|
115
262
|
...(project.lockPath ? [project.lockPath] : []),
|
|
116
263
|
]
|
|
117
264
|
const snapshots = new Map<string, string | undefined>()
|
|
@@ -123,6 +270,7 @@ export async function initUi(
|
|
|
123
270
|
...manifestDependencies(manifest),
|
|
124
271
|
[UI_PACKAGE]: release.version,
|
|
125
272
|
}
|
|
273
|
+
await writeDomainRegistryWorkspace(project, manifest)
|
|
126
274
|
await writeJson(project.packageJsonPath, manifest)
|
|
127
275
|
|
|
128
276
|
const currentCss = (await readOptional(project.cssPath)) ?? ''
|
|
@@ -214,6 +362,28 @@ export async function initUi(
|
|
|
214
362
|
}
|
|
215
363
|
}
|
|
216
364
|
|
|
365
|
+
async function pinUiDependency(
|
|
366
|
+
project: UiProject,
|
|
367
|
+
version: string,
|
|
368
|
+
runner: UiRunner,
|
|
369
|
+
): Promise<void> {
|
|
370
|
+
const manifest = JSON.parse(await readFile(project.packageJsonPath, 'utf8')) as Record<
|
|
371
|
+
string,
|
|
372
|
+
unknown
|
|
373
|
+
>
|
|
374
|
+
if (manifestDependencies(manifest)[UI_PACKAGE] === version) return
|
|
375
|
+
manifest.dependencies = { ...manifestDependencies(manifest), [UI_PACKAGE]: version }
|
|
376
|
+
await writeJson(project.packageJsonPath, manifest)
|
|
377
|
+
const result = await runner(project.manager, ['install'], project.root)
|
|
378
|
+
if (result.code !== 0) {
|
|
379
|
+
throw new UiError(
|
|
380
|
+
'UI_DEPENDENCY_INSTALL_FAILED',
|
|
381
|
+
project.manager + ' install failed while restoring the locked UI release.',
|
|
382
|
+
result.stderr.trim() || undefined,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
217
387
|
export async function listUi(
|
|
218
388
|
query: string | undefined,
|
|
219
389
|
options: { type?: string; limit?: number; version?: string },
|
|
@@ -323,6 +493,7 @@ export async function addUi(
|
|
|
323
493
|
throw new UiError('UI_TOOL_FAILED', 'The pinned shadcn operation omitted an item file.')
|
|
324
494
|
}
|
|
325
495
|
}
|
|
496
|
+
await pinUiDependency(project, lock.package.version, dependencies.runner ?? defaultUiRunner)
|
|
326
497
|
}
|
|
327
498
|
} catch (error) {
|
|
328
499
|
for (const [target, previous] of snapshots) {
|
package/src/ui/project.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type UiProject = {
|
|
|
12
12
|
cssPath: string
|
|
13
13
|
componentsPath: string
|
|
14
14
|
uiLockPath: string
|
|
15
|
+
isAstraleDomain: boolean
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const MANAGERS: readonly [string, PackageManager][] = [
|
|
@@ -173,6 +174,10 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
|
|
|
173
174
|
cssPath: path.join(root, cssRelative),
|
|
174
175
|
componentsPath,
|
|
175
176
|
uiLockPath: path.join(root, 'astrale-ui.lock.json'),
|
|
177
|
+
isAstraleDomain:
|
|
178
|
+
(await exists(path.join(root, 'astrale.config.ts'))) &&
|
|
179
|
+
(await exists(path.join(root, 'ui/index.ts'))) &&
|
|
180
|
+
(await exists(path.join(root, 'frontend/package.json'))),
|
|
176
181
|
}
|
|
177
182
|
}
|
|
178
183
|
|
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
|
|