@astrale-os/cli 1.0.0-beta.15 → 1.0.0-beta.17

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.15",
2581
+ version: "1.0.0-beta.17",
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) {
@@ -83768,6 +83769,108 @@ async function readOptional(target2) {
83768
83769
  function manifestDependencies(manifest) {
83769
83770
  return { ...manifest.dependencies };
83770
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
+ }
83771
83874
  async function initUi(options, dependencies = {}) {
83772
83875
  const project2 = await discoverUiProject(options.path);
83773
83876
  assertSupportedUiProject(project2);
@@ -83782,7 +83885,7 @@ async function initUi(options, dependencies = {}) {
83782
83885
  return;
83783
83886
  });
83784
83887
  const requestedVersion = options.version?.replace(/^v/u, "");
83785
- 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));
83786
83889
  if (!desired) {
83787
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.");
83788
83891
  }
@@ -83801,6 +83904,8 @@ async function initUi(options, dependencies = {}) {
83801
83904
  cssRelative,
83802
83905
  "components.json",
83803
83906
  "astrale-ui.lock.json",
83907
+ ...project2.isAstraleDomain ? ["components/package.json"] : [],
83908
+ ...project2.isAstraleDomain && project2.manager === "pnpm" ? ["pnpm-workspace.yaml"] : [],
83804
83909
  ...project2.lockPath ? [projectRelative(project2, project2.lockPath)] : []
83805
83910
  ],
83806
83911
  tooling: {
@@ -83812,11 +83917,19 @@ async function initUi(options, dependencies = {}) {
83812
83917
  };
83813
83918
  if (options.dryRun)
83814
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
+ }
83815
83926
  const mutationPaths = [
83816
83927
  project2.packageJsonPath,
83817
83928
  project2.cssPath,
83818
83929
  project2.componentsPath,
83819
83930
  project2.uiLockPath,
83931
+ ...project2.isAstraleDomain ? [domainRegistryPackagePath(project2)] : [],
83932
+ ...project2.isAstraleDomain && project2.manager === "pnpm" ? [pnpmWorkspacePath(project2)] : [],
83820
83933
  ...project2.lockPath ? [project2.lockPath] : []
83821
83934
  ];
83822
83935
  const snapshots = new Map;
@@ -83828,6 +83941,7 @@ async function initUi(options, dependencies = {}) {
83828
83941
  ...manifestDependencies(manifest),
83829
83942
  [UI_PACKAGE]: release.version
83830
83943
  };
83944
+ await writeDomainRegistryWorkspace(project2, manifest);
83831
83945
  await writeJson(project2.packageJsonPath, manifest);
83832
83946
  const currentCss = await readOptional(project2.cssPath) ?? "";
83833
83947
  const imports = [
@@ -83900,6 +84014,17 @@ async function initUi(options, dependencies = {}) {
83900
84014
  throw error52;
83901
84015
  }
83902
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
+ }
83903
84028
  async function listUi(query4, options, dependencies = {}) {
83904
84029
  const release = await resolveUiRelease(options.version, dependencies.fetcher);
83905
84030
  const needle = query4?.trim().toLowerCase();
@@ -83965,6 +84090,7 @@ async function addUi(addresses, options, dependencies = {}) {
83965
84090
  throw new UiError("UI_TOOL_FAILED", "The pinned shadcn operation omitted an item file.");
83966
84091
  }
83967
84092
  }
84093
+ await pinUiDependency(project2, lock.package.version, dependencies.runner ?? defaultUiRunner);
83968
84094
  }
