@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 +146 -28
- package/package.json +1 -1
- package/src/commands/ui/__tests__/commands.test.ts +26 -3
- package/src/program/__tests__/program.test.ts +95 -2
- package/src/program/argv.ts +14 -0
- package/src/program/build.ts +1 -1
- package/src/program/index.ts +1 -0
- package/src/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/recorder.ts +9 -1
- package/src/ui/__tests__/ui.test.ts +204 -2
- package/src/ui/project.ts +81 -13
- package/src/ui/release.ts +61 -7
package/dist/astrale.js
CHANGED
|
@@ -2578,7 +2578,7 @@ var package_default;
|
|
|
2578
2578
|
var init_package = __esm(() => {
|
|
2579
2579
|
package_default = {
|
|
2580
2580
|
name: "@astrale-os/cli",
|
|
2581
|
-
version: "1.0.0-beta.
|
|
2581
|
+
version: "1.0.0-beta.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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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 + "/
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
84018
|
-
const root = await
|
|
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
|
|
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([
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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({
|
|
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
|
-
'
|
|
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
|
+
}
|
package/src/program/build.ts
CHANGED
|
@@ -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'))
|
package/src/program/index.ts
CHANGED
|
@@ -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([
|
|
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: '
|
|
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: '
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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 + '/
|
|
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
|
|
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
|
|
175
|
+
!isSafeRelative(include) ||
|
|
176
|
+
!/^[A-Za-z0-9._/-]+$/u.test(include) ||
|
|
168
177
|
!include.endsWith('registry.json') ||
|
|
169
|
-
include
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|