@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 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.14",
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 direct = Array.isArray(source2.items) ? source2.items : [];
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.startsWith("/") || !include.endsWith("registry.json") || include.split("/").includes("..")) {
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
- const releaseRoot = RAW + "/" + commit + "/";
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
- return value3.length > 0 && !value3.startsWith("/") && !/^[A-Za-z]:[\\/]/u.test(value3) && !value3.includes("\\") && !value3.split("/").includes("..");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.14",
3
+ "version": "1.0.0-beta.16",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -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({ items: [item] })
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: 'registry/patterns/chart/line-basic.tsx',
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: { react: '19.2.8', 'react-dom': '19.2.8', tailwindcss: '4.3.3' },
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 fixture()
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 fixture()
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 fixture()
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 fixture()
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: 'registry/patterns/chart/summary.tsx',
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 fixture()
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 fixture()
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) => {
@@ -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 direct = Array.isArray(source.items) ? source.items : []
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.startsWith('/') ||
175
+ !isSafeRelative(include) ||
176
+ !/^[A-Za-z0-9._/-]+$/u.test(include) ||
171
177
  !include.endsWith('registry.json') ||
172
- include.split('/').includes('..')
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
- const releaseRoot = RAW + '/' + commit + '/'
178
- if (!url.startsWith(releaseRoot) || visited.has(url)) {
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
- !value.split('/').includes('..')
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