83969
84095
  } catch (error52) {
83970
84096
  for (const [target2, previous] of snapshots) {
@@ -84148,6 +84274,7 @@ async function writeJson(target2, value3) {
84148
84274
  `, "utf8");
84149
84275
  }
84150
84276
  var init_operations3 = __esm(() => {
84277
+ init_dist();
84151
84278
  init_lock();
84152
84279
  init_model7();
84153
84280
  init_project3();
@@ -84281,7 +84408,7 @@ var init_add = __esm(() => {
84281
84408
  ],
84282
84409
  afterHelpText: `
84283
84410
  Installed source belongs to the application. Ordinary add never overwrites local edits.
84284
- Use diff first, then --overwrite --yes when replacement is intentional.
84411
+ Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
84285
84412
  `,
84286
84413
  action: async (items, options) => runUiCommand(options, async () => {
84287
84414
  let selected2 = items;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.15",
3
+ "version": "1.0.0-beta.17",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -25,7 +25,7 @@ export default {
25
25
  UI_JSON_OPTION,
26
26
  ],
27
27
  afterHelpText:
28
- '\nInstalled source belongs to the application. Ordinary add never overwrites local edits.\nUse diff first, then --overwrite --yes when replacement is intentional.\n',
28
+ '\nInstalled source belongs to the application. Ordinary add never overwrites local edits.\nRun astrale ui doctor before intentionally replacing local source with --overwrite --yes.\n',
29
29
  action: async (items: string[], options: Options) =>
30
30
  runUiCommand(options, async () => {
31
31
  let selected = items
@@ -312,6 +312,24 @@ describe('help contract — connect-only command surface', () => {
312
312
  })
313
313
 
314
314
  describe('help contract — UI is project tooling', () => {
315
+ test('add recovery guidance uses only registered UI commands', async () => {
316
+ const program = await buildProgram()
317
+ const uiAdd = program.commands
318
+ .find((command) => command.name() === 'ui')
319
+ ?.commands.find((command) => command.name() === 'add')
320
+ let help = ''
321
+ uiAdd?.configureOutput({
322
+ writeOut: (chunk) => {
323
+ help += chunk ?? ''
324
+ },
325
+ })
326
+ uiAdd?.outputHelp()
327
+
328
+ expect(help).toContain('astrale ui doctor')
329
+ expect(help).toContain('--overwrite --yes')
330
+ expect(help).not.toMatch(/\b(?:astrale ui )?diff\b/u)
331
+ })
332
+
315
333
  test('keeps the documented root version alias operational', async () => {
316
334
  const program = await buildProgram()
317
335
  let stdout = ''
@@ -94,7 +94,11 @@ async function fixture(): Promise<string> {
94
94
  JSON.stringify({
95
95
  name: 'fixture',
96
96
  private: true,
97
- 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
+ },
98
102
  packageManager: 'pnpm@11.13.1',
99
103
  }),
100
104
  )
@@ -102,6 +106,16 @@ async function fixture(): Promise<string> {
102
106
  return root
103
107
  }
104
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
+
105
119
  function lock(): UiLock {
106
120
  return {
107
121
  $schema: 'https://example.invalid/ui-lock.schema.json',
@@ -360,6 +374,10 @@ describe('UI initialization transaction', () => {
360
374
  JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
361
375
  )
362
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")
363
381
  const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
364
382
 
365
383
  await initUi(
@@ -375,8 +393,14 @@ describe('UI initialization transaction', () => {
375
393
  expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
376
394
  expect(
377
395
  JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
378
- ).toHaveProperty('@astrale-os/ui')
396
+ ).toHaveProperty('@astrale-os/ui', '0.3.0-beta.0')
379
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')
380
404
 
381
405
  await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
382
406
  const repeated = await initUi(
@@ -386,6 +410,153 @@ describe('UI initialization transaction', () => {
386
410
  expect(repeated.status).toBe('unchanged')
387
411
  })
388
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
+
389
560
  test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
390
561
  const root = await fixture()
391
562
  const outside = await fixture()
@@ -526,8 +697,7 @@ describe('UI source operations', () => {
526
697
  })
527
698
 
528
699
  test('add dry-run invokes the exact shadcn version and does not advance the lock', async () => {
529
- const root = await fixture()
530
- await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
700
+ const root = await lockedFixture()
531
701
  const before = await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')
532
702
  const calls: Array<{ file: string; args: string[] }> = []
533
703
  const result = await addUi(
@@ -555,8 +725,7 @@ describe('UI source operations', () => {
555
725
  })
556
726
 
557
727
  test('successful add records installed file digests and doctor detects later edits', async () => {
558
- const root = await fixture()
559
- await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
728
+ const root = await lockedFixture()
560
729
  await writeFile(
561
730
  path.join(root, 'components.json'),
562
731
  JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
@@ -609,9 +778,80 @@ describe('UI source operations', () => {
609
778
  ).rejects.toBeInstanceOf(UiError)
610
779
  })
611
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
+
612
853
  test('preflights symlink targets without invoking shadcn', async () => {
613
- const root = await fixture()
614
- await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
854
+ const root = await lockedFixture()
615
855
  const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-outside-'))
616
856
  temporary.push(outside)
617
857
  await symlink(outside, path.join(root, 'components'))
@@ -633,8 +873,7 @@ describe('UI source operations', () => {
633
873
  })
634
874
 
635
875
  test('restores declared files and package state after a partial shadcn failure', async () => {
636
- const root = await fixture()
637
- await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
876
+ const root = await lockedFixture()
638
877
  const first = path.join(root, 'components/astrale/pattern/chart/line-basic.tsx')
639
878
  const second = path.join(root, 'components/astrale/pattern/chart/summary.tsx')
640
879
  await mkdir(path.dirname(first), { recursive: true })
@@ -676,8 +915,7 @@ describe('UI source operations', () => {
676
915
  })
677
916
 
678
917
  test('overwrite requires explicit yes confirmation before invoking shadcn', async () => {
679
- const root = await fixture()
680
- await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
918
+ const root = await lockedFixture()
681
919
  let invoked = false
682
920
  await expect(
683
921
  addUi(
@@ -697,8 +935,7 @@ describe('UI source operations', () => {
697
935
 
698
936
  /** @evidence TEST-CLI-UI-EXACT-ITEM-SOURCE */
699
937
  test('rejects a built item that differs from the admitted release index before invoking shadcn', async () => {
700
- const root = await fixture()
701
- await writeFile(path.join(root, 'astrale-ui.lock.json'), JSON.stringify(lock()))
938
+ const root = await lockedFixture()
702
939
  const fallback = mockFetch()
703
940
  let invoked = false
704
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