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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/astrale.js CHANGED
@@ -2578,7 +2578,7 @@ var package_default;
2578
2578
  var init_package = __esm(() => {
2579
2579
  package_default = {
2580
2580
  name: "@astrale-os/cli",
2581
- version: "1.0.0-beta.13",
2581
+ version: "1.0.0-beta.15",
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;
@@ -83590,7 +83655,10 @@ async function readRegistry(commit, fetcher) {
83590
83655
  return { name: root.name ?? "astrale-ui", homepage: root.homepage, items: installable };
83591
83656
  }
83592
83657
  async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited) {
83593
- const direct = Array.isArray(source2.items) ? source2.items : [];
83658
+ const releaseRoot = RAW + "/" + commit + "/";
83659
+ const sourceRelative = sourceUrl.slice(releaseRoot.length);
83660
+ const sourceDirectory = sourceRelative.slice(0, Math.max(0, sourceRelative.lastIndexOf("/")));
83661
+ const direct = Array.isArray(source2.items) ? source2.items.map((item) => qualifyRegistryItemPaths(item, sourceDirectory)) : [];
83594
83662
  const includes = source2.include ?? [];
83595
83663
  if (!Array.isArray(includes)) {
83596
83664
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include must be an array.");
@@ -83599,12 +83667,12 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
83599
83667
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains too many included documents.");
83600
83668
  }
83601
83669
  const nested = await Promise.all(includes.map(async (include) => {
83602
- if (typeof include !== "string" || include.startsWith("/") || !include.endsWith("registry.json") || include.split("/").includes("..")) {
83670
+ if (typeof include !== "string" || !isSafeRelative2(include) || !/^[A-Za-z0-9._/-]+$/u.test(include) || !include.endsWith("registry.json") || include === "registry.json") {
83603
83671
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an unsafe include.");
83604
83672
  }
83673
+ const sourceDirectoryUrl = sourceUrl.slice(0, sourceUrl.lastIndexOf("/") + 1);
83605
83674
  const url3 = new URL(include, sourceUrl).toString();
83606
- const releaseRoot = RAW + "/" + commit + "/";
83607
- if (!url3.startsWith(releaseRoot) || visited.has(url3)) {
83675
+ if (url3 !== sourceDirectoryUrl + include || !url3.startsWith(releaseRoot) || !url3.startsWith(sourceDirectoryUrl) || visited.has(url3)) {
83608
83676
  throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry include escaped or repeated the release snapshot.");
83609
83677
  }
83610
83678
  visited.add(url3);
@@ -83613,6 +83681,33 @@ async function resolveRegistryItems(source2, sourceUrl, commit, fetcher, visited
83613
83681
  }));
83614
83682
  return direct.concat(nested.flat());
83615
83683
  }
83684
+ function qualifyRegistryItemPaths(item, sourceDirectory) {
83685
+ if (!item || typeof item !== "object")
83686
+ return item;
83687
+ const candidate2 = item;
83688
+ if (!Array.isArray(candidate2.files))
83689
+ return item;
83690
+ return {
83691
+ ...candidate2,
83692
+ files: candidate2.files.map((file2) => {
83693
+ if (!file2 || typeof file2 !== "object")
83694
+ return file2;
83695
+ const candidateFile = file2;
83696
+ if (typeof candidateFile.path !== "string")
83697
+ return file2;
83698
+ if (!isSafeRelative2(candidateFile.path) || sourceDirectory !== "" && (candidateFile.path === sourceDirectory || candidateFile.path.startsWith(sourceDirectory + "/"))) {
83699
+ throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
83700
+ }
83701
+ if (sourceDirectory === "")
83702
+ return file2;
83703
+ const qualified = sourceDirectory + "/" + candidateFile.path;
83704
+ if (!isSafeRelative2(qualified)) {
83705
+ throw new UiError("UI_REGISTRY_UNAVAILABLE", "UI registry contains an invalid installable item path.");
83706
+ }
83707
+ return { ...candidateFile, path: qualified };
83708
+ })
83709
+ };
83710
+ }
83616
83711
  function isInstallableItem(item) {
83617
83712
  if (!item || typeof item !== "object")
83618
83713
  return false;
@@ -83630,7 +83725,11 @@ async function readUiRegistryItem(release, expected, fetcher = fetch) {
83630
83725
  return item;
83631
83726
  }
83632
83727
  function isSafeRelative2(value3) {
83633
- return value3.length > 0 && !value3.startsWith("/") && !/^[A-Za-z]:[\\/]/u.test(value3) && !value3.includes("\\") && !value3.split("/").includes("..");
83728
+ const segments = value3.split("/");
83729
+ return value3.length > 0 && !value3.startsWith("/") && !/^[A-Za-z]:[\\/]/u.test(value3) && !value3.includes("\\") && !/[?#%]/u.test(value3) && ![...value3].some((character) => {
83730
+ const code = character.codePointAt(0) ?? 0;
83731
+ return code <= 31 || code === 127;
83732
+ }) && segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
83634
83733
  }
83635
83734
  function registryItemUrl(release, itemName) {
83636
83735
  return RAW + "/" + release.commit + "/registry/public/r/" + encodeURIComponent(itemName) + ".json";
@@ -83656,7 +83755,7 @@ var init_runner = __esm(() => {
83656
83755
  });
83657
83756
 
83658
83757
  // 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";
83758
+ 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
83759
  import path6 from "node:path";
83661
83760
  async function exists2(target2) {
83662
83761
  return access3(target2).then(() => true, () => false);
@@ -84014,8 +84113,8 @@ async function safeTarget(project2, relative) {
84014
84113
  }
84015
84114
  const target2 = path6.resolve(project2.root, relative);
84016
84115
  projectRelative(project2, target2);
84017
- const parent = await realpath2(path6.dirname(target2));
84018
- const root = await realpath2(project2.root);
84116
+ const parent = await realpath3(path6.dirname(target2));
84117
+ const root = await realpath3(project2.root);
84019
84118
  if (parent !== root && !parent.startsWith(root + path6.sep)) {
84020
84119
  throw new UiError("UI_LOCK_INVALID", "Registry target escapes through a symlink: " + relative);
84021
84120
  }
@@ -84030,7 +84129,7 @@ async function assertSafePlannedTarget(project2, relative) {
84030
84129
  }
84031
84130
  const target2 = path6.resolve(project2.root, relative);
84032
84131
  projectRelative(project2, target2);
84033
- const root = await realpath2(project2.root);
84132
+ const root = await realpath3(project2.root);
84034
84133
  const physicalTarget = path6.resolve(root, path6.relative(project2.root, target2));
84035
84134
  let current = root;
84036
84135
  for (const segment of path6.relative(root, physicalTarget).split(path6.sep)) {
@@ -88103,7 +88202,7 @@ function registerGroup(parent, group) {
88103
88202
  // src/program/build.ts
88104
88203
  async function buildProgram() {
88105
88204
  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 () => {
88205
+ 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
88206
  if (process.stdin.isTTY && process.stdout.isTTY) {
88108
88207
  const { shouldAutostartSetup: shouldAutostartSetup2 } = await Promise.resolve().then(() => (init_engine(), exports_engine));
88109
88208
  if (await shouldAutostartSetup2()) {
@@ -88263,6 +88362,16 @@ Examples:
88263
88362
  `);
88264
88363
  return program4;
88265
88364
  }
88365
+ // src/program/argv.ts
88366
+ function normalizeRootVersionArgv(argv) {
88367
+ const normalized = [...argv];
88368
+ const commandIndex = normalized.findIndex((token, index3) => index3 >= 2 && !token.startsWith("-"));
88369
+ const versionIndex = normalized.indexOf("--version", 2);
88370
+ if (versionIndex !== -1 && (commandIndex === -1 || versionIndex < commandIndex)) {
88371
+ normalized[versionIndex] = "--cli-version";
88372
+ }
88373
+ return normalized;
88374
+ }
88266
88375
  // src/telemetry/recorder.ts
88267
88376
  import { appendFileSync as appendFileSync2 } from "node:fs";
88268
88377
 
@@ -88378,7 +88487,15 @@ function ensureSession(cwd) {
88378
88487
  init_settings();
88379
88488
  init_store();
88380
88489
  var NOOP = () => {};
88381
- var HELP_VERSION = new Set(["-h", "--help", "-V", "--version", "help", "version"]);
88490
+ var HELP_VERSION = new Set([
88491
+ "-h",
88492
+ "--help",
88493
+ "-V",
88494
+ "--version",
88495
+ "--cli-version",
88496
+ "help",
88497
+ "version"
88498
+ ]);
88382
88499
  function isHelpOrVersion(args) {
88383
88500
  return args.every((a) => HELP_VERSION.has(a));
88384
88501
  }
@@ -88500,14 +88617,15 @@ var errorName;
88500
88617
  if (finalize2)
88501
88618
  process.on("exit", (code) => finalize2(code ?? 0, errorName));
88502
88619
  maybeTriggerAnalysis(process.argv);
88503
- configureInvocation(process.argv);
88620
+ var argv = normalizeRootVersionArgv(process.argv);
88621
+ configureInvocation(argv);
88504
88622
  var program4 = await buildProgram();
88505
88623
  overrideExits(program4);
88506
88624
  program4.configureOutput({ writeErr: () => {
88507
88625
  return;
88508
88626
  } });
88509
88627
  try {
88510
- await program4.parseAsync();
88628
+ await program4.parseAsync(argv);
88511
88629
  } catch (error52) {
88512
88630
  if (error52 instanceof CommanderError) {
88513
88631
  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.15",
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
  }
@@ -119,7 +135,14 @@ function mockFetch(): typeof fetch {
119
135
  })
120
136
  }
121
137
  if (url.endsWith('/registry/patterns/chart/registry.json')) {
122
- 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
+ })
123
146
  }
124
147
  if (url.endsWith('/registry.json')) {
125
148
  return Response.json({ include: ['registry/patterns/chart/registry.json'] })
@@ -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 {
@@ -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
  }
@@ -114,6 +115,37 @@ function lock(): UiLock {
114
115
  }
115
116
 
116
117
  describe('UI release and runner contracts', () => {
118
+ test('resolves the default release from the public beta channel', async () => {
119
+ const seen: string[] = []
120
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
121
+ const url = String(input)
122
+ if (url.endsWith('/@astrale-os/ui/beta')) {
123
+ seen.push(url)
124
+ return Response.json({ version: '0.3.0-beta.1' })
125
+ }
126
+ return mockFetch(seen)(input, init)
127
+ }) as typeof fetch
128
+
129
+ const release = await resolveUiRelease(undefined, fetcher)
130
+
131
+ expect(release.version).toBe('0.3.0-beta.1')
132
+ expect(seen[0]).toBe('https://registry.npmjs.org/@astrale-os/ui/beta')
133
+ expect(seen).not.toContain('https://registry.npmjs.org/@astrale-os/ui/latest')
134
+ })
135
+
136
+ test('rejects a public beta channel that does not resolve to a beta release', async () => {
137
+ const fetcher = (async (input: string | URL | Request) => {
138
+ const url = String(input)
139
+ if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0' })
140
+ throw new Error('release snapshot must not be fetched')
141
+ }) as typeof fetch
142
+
143
+ await expect(resolveUiRelease(undefined, fetcher)).rejects.toMatchObject({
144
+ code: 'UI_REGISTRY_UNAVAILABLE',
145
+ message: 'Invalid UI beta release version: 0.3.0',
146
+ })
147
+ })
148
+
117
149
  /** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
118
150
  test('resolves one commit and reads the full release snapshot from it', async () => {
119
151
  const seen: string[] = []
@@ -121,6 +153,7 @@ describe('UI release and runner contracts', () => {
121
153
  expect(release.commit).toBe(commit)
122
154
  expect(release.compatibility.base).toBe('base')
123
155
  expect(release.registry.items).toHaveLength(1)
156
+ expect(release.registry.items[0]?.files[0]?.path).toBe('registry/patterns/chart/line-basic.tsx')
124
157
  expect(seen.filter((url) => new URL(url).hostname === 'raw.githubusercontent.com')).toEqual(
125
158
  expect.arrayContaining([
126
159
  expect.stringContaining('/' + commit + '/tooling/compatibility.json'),
@@ -162,6 +195,114 @@ describe('UI release and runner contracts', () => {
162
195
  expect(seen.some((url) => url.endsWith('/registry/registry.json'))).toBe(false)
163
196
  })
164
197
 
198
+ test('rejects noncanonical and encoded registry includes before fetching them', async () => {
199
+ const unsafeIncludes = [
200
+ '%2e%2e/other/registry.json',
201
+ 'registry/%2Fother/registry.json',
202
+ 'registry/other/registry.json?raw=1',
203
+ 'registry/other/registry.json#item',
204
+ 'https://example.invalid/registry.json',
205
+ './registry/other/registry.json',
206
+ ]
207
+
208
+ for (const include of unsafeIncludes) {
209
+ const seen: string[] = []
210
+ const fallback = mockFetch(seen)
211
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
212
+ const url = String(input)
213
+ if (url.endsWith('/' + commit + '/registry.json')) {
214
+ return Response.json({ include: [include] })
215
+ }
216
+ return fallback(input, init)
217
+ }) as typeof fetch
218
+
219
+ await expect(resolveUiRelease('0.3.0-beta.0', fetcher)).rejects.toMatchObject({
220
+ code: 'UI_REGISTRY_UNAVAILABLE',
221
+ })
222
+ expect(seen.some((url) => url.includes('example.invalid') || url.includes('/other/'))).toBe(
223
+ false,
224
+ )
225
+ }
226
+ })
227
+
228
+ test('rejects malformed and already-qualified family-local item paths', async () => {
229
+ const unsafePaths = [
230
+ '',
231
+ '/absolute.tsx',
232
+ 'C:/windows.tsx',
233
+ './line-basic.tsx',
234
+ '../line-basic.tsx',
235
+ '%2e%2e/line-basic.tsx',
236
+ 'registry/patterns/chart/line-basic.tsx',
237
+ ]
238
+
239
+ for (const unsafePath of unsafePaths) {
240
+ const supplied = structuredClone(registry)
241
+ supplied.items[0]!.files[0]!.path = unsafePath
242
+ await expect(resolveUiRelease('0.3.0-beta.0', mockFetch([], supplied))).rejects.toMatchObject(
243
+ {
244
+ code: 'UI_REGISTRY_UNAVAILABLE',
245
+ },
246
+ )
247
+ }
248
+ })
249
+
250
+ test('qualifies every item file relative to its declaring nested registry', async () => {
251
+ const rootItem = {
252
+ ...structuredClone(registry.items[0]!),
253
+ name: 'pattern-chart-root',
254
+ files: [
255
+ {
256
+ ...structuredClone(registry.items[0]!.files[0]!),
257
+ path: 'registry/root.tsx',
258
+ target: 'components/astrale/pattern/chart/root.tsx',
259
+ },
260
+ ],
261
+ meta: { canonicalAddress: 'pattern/chart/root' },
262
+ }
263
+ const chartItem = {
264
+ ...structuredClone(registry.items[0]!),
265
+ files: [
266
+ structuredClone(registry.items[0]!.files[0]!),
267
+ {
268
+ ...structuredClone(registry.items[0]!.files[0]!),
269
+ path: 'parts/legend.tsx',
270
+ target: 'components/astrale/pattern/chart/parts/legend.tsx',
271
+ },
272
+ ],
273
+ }
274
+ const nestedItem = {
275
+ ...structuredClone(registry.items[0]!),
276
+ name: 'pattern-chart-nested-line',
277
+ files: [structuredClone(registry.items[0]!.files[0]!)],
278
+ meta: { canonicalAddress: 'pattern/chart/nested-line' },
279
+ }
280
+ const fallback = mockFetch()
281
+ const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
282
+ const url = String(input)
283
+ if (url.endsWith('/' + commit + '/registry.json')) {
284
+ return Response.json({
285
+ items: [rootItem],
286
+ include: ['registry/patterns/chart/registry.json'],
287
+ })
288
+ }
289
+ if (url.endsWith('/registry/patterns/chart/registry.json')) {
290
+ return Response.json({ items: [chartItem], include: ['nested/registry.json'] })
291
+ }
292
+ if (url.endsWith('/registry/patterns/chart/nested/registry.json')) {
293
+ return Response.json({ items: [nestedItem] })
294
+ }
295
+ return fallback(input, init)
296
+ }) as typeof fetch
297
+
298
+ const release = await resolveUiRelease('0.3.0-beta.0', fetcher)
299
+ expect(release.registry.items.map((item) => item.files.map((file) => file.path))).toEqual([
300
+ ['registry/root.tsx'],
301
+ ['registry/patterns/chart/line-basic.tsx', 'registry/patterns/chart/parts/legend.tsx'],
302
+ ['registry/patterns/chart/nested/line-basic.tsx'],
303
+ ])
304
+ })
305
+
165
306
  /** @evidence TEST-CLI-UI-BOUNDED-REMOTE-DOCUMENTS */
166
307
  test('bounds and normalizes malformed registry responses', async () => {
167
308
  const fallback = mockFetch()
@@ -211,6 +352,67 @@ describe('UI release and runner contracts', () => {
211
352
  })
212
353
 
213
354
  describe('UI initialization transaction', () => {
355
+ test('initializes the generated Domain frontend stylesheet instead of an unused fallback', async () => {
356
+ const root = await fixture()
357
+ await mkdir(path.join(root, 'frontend/src'), { recursive: true })
358
+ await writeFile(
359
+ path.join(root, 'frontend/package.json'),
360
+ JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
361
+ )
362
+ await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
363
+ const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
364
+
365
+ await initUi(
366
+ { path: path.join(root, 'frontend'), version: '0.3.0-beta.0', install: false },
367
+ { fetcher: mockFetch() },
368
+ )
369
+
370
+ const css = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
371
+ const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
372
+ expect(css).toContain("@import '@astrale-os/ui/theme.css';")
373
+ expect(css).toContain('/* Domain frontend */')
374
+ expect(components.tailwind.css).toBe('frontend/src/styles.css')
375
+ expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
376
+ expect(
377
+ JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
378
+ ).toHaveProperty('@astrale-os/ui')
379
+ expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
380
+
381
+ await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
382
+ const repeated = await initUi(
383
+ { path: root, version: '0.3.0-beta.0', install: false },
384
+ { fetcher: mockFetch() },
385
+ )
386
+ expect(repeated.status).toBe('unchanged')
387
+ })
388
+
389
+ test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
390
+ const root = await fixture()
391
+ const outside = await fixture()
392
+ await symlink(outside, path.join(root, 'escaped'), 'dir')
393
+ await writeFile(
394
+ path.join(root, 'components.json'),
395
+ JSON.stringify({ tailwind: { css: 'escaped/styles.css' } }),
396
+ )
397
+
398
+ await expect(
399
+ initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
400
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
401
+ expect(await Bun.file(path.join(outside, 'styles.css')).exists()).toBe(false)
402
+
403
+ const automaticRoot = await fixture()
404
+ await rm(path.join(automaticRoot, 'src/index.css'))
405
+ const outsideCss = await readFile(path.join(outside, 'src/index.css'), 'utf8')
406
+ await symlink(outside, path.join(automaticRoot, 'frontend'), 'dir')
407
+ await expect(
408
+ initUi(
409
+ { path: automaticRoot, version: '0.3.0-beta.0', install: false },
410
+ { fetcher: mockFetch() },
411
+ ),
412
+ ).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
413
+ expect(await readFile(path.join(outside, 'src/index.css'), 'utf8')).toBe(outsideCss)
414
+ })
415
+
214
416
  test('dry-run reports every mutation and writes nothing', async () => {
215
417
  const root = await fixture()
216
418
  const before = await readFile(path.join(root, 'package.json'), 'utf8')
@@ -445,7 +647,7 @@ describe('UI source operations', () => {
445
647
  files: [
446
648
  registry.items[0]!.files[0]!,
447
649
  {
448
- path: 'registry/patterns/chart/summary.tsx',
650
+ path: 'summary.tsx',
449
651
  type: 'registry:component',
450
652
  target: 'components/astrale/pattern/chart/summary.tsx',
451
653
  },
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 }
@@ -149,7 +152,12 @@ async function resolveRegistryItems(
149
152
  fetcher: Fetch,
150
153
  visited: Set<string>,
151
154
  ): Promise<unknown[]> {
152
- 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
+ : []
153
161
  const includes = source.include ?? []
154
162
  if (!Array.isArray(includes)) {
155
163
  throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry include must be an array.')
@@ -164,15 +172,21 @@ async function resolveRegistryItems(
164
172
  includes.map(async (include) => {
165
173
  if (
166
174
  typeof include !== 'string' ||
167
- include.startsWith('/') ||
175
+ !isSafeRelative(include) ||
176
+ !/^[A-Za-z0-9._/-]+$/u.test(include) ||
168
177
  !include.endsWith('registry.json') ||
169
- include.split('/').includes('..')
178
+ include === 'registry.json'
170
179
  ) {
171
180
  throw new UiError('UI_REGISTRY_UNAVAILABLE', 'UI registry contains an unsafe include.')
172
181
  }
182
+ const sourceDirectoryUrl = sourceUrl.slice(0, sourceUrl.lastIndexOf('/') + 1)
173
183
  const url = new URL(include, sourceUrl).toString()
174
- const releaseRoot = RAW + '/' + commit + '/'
175
- 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
+ ) {
176
190
  throw new UiError(
177
191
  'UI_REGISTRY_UNAVAILABLE',
178
192
  'UI registry include escaped or repeated the release snapshot.',
@@ -186,6 +200,40 @@ async function resolveRegistryItems(
186
200
  return direct.concat(nested.flat())
187
201
  }
188
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
+
189
237
  function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
190
238
  if (!item || typeof item !== 'object') return false
191
239
  const candidate = item as Partial<UiRegistry['items'][number]>
@@ -253,12 +301,18 @@ export async function readUiRegistryItem(
253
301
  }
254
302
 
255
303
  function isSafeRelative(value: string): boolean {
304
+ const segments = value.split('/')
256
305
  return (
257
306
  value.length > 0 &&
258
307
  !value.startsWith('/') &&
259
308
  !/^[A-Za-z]:[\\/]/u.test(value) &&
260
309
  !value.includes('\\') &&
261
- !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 !== '..')
262
316
  )
263
317
  }
264
318