@astrale-os/cli 1.0.0-beta.13 → 1.0.0-beta.14

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.13",
2581
+ version: "1.0.0-beta.14",
2582
2582
  description: "Astrale CLI — connect to existing Astrale kernels",
2583
2583
  keywords: [
2584
2584
  "astrale",
@@ -83398,11 +83398,43 @@ var init_lock = __esm(() => {
83398
83398
  });
83399
83399
 
83400
83400
  // src/ui/project.ts
83401
- import { access as access2, lstat, readFile as readFile17 } from "node:fs/promises";
83401
+ import { access as access2, lstat, readFile as readFile17, realpath as realpath2 } from "node:fs/promises";
83402
83402
  import path5 from "node:path";
83403
83403
  async function exists(target2) {
83404
83404
  return access2(target2).then(() => true, () => false);
83405
83405
  }
83406
+ async function readManifest(target2) {
83407
+ try {
83408
+ return JSON.parse(await readFile17(target2, "utf8"));
83409
+ } catch (cause) {
83410
+ throw new UiError("UI_PROJECT_UNSUPPORTED", "package.json is not valid JSON.", undefined, {
83411
+ cause
83412
+ });
83413
+ }
83414
+ }
83415
+ function hasReactTailwind(manifest) {
83416
+ const dependencies = {
83417
+ ...manifest.dependencies,
83418
+ ...manifest.devDependencies,
83419
+ ...manifest.peerDependencies
83420
+ };
83421
+ return Boolean(dependencies.react && dependencies["react-dom"] && dependencies.tailwindcss);
83422
+ }
83423
+ async function assertPhysicalProjectPath(root, target2) {
83424
+ const physicalRoot = await realpath2(root);
83425
+ let existing = target2;
83426
+ while (!await exists(existing)) {
83427
+ const parent = path5.dirname(existing);
83428
+ if (parent === existing)
83429
+ break;
83430
+ existing = parent;
83431
+ }
83432
+ const physicalTarget = await realpath2(existing);
83433
+ const relative = path5.relative(physicalRoot, physicalTarget);
83434
+ if (relative === ".." || relative.startsWith(".." + path5.sep) || path5.isAbsolute(relative)) {
83435
+ throw new UiError("UI_PROJECT_UNSUPPORTED", "components.json CSS path escapes the project.");
83436
+ }
83437
+ }
83406
83438
  async function discoverUiProject(input = process.cwd()) {
83407
83439
  let root = path5.resolve(input);
83408
83440
  if (!await exists(root)) {
@@ -83410,7 +83442,19 @@ async function discoverUiProject(input = process.cwd()) {
83410
83442
  }
83411
83443
  if (!(await lstat(root)).isDirectory())
83412
83444
  root = path5.dirname(root);
83413
- while (!await exists(path5.join(root, "package.json"))) {
83445
+ while (true) {
83446
+ const manifestPath = path5.join(root, "package.json");
83447
+ if (await exists(manifestPath)) {
83448
+ const manifest = await readManifest(manifestPath);
83449
+ const parent2 = path5.dirname(root);
83450
+ if (hasReactTailwind(manifest) || parent2 === root)
83451
+ break;
83452
+ if (await exists(path5.join(parent2, "package.json"))) {
83453
+ root = parent2;
83454
+ continue;
83455
+ }
83456
+ break;
83457
+ }
83414
83458
  const parent = path5.dirname(root);
83415
83459
  if (parent === root) {
83416
83460
  throw new UiError("UI_PROJECT_UNSUPPORTED", "No package.json found from " + path5.resolve(input), "Run the command inside an existing React application or pass its path.");
@@ -83418,14 +83462,7 @@ async function discoverUiProject(input = process.cwd()) {
83418
83462
  root = parent;
83419
83463
  }
83420
83464
  const packageJsonPath = path5.join(root, "package.json");
83421
- let packageJson;
83422
- try {
83423
- packageJson = JSON.parse(await readFile17(packageJsonPath, "utf8"));
83424
- } catch (cause) {
83425
- throw new UiError("UI_PROJECT_UNSUPPORTED", "package.json is not valid JSON.", undefined, {
83426
- cause
83427
- });
83428
- }
83465
+ const packageJson = await readManifest(packageJsonPath);
83429
83466
  let manager = "npm";
83430
83467
  let lockPath;
83431
83468
  for (const [file2, candidate2] of MANAGERS) {
@@ -83452,9 +83489,34 @@ async function discoverUiProject(input = process.cwd()) {
83452
83489
  }[manager];
83453
83490
  lockPath = path5.join(root, expectedLock);
83454
83491
  }
83455
- const cssCandidates = ["src/index.css", "src/app.css", "app/globals.css", "src/styles.css"];
83492
+ const rootCssCandidates = ["src/index.css", "src/app.css", "app/globals.css", "src/styles.css"];
83493
+ const frontendCssCandidates = [
83494
+ "frontend/src/index.css",
83495
+ "frontend/src/app.css",
83496
+ "frontend/src/styles.css"
83497
+ ];
83498
+ const componentsPath = path5.join(root, "components.json");
83499
+ const configuredCss = await readFile17(componentsPath, "utf8").then((value3) => {
83500
+ const components = JSON.parse(value3);
83501
+ const css = components.tailwind?.css;
83502
+ if (typeof css !== "string" || css.length === 0)
83503
+ return;
83504
+ const target2 = path5.resolve(root, css);
83505
+ const relative = path5.relative(root, target2);
83506
+ if (relative === ".." || relative.startsWith(".." + path5.sep) || path5.isAbsolute(relative)) {
83507
+ throw new UiError("UI_PROJECT_UNSUPPORTED", "components.json CSS path escapes the project.");
83508
+ }
83509
+ return { relative: relative.split(path5.sep).join("/"), target: target2 };
83510
+ }).catch((error52) => {
83511
+ if (error52 instanceof UiError)
83512
+ throw error52;
83513
+ return;
83514
+ });
83515
+ const configuredCssRelative = configuredCss?.relative;
83516
+ const cssCandidates = configuredCssRelative !== undefined ? [configuredCssRelative] : await exists(path5.join(root, "frontend/package.json")) ? [...frontendCssCandidates, ...rootCssCandidates] : [...rootCssCandidates, ...frontendCssCandidates];
83456
83517
  const resolvedCss = await Promise.all(cssCandidates.map(async (file2) => await exists(path5.join(root, file2)) ? file2 : undefined));
83457
- const cssRelative = resolvedCss.find(Boolean) ?? "src/astrale-ui.css";
83518
+ const cssRelative = configuredCssRelative ?? resolvedCss.find(Boolean) ?? "src/astrale-ui.css";
83519
+ await assertPhysicalProjectPath(root, path5.join(root, cssRelative));
83458
83520
  return {
83459
83521
  root,
83460
83522
  packageJsonPath,
@@ -83462,7 +83524,7 @@ async function discoverUiProject(input = process.cwd()) {
83462
83524
  manager,
83463
83525
  lockPath,
83464
83526
  cssPath: path5.join(root, cssRelative),
83465
- componentsPath: path5.join(root, "components.json"),
83527
+ componentsPath,
83466
83528
  uiLockPath: path5.join(root, "astrale-ui.lock.json")
83467
83529
  };
83468
83530
  }
@@ -83549,11 +83611,14 @@ async function json4(fetcher, url3, label) {
83549
83611
  }
83550
83612
  }
83551
83613
  async function resolveUiRelease(requested, fetcher = fetch) {
83552
- const versionDocument = requested ? { version: requested.replace(/^v/u, "") } : await json4(fetcher, NPM_PACKAGE + "/latest", "npm UI release");
83614
+ const versionDocument = requested ? { version: requested.replace(/^v/u, "") } : await json4(fetcher, NPM_PACKAGE + "/beta", "npm UI release");
83553
83615
  const version2 = versionDocument.version;
83554
83616
  if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version2)) {
83555
83617
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "Invalid UI release version: " + version2);
83556
83618
  }
83619
+ if (!requested && !/-beta\.\d+$/u.test(version2)) {
83620
+ throw new UiError("UI_REGISTRY_UNAVAILABLE", "Invalid UI beta release version: " + version2);
83621
+ }
83557
83622
  const ref2 = "v" + version2;
83558
83623
  const reference4 = await json4(fetcher, GITHUB_API + "/git/ref/tags/" + encodeURIComponent(ref2), "UI ref " + ref2);
83559
83624
  const commit = reference4.object.type === "commit" ? reference4.object.sha : (await json4(fetcher, reference4.object.url, "annotated UI tag " + ref2)).object.sha;
@@ -83656,7 +83721,7 @@ var init_runner = __esm(() => {
83656
83721
  });
83657
83722
 
83658
83723
  // src/ui/operations.ts
83659
- import { access as access3, lstat as lstat2, mkdir as mkdir10, readFile as readFile18, realpath as realpath2, rm as rm3, writeFile as writeFile6 } from "node:fs/promises";
83724
+ import { access as access3, lstat as lstat2, mkdir as mkdir10, readFile as readFile18, realpath as realpath3, rm as rm3, writeFile as writeFile6 } from "node:fs/promises";
83660
83725
  import path6 from "node:path";
83661
83726
  async function exists2(target2) {
83662
83727
  return access3(target2).then(() => true, () => false);
@@ -84014,8 +84079,8 @@ async function safeTarget(project2, relative) {
84014
84079
  }
84015
84080
  const target2 = path6.resolve(project2.root, relative);
84016
84081
  projectRelative(project2, target2);
84017
- const parent = await realpath2(path6.dirname(target2));
84018
- const root = await realpath2(project2.root);
84082
+ const parent = await realpath3(path6.dirname(target2));
84083
+ const root = await realpath3(project2.root);
84019
84084
  if (parent !== root && !parent.startsWith(root + path6.sep)) {
84020
84085
  throw new UiError("UI_LOCK_INVALID", "Registry target escapes through a symlink: " + relative);
84021
84086
  }
@@ -84030,7 +84095,7 @@ async function assertSafePlannedTarget(project2, relative) {
84030
84095
  }
84031
84096
  const target2 = path6.resolve(project2.root, relative);
84032
84097
  projectRelative(project2, target2);
84033
- const root = await realpath2(project2.root);
84098
+ const root = await realpath3(project2.root);
84034
84099
  const physicalTarget = path6.resolve(root, path6.relative(project2.root, target2));
84035
84100
  let current = root;
84036
84101
  for (const segment of path6.relative(root, physicalTarget).split(path6.sep)) {
@@ -88103,7 +88168,7 @@ function registerGroup(parent, group) {
88103
88168
  // src/program/build.ts
88104
88169
  async function buildProgram() {
88105
88170
  const program4 = new Command2;
88106
- program4.name("astrale").description("Astrale CLI — connect to existing Astrale kernels").version(package_default.version).showSuggestionAfterError(true).addOption(new Option2("--ci", "Machine mode: no prompts, structured errors on stderr")).addOption(new Option2("--no-prompt", "Disable interactive prompts")).action(async () => {
88171
+ program4.name("astrale").description("Astrale CLI — connect to existing Astrale kernels").version(package_default.version, "-V, --cli-version", "output the CLI version (root alias: --version)").showSuggestionAfterError(true).addOption(new Option2("--ci", "Machine mode: no prompts, structured errors on stderr")).addOption(new Option2("--no-prompt", "Disable interactive prompts")).action(async () => {
88107
88172
  if (process.stdin.isTTY && process.stdout.isTTY) {
88108
88173
  const { shouldAutostartSetup: shouldAutostartSetup2 } = await Promise.resolve().then(() => (init_engine(), exports_engine));
88109
88174
  if (await shouldAutostartSetup2()) {
@@ -88263,6 +88328,16 @@ Examples:
88263
88328
  `);
88264
88329
  return program4;
88265
88330
  }
88331
+ // src/program/argv.ts
88332
+ function normalizeRootVersionArgv(argv) {
88333
+ const normalized = [...argv];
88334
+ const commandIndex = normalized.findIndex((token, index3) => index3 >= 2 && !token.startsWith("-"));
88335
+ const versionIndex = normalized.indexOf("--version", 2);
88336
+ if (versionIndex !== -1 && (commandIndex === -1 || versionIndex < commandIndex)) {
88337
+ normalized[versionIndex] = "--cli-version";
88338
+ }
88339
+ return normalized;
88340
+ }
88266
88341
  // src/telemetry/recorder.ts
88267
88342
  import { appendFileSync as appendFileSync2 } from "node:fs";
88268
88343
 
@@ -88378,7 +88453,15 @@ function ensureSession(cwd) {
88378
88453
  init_settings();
88379
88454
  init_store();
88380
88455
  var NOOP = () => {};
88381
- var HELP_VERSION = new Set(["-h", "--help", "-V", "--version", "help", "version"]);
88456
+ var HELP_VERSION = new Set([
88457
+ "-h",
88458
+ "--help",
88459
+ "-V",
88460
+ "--version",
88461
+ "--cli-version",
88462
+ "help",
88463
+ "version"
88464
+ ]);
88382
88465
  function isHelpOrVersion(args) {
88383
88466
  return args.every((a) => HELP_VERSION.has(a));
88384
88467
  }
@@ -88500,14 +88583,15 @@ var errorName;
88500
88583
  if (finalize2)
88501
88584
  process.on("exit", (code) => finalize2(code ?? 0, errorName));
88502
88585
  maybeTriggerAnalysis(process.argv);
88503
- configureInvocation(process.argv);
88586
+ var argv = normalizeRootVersionArgv(process.argv);
88587
+ configureInvocation(argv);
88504
88588
  var program4 = await buildProgram();
88505
88589
  overrideExits(program4);
88506
88590
  program4.configureOutput({ writeErr: () => {
88507
88591
  return;
88508
88592
  } });
88509
88593
  try {
88510
- await program4.parseAsync();
88594
+ await program4.parseAsync(argv);
88511
88595
  } catch (error52) {
88512
88596
  if (error52 instanceof CommanderError) {
88513
88597
  if (error52.exitCode === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.13",
3
+ "version": "1.0.0-beta.14",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -72,6 +72,21 @@ describe('UI command machine contracts', () => {
72
72
  expect(JSON.parse(stdout)).toEqual([item])
73
73
  })
74
74
 
75
+ test('list forwards an explicit release without consulting the beta channel', async () => {
76
+ const seen: string[] = []
77
+ globalThis.fetch = mockFetch(seen)
78
+ const action = listCommand.action as (
79
+ query: string | undefined,
80
+ options: { json?: boolean; limit?: string; version?: string },
81
+ ) => Promise<void>
82
+
83
+ await action('line-basic', { json: true, limit: '100', version: '0.3.0-beta.1' })
84
+
85
+ expect(JSON.parse(stdout)).toEqual([item])
86
+ expect(seen.some((url) => url.endsWith('/@astrale-os/ui/beta'))).toBe(false)
87
+ expect(seen.some((url) => url.includes('/git/ref/tags/v0.3.0-beta.1'))).toBe(true)
88
+ })
89
+
75
90
  test('add rejects missing items without prompting in machine mode', async () => {
76
91
  process.argv = ['node', 'astrale', '--no-prompt', 'ui', 'add']
77
92
  const add = addCommand.action as (items: string[], options: { json?: boolean }) => Promise<void>
@@ -99,10 +114,11 @@ describe('UI command machine contracts', () => {
99
114
  })
100
115
  })
101
116
 
102
- function mockFetch(): typeof fetch {
117
+ function mockFetch(seen: string[] = []): typeof fetch {
103
118
  return (async (input: string | URL | Request) => {
104
119
  const url = String(input)
105
- if (url.endsWith('/@astrale-os/ui/latest')) return Response.json({ version: '0.3.0-beta.0' })
120
+ seen.push(url)
121
+ if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0-beta.1' })
106
122
  if (url.includes('/git/ref/tags/')) {
107
123
  return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
108
124
  }
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto'
5
5
  import { existsSync, readFileSync } from 'node:fs'
6
6
  import { join } from 'node:path'
7
7
 
8
- import { buildProgram } from '../index'
8
+ import { buildProgram, normalizeRootVersionArgv } from '../index'
9
9
 
10
10
  // Help output is the public CLI contract: version, spec anchors, and skill mirror stay in sync.
11
11
 
@@ -195,7 +195,7 @@ describe('program composition', () => {
195
195
  'whoami',
196
196
  ])
197
197
  expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
198
- '873d096c4e99f29ab1db17bec0abc4ad2de613f5e44f9cba97c95b052c8b5a98',
198
+ 'd37c3d80b3067b3553b8527b10bf1c5632922d624b3d6072c3b03e7a507efc4f',
199
199
  )
200
200
  })
201
201
 
@@ -312,6 +312,99 @@ describe('help contract — connect-only command surface', () => {
312
312
  })
313
313
 
314
314
  describe('help contract — UI is project tooling', () => {
315
+ test('keeps the documented root version alias operational', async () => {
316
+ const program = await buildProgram()
317
+ let stdout = ''
318
+ program.exitOverride().configureOutput({
319
+ writeOut: (chunk) => {
320
+ stdout += chunk ?? ''
321
+ },
322
+ })
323
+
324
+ await expect(
325
+ program.parseAsync(normalizeRootVersionArgv(['node', 'astrale', '--version'])),
326
+ ).rejects.toMatchObject({ code: 'commander.version' })
327
+ expect(stdout.trim()).toBe(program.version() ?? '')
328
+ expect(program.helpInformation()).toMatch(/root alias:\s+--version/u)
329
+ })
330
+
331
+ test('routes root and subcommand version flags to their exact owners', async () => {
332
+ const program = await buildProgram()
333
+ const uiList = program.commands
334
+ .find((command) => command.name() === 'ui')
335
+ ?.commands.find((command) => command.name() === 'list')
336
+ let observedVersion: unknown
337
+ uiList?.action((_query, options) => {
338
+ observedVersion = options.version
339
+ })
340
+
341
+ const uiArgv = ['node', 'astrale', 'ui', 'list', 'chart', '--version', '0.3.0-beta.1']
342
+ expect(normalizeRootVersionArgv(uiArgv)).toEqual(uiArgv)
343
+ expect(normalizeRootVersionArgv(['node', 'astrale', '--version'])).toEqual([
344
+ 'node',
345
+ 'astrale',
346
+ '--cli-version',
347
+ ])
348
+ expect(normalizeRootVersionArgv(['node', 'astrale', '--ci', '--version'])).toEqual([
349
+ 'node',
350
+ 'astrale',
351
+ '--ci',
352
+ '--cli-version',
353
+ ])
354
+
355
+ await program.parseAsync(uiArgv)
356
+
357
+ expect(observedVersion).toBe('0.3.0-beta.1')
358
+ })
359
+
360
+ test('routes explicit versions with positional inputs for UI init and update', async () => {
361
+ const program = await buildProgram()
362
+ const uiInit = program.commands
363
+ .find((command) => command.name() === 'ui')
364
+ ?.commands.find((command) => command.name() === 'init')
365
+ const update = program.commands.find((command) => command.name() === 'update')
366
+ let initializedPath: unknown
367
+ let initializedVersion: unknown
368
+ let updateVersion: unknown
369
+ uiInit?.action((projectPath, options) => {
370
+ initializedPath = projectPath
371
+ initializedVersion = options.version
372
+ })
373
+ update?.action((options) => {
374
+ updateVersion = options.version
375
+ })
376
+
377
+ await program.parseAsync([
378
+ 'node',
379
+ 'astrale',
380
+ 'ui',
381
+ 'init',
382
+ './app',
383
+ '--version',
384
+ '0.3.0-beta.1',
385
+ ])
386
+ expect(initializedPath).toBe('./app')
387
+ expect(initializedVersion).toBe('0.3.0-beta.1')
388
+
389
+ await program.parseAsync(['node', 'astrale', 'update', '--version', '1.0.0-beta.13'])
390
+ expect(updateVersion).toBe('1.0.0-beta.13')
391
+ })
392
+
393
+ test('continues to admit global machine flags after a subcommand', async () => {
394
+ const program = await buildProgram()
395
+ const uiList = program.commands
396
+ .find((command) => command.name() === 'ui')
397
+ ?.commands.find((command) => command.name() === 'list')
398
+ let invoked = false
399
+ uiList?.action(() => {
400
+ invoked = true
401
+ })
402
+
403
+ await program.parseAsync(['node', 'astrale', 'ui', 'list', '--ci', '--no-prompt'])
404
+
405
+ expect(invoked).toBe(true)
406
+ })
407
+
315
408
  test('UI commands are local-only and add accepts zero or more canonical addresses', async () => {
316
409
  const program = await buildProgram()
317
410
  const ui = program.commands.find((command) => command.name() === 'ui')
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Keep `astrale --version` as the familiar root spelling without claiming the
3
+ * same flag after a subcommand. Commands such as `ui list` and `update` own
4
+ * their release `--version` value.
5
+ */
6
+ export function normalizeRootVersionArgv(argv: readonly string[]): string[] {
7
+ const normalized = [...argv]
8
+ const commandIndex = normalized.findIndex((token, index) => index >= 2 && !token.startsWith('-'))
9
+ const versionIndex = normalized.indexOf('--version', 2)
10
+ if (versionIndex !== -1 && (commandIndex === -1 || versionIndex < commandIndex)) {
11
+ normalized[versionIndex] = '--cli-version'
12
+ }
13
+ return normalized
14
+ }
@@ -19,7 +19,7 @@ export async function buildProgram(): Promise<Command> {
19
19
  .description('Astrale CLI — connect to existing Astrale kernels')
20
20
  // Single source of truth = package.json (bumped by release-please together
21
21
  // with .release-please-manifest.json). Never hand-write a version literal.
22
- .version(pkg.version)
22
+ .version(pkg.version, '-V, --cli-version', 'output the CLI version (root alias: --version)')
23
23
  .showSuggestionAfterError(true)
24
24
  .addOption(new Option('--ci', 'Machine mode: no prompts, structured errors on stderr'))
25
25
  .addOption(new Option('--no-prompt', 'Disable interactive prompts'))
@@ -1,2 +1,3 @@
1
1
  export { buildProgram } from './build.js'
2
+ export { normalizeRootVersionArgv } from './argv.js'
2
3
  export type { CommandArgument, CommandDefinition, CommandGroup, CommandOption } from './command.js'
@@ -91,6 +91,7 @@ describe('beginInvocation', () => {
91
91
  test('writes nothing for help/version-only invocations', () => {
92
92
  beginInvocation(['node', 'astrale', '--help'])(0)
93
93
  beginInvocation(['node', 'astrale', '--version'])(0)
94
+ beginInvocation(['node', 'astrale', '--cli-version'])(0)
94
95
  beginInvocation(['node', 'astrale'])(0)
95
96
  expect(listSessions()).toHaveLength(0)
96
97
  })
@@ -17,7 +17,15 @@ import { eventsPath } from './store'
17
17
  export type Finalizer = (exitCode: number, errorName?: string) => void
18
18
 
19
19
  const NOOP: Finalizer = () => {}
20
- const HELP_VERSION = new Set(['-h', '--help', '-V', '--version', 'help', 'version'])
20
+ const HELP_VERSION = new Set([
21
+ '-h',
22
+ '--help',
23
+ '-V',
24
+ '--version',
25
+ '--cli-version',
26
+ 'help',
27
+ 'version',
28
+ ])
21
29
 
22
30
  /** Bare invocation (every() is true for []) or only help/version tokens — skip. */
23
31
  function isHelpOrVersion(args: string[]): boolean {
@@ -114,6 +114,37 @@ function lock(): UiLock {
114
114
  }
115
115
 
116
116
  describe('UI release and runner contracts', () => {
117
+ test('resolves the default release from the public beta channel', async () => {
118
+ const seen: string[] = []
119
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
120
+ const url = String(input)
121
+ if (url.endsWith('/@astrale-os/ui/beta')) {
122
+ seen.push(url)
123
+ return Response.json({ version: '0.3.0-beta.1' })
124
+ }
125
+ return mockFetch(seen)(input, init)
126
+ }) as typeof fetch
127
+
128
+ const release = await resolveUiRelease(undefined, fetcher)
129
+
130
+ expect(release.version).toBe('0.3.0-beta.1')
131
+ expect(seen[0]).toBe('https://registry.npmjs.org/@astrale-os/ui/beta')
132
+ expect(seen).not.toContain('https://registry.npmjs.org/@astrale-os/ui/latest')
133
+ })
134
+
135
+ test('rejects a public beta channel that does not resolve to a beta release', async () => {
136
+ const fetcher = (async (input: string | URL | Request) => {
137
+ const url = String(input)
138
+ if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0' })
139
+ throw new Error('release snapshot must not be fetched')
140
+ }) as typeof fetch
141
+
142
+ await expect(resolveUiRelease(undefined, fetcher)).rejects.toMatchObject({
143
+ code: 'UI_REGISTRY_UNAVAILABLE',
144
+ message: 'Invalid UI beta release version: 0.3.0',
145
+ })
146
+ })
147
+
117
148
  /** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
118
149
  test('resolves one commit and reads the full release snapshot from it', async () => {
119
150
  const seen: string[] = []
@@ -211,6 +242,67 @@ describe('UI release and runner contracts', () => {
211
242
  })
212
243
 
213
244
  describe('UI initialization transaction', () => {
245
+ test('initializes the generated Domain frontend stylesheet instead of an unused fallback', async () => {
246
+ const root = await fixture()
247
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
248
+ await writeFile(
249
+ path.join(root, 'frontend/package.json'),
250
+ JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
251
+ )
252
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
253
+ const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
254
+
255
+ await initUi(
256
+ { path: path.join(root, 'frontend'), version: '0.3.0-beta.0', install: false },
257
+ { fetcher: mockFetch() },
258
+ )
259
+
260
+ const css = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
261
+ const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
262
+ expect(css).toContain("@import '@astrale-os/ui/theme.css';")
263
+ expect(css).toContain('/* Domain frontend */')
264
+ expect(components.tailwind.css).toBe('frontend/src/styles.css')
265
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
266
+ expect(
267
+ JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
268
+ ).toHaveProperty('@astrale-os/ui')
269
+ expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
270
+
271
+ await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
272
+ const repeated = await initUi(
273
+ { path: root, version: '0.3.0-beta.0', install: false },
274
+ { fetcher: mockFetch() },
275
+ )
276
+ expect(repeated.status).toBe('unchanged')
277
+ })
278
+
279
+ test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
280
+ const root = await fixture()
281
+ const outside = await fixture()
282
+ await symlink(outside, path.join(root, 'escaped'), 'dir')
283
+ await writeFile(
284
+ path.join(root, 'components.json'),
285
+ JSON.stringify({ tailwind: { css: 'escaped/styles.css' } }),
286
+ )
287
+
288
+ await expect(
289
+ initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
290
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
291
+ expect(await Bun.file(path.join(outside, 'styles.css')).exists()).toBe(false)
292
+
293
+ const automaticRoot = await fixture()
294
+ await rm(path.join(automaticRoot, 'src/index.css'))
295
+ const outsideCss = await readFile(path.join(outside, 'src/index.css'), 'utf8')
296
+ await symlink(outside, path.join(automaticRoot, 'frontend'), 'dir')
297
+ await expect(
298
+ initUi(
299
+ { path: automaticRoot, version: '0.3.0-beta.0', install: false },
300
+ { fetcher: mockFetch() },
301
+ ),
302
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
303
+ expect(await readFile(path.join(outside, 'src/index.css'), 'utf8')).toBe(outsideCss)
304
+ })
305
+
214
306
  test('dry-run reports every mutation and writes nothing', async () => {
215
307
  const root = await fixture()
216
308
  const before = await readFile(path.join(root, 'package.json'), 'utf8')
package/src/ui/project.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { access, lstat, readFile } from 'node:fs/promises'
1
+ import { access, lstat, readFile, realpath } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  import { UiError, type PackageManager } from './model'
@@ -29,6 +29,40 @@ async function exists(target: string): Promise<boolean> {
29
29
  )
30
30
  }
31
31
 
32
+ async function readManifest(target: string): Promise<Record<string, unknown>> {
33
+ try {
34
+ return JSON.parse(await readFile(target, 'utf8')) as Record<string, unknown>
35
+ } catch (cause) {
36
+ throw new UiError('UI_PROJECT_UNSUPPORTED', 'package.json is not valid JSON.', undefined, {
37
+ cause,
38
+ })
39
+ }
40
+ }
41
+
42
+ function hasReactTailwind(manifest: Record<string, unknown>): boolean {
43
+ const dependencies = {
44
+ ...(manifest.dependencies as Record<string, string> | undefined),
45
+ ...(manifest.devDependencies as Record<string, string> | undefined),
46
+ ...(manifest.peerDependencies as Record<string, string> | undefined),
47
+ }
48
+ return Boolean(dependencies.react && dependencies['react-dom'] && dependencies.tailwindcss)
49
+ }
50
+
51
+ async function assertPhysicalProjectPath(root: string, target: string): Promise<void> {
52
+ const physicalRoot = await realpath(root)
53
+ let existing = target
54
+ while (!(await exists(existing))) {
55
+ const parent = path.dirname(existing)
56
+ if (parent === existing) break
57
+ existing = parent
58
+ }
59
+ const physicalTarget = await realpath(existing)
60
+ const relative = path.relative(physicalRoot, physicalTarget)
61
+ if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
62
+ throw new UiError('UI_PROJECT_UNSUPPORTED', 'components.json CSS path escapes the project.')
63
+ }
64
+ }
65
+
32
66
  export async function discoverUiProject(input = process.cwd()): Promise<UiProject> {
33
67
  let root = path.resolve(input)
34
68
  if (!(await exists(root))) {
@@ -36,7 +70,18 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
36
70
  }
37
71
  if (!(await lstat(root)).isDirectory()) root = path.dirname(root)
38
72
 
39
- while (!(await exists(path.join(root, 'package.json')))) {
73
+ while (true) {
74
+ const manifestPath = path.join(root, 'package.json')
75
+ if (await exists(manifestPath)) {
76
+ const manifest = await readManifest(manifestPath)
77
+ const parent = path.dirname(root)
78
+ if (hasReactTailwind(manifest) || parent === root) break
79
+ if (await exists(path.join(parent, 'package.json'))) {
80
+ root = parent
81
+ continue
82
+ }
83
+ break
84
+ }
40
85
  const parent = path.dirname(root)
41
86
  if (parent === root) {
42
87
  throw new UiError(
@@ -49,14 +94,7 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
49
94
  }
50
95
 
51
96
  const packageJsonPath = path.join(root, 'package.json')
52
- let packageJson: Record<string, unknown>
53
- try {
54
- packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as Record<string, unknown>
55
- } catch (cause) {
56
- throw new UiError('UI_PROJECT_UNSUPPORTED', 'package.json is not valid JSON.', undefined, {
57
- cause,
58
- })
59
- }
97
+ const packageJson = await readManifest(packageJsonPath)
60
98
 
61
99
  let manager: PackageManager = 'npm'
62
100
  let lockPath: string | undefined
@@ -90,11 +128,41 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
90
128
  lockPath = path.join(root, expectedLock)
91
129
  }
92
130
 
93
- const cssCandidates = ['src/index.css', 'src/app.css', 'app/globals.css', 'src/styles.css']
131
+ const rootCssCandidates = ['src/index.css', 'src/app.css', 'app/globals.css', 'src/styles.css']
132
+ const frontendCssCandidates = [
133
+ 'frontend/src/index.css',
134
+ 'frontend/src/app.css',
135
+ 'frontend/src/styles.css',
136
+ ]
137
+ const componentsPath = path.join(root, 'components.json')
138
+ const configuredCss = await readFile(componentsPath, 'utf8')
139
+ .then((value) => {
140
+ const components = JSON.parse(value) as { tailwind?: { css?: unknown } }
141
+ const css = components.tailwind?.css
142
+ if (typeof css !== 'string' || css.length === 0) return undefined
143
+ const target = path.resolve(root, css)
144
+ const relative = path.relative(root, target)
145
+ if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
146
+ throw new UiError('UI_PROJECT_UNSUPPORTED', 'components.json CSS path escapes the project.')
147
+ }
148
+ return { relative: relative.split(path.sep).join('/'), target }
149
+ })
150
+ .catch((error: unknown) => {
151
+ if (error instanceof UiError) throw error
152
+ return undefined
153
+ })
154
+ const configuredCssRelative = configuredCss?.relative
155
+ const cssCandidates =
156
+ configuredCssRelative !== undefined
157
+ ? [configuredCssRelative]
158
+ : (await exists(path.join(root, 'frontend/package.json')))
159
+ ? [...frontendCssCandidates, ...rootCssCandidates]
160
+ : [...rootCssCandidates, ...frontendCssCandidates]
94
161
  const resolvedCss = await Promise.all(
95
162
  cssCandidates.map(async (file) => ((await exists(path.join(root, file))) ? file : undefined)),
96
163
  )
97
- const cssRelative = resolvedCss.find(Boolean) ?? 'src/astrale-ui.css'
164
+ const cssRelative = configuredCssRelative ?? resolvedCss.find(Boolean) ?? 'src/astrale-ui.css'
165
+ await assertPhysicalProjectPath(root, path.join(root, cssRelative))
98
166
 
99
167
  return {
100
168
  root,
@@ -103,7 +171,7 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
103
171
  manager,
104
172
  lockPath,
105
173
  cssPath: path.join(root, cssRelative),
106
- componentsPath: path.join(root, 'components.json'),
174
+ componentsPath,
107
175
  uiLockPath: path.join(root, 'astrale-ui.lock.json'),
108
176
  }
109
177
  }
package/src/ui/release.ts CHANGED
@@ -67,11 +67,14 @@ export async function resolveUiRelease(
67
67
  ): Promise<UiRelease> {
68
68
  const versionDocument = requested
69
69
  ? { version: requested.replace(/^v/u, '') }
70
- : await json<{ version: string }>(fetcher, NPM_PACKAGE + '/latest', 'npm UI release')
70
+ : await json<{ version: string }>(fetcher, NPM_PACKAGE + '/beta', 'npm UI release')
71
71
  const version = versionDocument.version
72
72
  if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
73
73
  throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Invalid UI release version: ' + version)
74
74
  }
75
+ if (!requested && !/-beta\.\d+$/u.test(version)) {
76
+ throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Invalid UI beta release version: ' + version)
77
+ }
75
78
  const ref = 'v' + version
76
79
  const reference = await json<{
77
80
  object: { type: 'commit' | 'tag'; sha: string; url: string }