@astrale-os/cli 1.0.0-beta.20 → 1.0.0-beta.21
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 +191 -17
- package/package.json +1 -1
- package/src/commands/ui/add.ts +3 -3
- package/src/commands/ui/doctor.ts +12 -4
- package/src/commands/ui/list.ts +3 -3
- package/src/lib/__tests__/view-assets.test.ts +11 -0
- package/src/program/__tests__/program.test.ts +1 -1
- package/src/ui/.spec/architecture.md +5 -3
- package/src/ui/.spec/laws.ts +7 -0
- package/src/ui/__tests__/ui.test.ts +313 -2
- package/src/ui/lock.ts +3 -1
- package/src/ui/operations.ts +228 -6
- package/src/ui/release.ts +20 -5
- package/viewer/dist/index.html +21 -0
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.21",
|
|
2582
2582
|
description: "Astrale CLI — connect to existing Astrale kernels",
|
|
2583
2583
|
keywords: [
|
|
2584
2584
|
"astrale",
|
|
@@ -84421,7 +84421,7 @@ function parseUiLock(value3) {
|
|
|
84421
84421
|
throw new UiError("UI_LOCK_INVALID", UI_LOCK_FILE + " is structurally invalid.");
|
|
84422
84422
|
}
|
|
84423
84423
|
for (const [address, item] of Object.entries(lock.items)) {
|
|
84424
|
-
if (!/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]
|
|
84424
|
+
if (!/^(?:(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+|theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/u.test(address) || !isRecord8(item) || item.address !== address || !isDigest(item.sourceDigest) || !isRecord8(item.files)) {
|
|
84425
84425
|
throw new UiError("UI_LOCK_INVALID", UI_LOCK_FILE + " contains an invalid item record.");
|
|
84426
84426
|
}
|
|
84427
84427
|
for (const [file2, expected] of Object.entries(item.files)) {
|
|
@@ -84776,7 +84776,10 @@ function isInstallableItem(item) {
|
|
|
84776
84776
|
if (!item || typeof item !== "object")
|
|
84777
84777
|
return false;
|
|
84778
84778
|
const candidate2 = item;
|
|
84779
|
-
|
|
84779
|
+
const address = candidate2.meta?.canonicalAddress;
|
|
84780
|
+
const isTheme = candidate2.type === "registry:theme" && typeof candidate2.name === "string" && /^theme-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(candidate2.name) && typeof address === "string" && /^theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(address);
|
|
84781
|
+
const isComposition = candidate2.type === "registry:block" && typeof candidate2.name === "string" && /^(?:pattern|block)-[a-z0-9-]+$/u.test(candidate2.name) && typeof address === "string" && /^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(address);
|
|
84782
|
+
return (isTheme || isComposition) && (candidate2.dependencies === undefined || Array.isArray(candidate2.dependencies) && candidate2.dependencies.every((dependency2) => typeof dependency2 === "string" && /^(?:@[a-z0-9-]+\/)?[a-z0-9-]+@[~^<>=0-9A-Za-z.* -]+$/u.test(dependency2))) && Array.isArray(candidate2.files) && candidate2.files.length > 0 && candidate2.files.every((file2) => file2 && typeof file2.path === "string" && isSafeRelative2(file2.path) && typeof file2.type === "string" && typeof file2.target === "string" && file2.target.startsWith("components/astrale/") && isSafeRelative2(file2.target) && (!isTheme || candidate2.files?.length === 1 && file2.type === "registry:file" && file2.target === "components/astrale/theme/" + address.slice("theme/".length) + ".css")) && typeof address === "string";
|
|
84780
84783
|
}
|
|
84781
84784
|
async function readUiRegistryItem(release, expected, fetcher = fetch) {
|
|
84782
84785
|
const item = await json4(fetcher, registryItemUrl(release, expected.name), "UI registry item " + expected.name);
|
|
@@ -84829,6 +84832,32 @@ async function readOptional(target2) {
|
|
|
84829
84832
|
return;
|
|
84830
84833
|
});
|
|
84831
84834
|
}
|
|
84835
|
+
function isLocalThemeAddress(address) {
|
|
84836
|
+
return (address.startsWith("./") || address.startsWith("../")) && address.endsWith(".css");
|
|
84837
|
+
}
|
|
84838
|
+
function admitLocalThemeCss(source2, slug) {
|
|
84839
|
+
if (new TextEncoder().encode(source2).byteLength > LOCAL_THEME_MAX_BYTES) {
|
|
84840
|
+
throw new UiError("UI_ITEM_CONFLICT", "Local theme CSS exceeds 128 KiB.");
|
|
84841
|
+
}
|
|
84842
|
+
if (!source2.includes("Consumer-owned after installation.") || !source2.includes("[data-ui-theme='" + slug + "']") || !source2.includes("[data-ui-theme='" + slug + "'].dark") || /@import\b|url\s*\(/iu.test(source2) || REQUIRED_THEME_TOKENS.some((token) => source2.match(new RegExp(`--ui-${token}:`, "gu"))?.length !== 2)) {
|
|
84843
|
+
throw new UiError("UI_ITEM_CONFLICT", "Local theme CSS is not an admitted Astrale playground export.", "Export the CSS from the Astrale UI playground, keep its filename aligned with the theme name, and try again.");
|
|
84844
|
+
}
|
|
84845
|
+
}
|
|
84846
|
+
function themeImport(project2, target2) {
|
|
84847
|
+
let relative = path6.relative(path6.dirname(project2.cssPath), target2).split(path6.sep).join("/");
|
|
84848
|
+
if (!relative.startsWith("."))
|
|
84849
|
+
relative = "./" + relative;
|
|
84850
|
+
return "@import '" + relative + "';";
|
|
84851
|
+
}
|
|
84852
|
+
function activateTheme(current, statement) {
|
|
84853
|
+
const lines = current.split(/\r?\n/u).filter((line) => !/^@import\s+['"][^'"]*components\/astrale\/theme\/[a-z][a-z0-9-]*\.css['"];?\s*$/u.test(line));
|
|
84854
|
+
const packageImport = lines.reduce((last, line, index3) => /^@import\s+['"]@astrale-os\/ui\/(?:theme\.css|presets\/[a-z-]+\.css)['"];?\s*$/u.test(line) ? index3 : last, -1);
|
|
84855
|
+
lines.splice(packageImport + 1, 0, statement);
|
|
84856
|
+
return lines.join(`
|
|
84857
|
+
`).replace(/\n{3,}/gu, `
|
|
84858
|
+
|
|
84859
|
+
`).replace(/^\n/u, "");
|
|
84860
|
+
}
|
|
84832
84861
|
function manifestDependencies(manifest) {
|
|
84833
84862
|
return { ...manifest.dependencies };
|
|
84834
84863
|
}
|
|
@@ -84936,6 +84965,7 @@ async function writeDomainRegistryWorkspace(project2, manifest) {
|
|
|
84936
84965
|
}
|
|
84937
84966
|
async function initUi(options, dependencies = {}) {
|
|
84938
84967
|
const project2 = await discoverUiProject(options.path);
|
|
84968
|
+
await assertSafePlannedTarget(project2, UI_LOCK_FILE);
|
|
84939
84969
|
assertSupportedUiProject(project2);
|
|
84940
84970
|
const preset = options.preset ?? "astrale";
|
|
84941
84971
|
if (!UI_PRESETS.includes(preset)) {
|
|
@@ -85113,17 +85143,94 @@ async function lockedRelease(project2, fetcher) {
|
|
|
85113
85143
|
}, fetcher);
|
|
85114
85144
|
return { lock, release };
|
|
85115
85145
|
}
|
|
85146
|
+
async function addLocalTheme(address, project2, options) {
|
|
85147
|
+
const sourcePath = path6.resolve(project2.root, address);
|
|
85148
|
+
const sourceInfo = await lstat2(sourcePath).catch(() => {
|
|
85149
|
+
return;
|
|
85150
|
+
});
|
|
85151
|
+
if (!sourceInfo?.isFile() || sourceInfo.isSymbolicLink()) {
|
|
85152
|
+
throw new UiError("UI_ITEM_NOT_FOUND", "Local theme is not a regular file: " + address);
|
|
85153
|
+
}
|
|
85154
|
+
const slug = path6.basename(sourcePath, ".css");
|
|
85155
|
+
if (!THEME_SLUG.test(slug)) {
|
|
85156
|
+
throw new UiError("UI_ITEM_CONFLICT", "Local theme filename must be a kebab-case theme name.", "Rename it to a name such as observatory.css.");
|
|
85157
|
+
}
|
|
85158
|
+
const source2 = await readFile18(sourcePath, "utf8");
|
|
85159
|
+
admitLocalThemeCss(source2, slug);
|
|
85160
|
+
const lock = await readUiLock(project2.uiLockPath);
|
|
85161
|
+
const canonicalAddress = "theme/" + slug;
|
|
85162
|
+
const relativeTarget = "components/astrale/theme/" + slug + ".css";
|
|
85163
|
+
const target2 = await assertSafePlannedTarget(project2, relativeTarget);
|
|
85164
|
+
const existing = await readOptional(target2);
|
|
85165
|
+
const installed = lock.items[canonicalAddress];
|
|
85166
|
+
if (existing !== undefined && !installed && !options.overwrite) {
|
|
85167
|
+
throw new UiError("UI_LOCAL_CHANGES", "Theme target already exists outside the Astrale UI lock: " + relativeTarget, "Review the file, then repeat with explicit --overwrite --yes.");
|
|
85168
|
+
}
|
|
85169
|
+
if (installed && !options.overwrite) {
|
|
85170
|
+
const expected = installed.files[relativeTarget];
|
|
85171
|
+
if (!expected || digest5(existing ?? "") !== expected) {
|
|
85172
|
+
throw new UiError("UI_LOCAL_CHANGES", "Installed UI file has local changes: " + relativeTarget, "Review the file, then repeat add with explicit --overwrite --yes.");
|
|
85173
|
+
}
|
|
85174
|
+
}
|
|
85175
|
+
const statement = themeImport(project2, target2);
|
|
85176
|
+
const result = {
|
|
85177
|
+
status: options.dryRun ? "planned" : "installed",
|
|
85178
|
+
items: [canonicalAddress],
|
|
85179
|
+
files: { [canonicalAddress]: { [relativeTarget]: digest5(source2) } },
|
|
85180
|
+
activation: { file: projectRelative(project2, project2.cssPath), import: statement },
|
|
85181
|
+
source: address
|
|
85182
|
+
};
|
|
85183
|
+
if (options.dryRun)
|
|
85184
|
+
return result;
|
|
85185
|
+
const snapshots = new Map;
|
|
85186
|
+
for (const mutation of [target2, project2.cssPath, project2.uiLockPath]) {
|
|
85187
|
+
snapshots.set(mutation, await readOptional(mutation));
|
|
85188
|
+
}
|
|
85189
|
+
try {
|
|
85190
|
+
await mkdir10(path6.dirname(target2), { recursive: true });
|
|
85191
|
+
await writeFile6(target2, source2, "utf8");
|
|
85192
|
+
const css = await readOptional(project2.cssPath) ?? "";
|
|
85193
|
+
await writeFile6(project2.cssPath, activateTheme(css, statement), "utf8");
|
|
85194
|
+
lock.items[canonicalAddress] = {
|
|
85195
|
+
address: canonicalAddress,
|
|
85196
|
+
sourceDigest: digest5(source2),
|
|
85197
|
+
files: { [relativeTarget]: digest5(source2) }
|
|
85198
|
+
};
|
|
85199
|
+
await writeJson(project2.uiLockPath, lock);
|
|
85200
|
+
return result;
|
|
85201
|
+
} catch (error52) {
|
|
85202
|
+
for (const [mutation, previous] of snapshots) {
|
|
85203
|
+
if (previous === undefined)
|
|
85204
|
+
await rm3(mutation, { force: true });
|
|
85205
|
+
else
|
|
85206
|
+
await writeFile6(mutation, previous, "utf8");
|
|
85207
|
+
}
|
|
85208
|
+
throw error52;
|
|
85209
|
+
}
|
|
85210
|
+
}
|
|
85116
85211
|
async function addUi(addresses, options, dependencies = {}) {
|
|
85117
85212
|
if (addresses.length === 0) {
|
|
85118
85213
|
throw new UiError("UI_ITEM_NOT_FOUND", "No UI item was provided.");
|
|
85119
85214
|
}
|
|
85120
85215
|
const project2 = await discoverUiProject(options.project);
|
|
85121
|
-
|
|
85122
|
-
const items = addresses.map((address) => findItem(release, address));
|
|
85123
|
-
const itemDocuments = await Promise.all(items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)));
|
|
85216
|
+
await assertSafePlannedTarget(project2, UI_LOCK_FILE);
|
|
85124
85217
|
if (options.overwrite && !options.yes) {
|
|
85125
85218
|
throw new UiError("UI_LOCAL_CHANGES", "Replacing installed UI source requires explicit confirmation.", "Review the locally edited files, then repeat with both --overwrite and --yes.");
|
|
85126
85219
|
}
|
|
85220
|
+
const localThemes = addresses.filter(isLocalThemeAddress);
|
|
85221
|
+
if (localThemes.length > 0) {
|
|
85222
|
+
if (addresses.length !== 1) {
|
|
85223
|
+
throw new UiError("UI_ITEM_CONFLICT", "Install one local theme at a time and do not mix local files with registry addresses.");
|
|
85224
|
+
}
|
|
85225
|
+
return addLocalTheme(localThemes[0], project2, options);
|
|
85226
|
+
}
|
|
85227
|
+
const { lock, release } = await lockedRelease(project2, dependencies.fetcher);
|
|
85228
|
+
const items = addresses.map((address) => findItem(release, address));
|
|
85229
|
+
const themes = items.filter((item) => item.type === "registry:theme");
|
|
85230
|
+
if (themes.length > 1) {
|
|
85231
|
+
throw new UiError("UI_ITEM_CONFLICT", "Only one theme can be activated per add operation.");
|
|
85232
|
+
}
|
|
85233
|
+
const itemDocuments = await Promise.all(items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)));
|
|
85127
85234
|
await rejectLocalChanges(project2, lock, items, options.overwrite === true);
|
|
85128
85235
|
const targets = (await Promise.all(items.flatMap((item) => item.files.filter((file2) => file2.target).map((file2) => assertSafePlannedTarget(project2, file2.target))))).filter((target2, index3, all) => all.indexOf(target2) === index3);
|
|
85129
85236
|
const invocation3 = shadcnInvocation(project2.manager, release.compatibility.shadcn, [
|
|
@@ -85137,7 +85244,13 @@ async function addUi(addresses, options, dependencies = {}) {
|
|
|
85137
85244
|
]);
|
|
85138
85245
|
const snapshots = new Map;
|
|
85139
85246
|
if (!options.dryRun) {
|
|
85140
|
-
for (const target2 of [
|
|
85247
|
+
for (const target2 of [
|
|
85248
|
+
project2.packageJsonPath,
|
|
85249
|
+
project2.lockPath,
|
|
85250
|
+
project2.cssPath,
|
|
85251
|
+
project2.uiLockPath,
|
|
85252
|
+
...targets
|
|
85253
|
+
]) {
|
|
85141
85254
|
snapshots.set(target2, await readOptional(target2));
|
|
85142
85255
|
}
|
|
85143
85256
|
}
|
|
@@ -85154,6 +85267,13 @@ async function addUi(addresses, options, dependencies = {}) {
|
|
|
85154
85267
|
}
|
|
85155
85268
|
}
|
|
85156
85269
|
await pinUiDependency(project2, lock.package.version, dependencies.runner ?? defaultUiRunner);
|
|
85270
|
+
const theme = themes[0];
|
|
85271
|
+
const themeTarget = theme?.files[0]?.target;
|
|
85272
|
+
if (themeTarget) {
|
|
85273
|
+
const target2 = await safeTarget(project2, themeTarget);
|
|
85274
|
+
const css = await readOptional(project2.cssPath) ?? "";
|
|
85275
|
+
await writeFile6(project2.cssPath, activateTheme(css, themeImport(project2, target2)), "utf8");
|
|
85276
|
+
}
|
|
85157
85277
|
}
|
|
85158
85278
|
} catch (error52) {
|
|
85159
85279
|
for (const [target2, previous] of snapshots) {
|
|
@@ -85174,7 +85294,11 @@ async function addUi(addresses, options, dependencies = {}) {
|
|
|
85174
85294
|
files: item.files.map((file2) => file2.target).filter(Boolean)
|
|
85175
85295
|
})),
|
|
85176
85296
|
command: [invocation3.file, ...invocation3.args],
|
|
85177
|
-
output: result.stdout
|
|
85297
|
+
output: result.stdout,
|
|
85298
|
+
activation: themes[0] ? {
|
|
85299
|
+
file: projectRelative(project2, project2.cssPath),
|
|
85300
|
+
import: themeImport(project2, targets.find((target2) => target2.endsWith(".css")))
|
|
85301
|
+
} : undefined
|
|
85178
85302
|
};
|
|
85179
85303
|
}
|
|
85180
85304
|
try {
|
|
@@ -85246,6 +85370,11 @@ async function doctorUi(input) {
|
|
|
85246
85370
|
checks3.push({ check: "item:" + item.address + ":" + file2, ok: actual === expected });
|
|
85247
85371
|
}
|
|
85248
85372
|
}
|
|
85373
|
+
const themeFiles = Object.values(lock.items).filter((item) => item.address.startsWith("theme/")).flatMap((item) => Object.keys(item.files).filter((file2) => file2.endsWith(".css")));
|
|
85374
|
+
if (themeFiles.length > 0) {
|
|
85375
|
+
const active = themeFiles.some((file2) => css.includes(themeImport(project2, path6.join(project2.root, file2))));
|
|
85376
|
+
checks3.push({ check: "theme-active", ok: active });
|
|
85377
|
+
}
|
|
85249
85378
|
}
|
|
85250
85379
|
return { healthy: checks3.every((check3) => check3.ok), checks: checks3 };
|
|
85251
85380
|
}
|
|
@@ -85254,6 +85383,7 @@ async function applyPreset(preset, options) {
|
|
|
85254
85383
|
throw new UiError("UI_ITEM_NOT_FOUND", "Unknown preset: " + preset);
|
|
85255
85384
|
}
|
|
85256
85385
|
const project2 = await discoverUiProject(options.project);
|
|
85386
|
+
await assertSafePlannedTarget(project2, UI_LOCK_FILE);
|
|
85257
85387
|
const lock = await readUiLock(project2.uiLockPath);
|
|
85258
85388
|
const css = await readOptional(project2.cssPath) ?? "";
|
|
85259
85389
|
const next = css.replace(/@astrale-os\/ui\/presets\/[a-z-]+\.css/gu, "@astrale-os/ui/presets/" + preset + ".css");
|
|
@@ -85336,6 +85466,7 @@ async function writeJson(target2, value3) {
|
|
|
85336
85466
|
await writeFile6(target2, JSON.stringify(value3, null, 2) + `
|
|
85337
85467
|
`, "utf8");
|
|
85338
85468
|
}
|
|
85469
|
+
var LOCAL_THEME_MAX_BYTES = 131072, THEME_SLUG, REQUIRED_THEME_TOKENS;
|
|
85339
85470
|
var init_operations3 = __esm(() => {
|
|
85340
85471
|
init_dist();
|
|
85341
85472
|
init_lock();
|
|
@@ -85343,6 +85474,44 @@ var init_operations3 = __esm(() => {
|
|
|
85343
85474
|
init_project3();
|
|
85344
85475
|
init_release();
|
|
85345
85476
|
init_runner();
|
|
85477
|
+
THEME_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
85478
|
+
REQUIRED_THEME_TOKENS = [
|
|
85479
|
+
"background",
|
|
85480
|
+
"foreground",
|
|
85481
|
+
"card",
|
|
85482
|
+
"card-foreground",
|
|
85483
|
+
"popover",
|
|
85484
|
+
"popover-foreground",
|
|
85485
|
+
"primary",
|
|
85486
|
+
"primary-foreground",
|
|
85487
|
+
"secondary",
|
|
85488
|
+
"secondary-foreground",
|
|
85489
|
+
"muted",
|
|
85490
|
+
"muted-foreground",
|
|
85491
|
+
"accent",
|
|
85492
|
+
"accent-foreground",
|
|
85493
|
+
"destructive",
|
|
85494
|
+
"destructive-foreground",
|
|
85495
|
+
"border",
|
|
85496
|
+
"input",
|
|
85497
|
+
"ring",
|
|
85498
|
+
"chart-1",
|
|
85499
|
+
"chart-2",
|
|
85500
|
+
"chart-3",
|
|
85501
|
+
"chart-4",
|
|
85502
|
+
"chart-5",
|
|
85503
|
+
"font-body",
|
|
85504
|
+
"font-heading",
|
|
85505
|
+
"radius",
|
|
85506
|
+
"radius-panel",
|
|
85507
|
+
"control-height",
|
|
85508
|
+
"control-height-sm",
|
|
85509
|
+
"control-height-lg",
|
|
85510
|
+
"shadow-control",
|
|
85511
|
+
"shadow-panel",
|
|
85512
|
+
"motion-fast",
|
|
85513
|
+
"motion-standard"
|
|
85514
|
+
];
|
|
85346
85515
|
});
|
|
85347
85516
|
|
|
85348
85517
|
// src/ui/index.ts
|
|
@@ -85425,13 +85594,13 @@ var init_list = __esm(() => {
|
|
|
85425
85594
|
init_shared();
|
|
85426
85595
|
list_default = {
|
|
85427
85596
|
name: "list",
|
|
85428
|
-
description: "List or search Astrale patterns and
|
|
85597
|
+
description: "List or search Astrale patterns, blocks, and themes",
|
|
85429
85598
|
arguments: [{ name: "query", description: "Optional search text", required: false }],
|
|
85430
85599
|
options: [
|
|
85431
85600
|
{
|
|
85432
85601
|
flags: "--type <type>",
|
|
85433
|
-
description: "Restrict to pattern or
|
|
85434
|
-
choices: ["pattern", "block"]
|
|
85602
|
+
description: "Restrict to pattern, block, or theme",
|
|
85603
|
+
choices: ["pattern", "block", "theme"]
|
|
85435
85604
|
},
|
|
85436
85605
|
{ flags: "--limit <n>", description: "Maximum results", default: "100" },
|
|
85437
85606
|
{ flags: "--version <version>", description: "Exact release to inspect" },
|
|
@@ -85453,11 +85622,11 @@ var init_add = __esm(() => {
|
|
|
85453
85622
|
init_shared();
|
|
85454
85623
|
add_default = {
|
|
85455
85624
|
name: "add",
|
|
85456
|
-
description: "Install consumer-owned Astrale pattern or
|
|
85625
|
+
description: "Install consumer-owned Astrale pattern, block, or theme source",
|
|
85457
85626
|
arguments: [
|
|
85458
85627
|
{
|
|
85459
85628
|
name: "items",
|
|
85460
|
-
description: "Canonical
|
|
85629
|
+
description: "Canonical registry addresses or a local exported theme CSS file",
|
|
85461
85630
|
required: false,
|
|
85462
85631
|
variadic: true
|
|
85463
85632
|
}
|
|
@@ -85470,8 +85639,8 @@ var init_add = __esm(() => {
|
|
|
85470
85639
|
UI_JSON_OPTION
|
|
85471
85640
|
],
|
|
85472
85641
|
afterHelpText: `
|
|
85473
|
-
Installed source belongs to the application.
|
|
85474
|
-
Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
|
|
85642
|
+
Installed source belongs to the application. Use theme/<name> for a released theme or ./theme.css for a playground export.
|
|
85643
|
+
Ordinary add never overwrites local edits. Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
|
|
85475
85644
|
`,
|
|
85476
85645
|
action: async (items, options) => runUiCommand(options, async () => {
|
|
85477
85646
|
let selected = items;
|
|
@@ -85506,8 +85675,13 @@ var init_doctor = __esm(() => {
|
|
|
85506
85675
|
name: "doctor",
|
|
85507
85676
|
description: "Verify Astrale UI package, CSS, config, lock, and installed source integrity",
|
|
85508
85677
|
arguments: [{ name: "path", description: "Application directory", required: false }],
|
|
85509
|
-
options: [UI_JSON_OPTION],
|
|
85510
|
-
action: async (path7, options) => runUiCommand(options, () =>
|
|
85678
|
+
options: [UI_PROJECT_OPTION, UI_JSON_OPTION],
|
|
85679
|
+
action: async (path7, options) => runUiCommand(options, () => {
|
|
85680
|
+
if (path7 && options.project) {
|
|
85681
|
+
throw new UiError("UI_ITEM_CONFLICT", "Choose either the positional application path or --project, not both.");
|
|
85682
|
+
}
|
|
85683
|
+
return doctorUi(options.project ?? path7);
|
|
85684
|
+
})
|
|
85511
85685
|
};
|
|
85512
85686
|
});
|
|
85513
85687
|
|
package/package.json
CHANGED
package/src/commands/ui/add.ts
CHANGED
|
@@ -8,11 +8,11 @@ type Options = UiCommandOptions & { dryRun?: boolean; overwrite?: boolean; yes?:
|
|
|
8
8
|
|
|
9
9
|
export default {
|
|
10
10
|
name: 'add',
|
|
11
|
-
description: 'Install consumer-owned Astrale pattern or
|
|
11
|
+
description: 'Install consumer-owned Astrale pattern, block, or theme source',
|
|
12
12
|
arguments: [
|
|
13
13
|
{
|
|
14
14
|
name: 'items',
|
|
15
|
-
description: 'Canonical
|
|
15
|
+
description: 'Canonical registry addresses or a local exported theme CSS file',
|
|
16
16
|
required: false,
|
|
17
17
|
variadic: true,
|
|
18
18
|
},
|
|
@@ -25,7 +25,7 @@ export default {
|
|
|
25
25
|
UI_JSON_OPTION,
|
|
26
26
|
],
|
|
27
27
|
afterHelpText:
|
|
28
|
-
'\nInstalled source belongs to the application.
|
|
28
|
+
'\nInstalled source belongs to the application. Use theme/<name> for a released theme or ./theme.css for a playground export.\nOrdinary add never overwrites local edits. Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.\n',
|
|
29
29
|
action: async (items: string[], options: Options) =>
|
|
30
30
|
runUiCommand(options, async () => {
|
|
31
31
|
let selected = items
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import type { CommandDefinition } from '../../program'
|
|
2
2
|
|
|
3
|
-
import { doctorUi } from '../../ui'
|
|
4
|
-
import { UI_JSON_OPTION, runUiCommand, type UiCommandOptions } from './shared'
|
|
3
|
+
import { doctorUi, UiError } from '../../ui'
|
|
4
|
+
import { UI_JSON_OPTION, UI_PROJECT_OPTION, runUiCommand, type UiCommandOptions } from './shared'
|
|
5
5
|
|
|
6
6
|
export default {
|
|
7
7
|
name: 'doctor',
|
|
8
8
|
description: 'Verify Astrale UI package, CSS, config, lock, and installed source integrity',
|
|
9
9
|
arguments: [{ name: 'path', description: 'Application directory', required: false }],
|
|
10
|
-
options: [UI_JSON_OPTION],
|
|
10
|
+
options: [UI_PROJECT_OPTION, UI_JSON_OPTION],
|
|
11
11
|
action: async (path: string | undefined, options: UiCommandOptions) =>
|
|
12
|
-
runUiCommand(options, () =>
|
|
12
|
+
runUiCommand(options, () => {
|
|
13
|
+
if (path && options.project) {
|
|
14
|
+
throw new UiError(
|
|
15
|
+
'UI_ITEM_CONFLICT',
|
|
16
|
+
'Choose either the positional application path or --project, not both.',
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
return doctorUi(options.project ?? path)
|
|
20
|
+
}),
|
|
13
21
|
} satisfies CommandDefinition
|
package/src/commands/ui/list.ts
CHANGED
|
@@ -7,13 +7,13 @@ type Options = UiCommandOptions & { type?: string; limit?: string; version?: str
|
|
|
7
7
|
|
|
8
8
|
export default {
|
|
9
9
|
name: 'list',
|
|
10
|
-
description: 'List or search Astrale patterns and
|
|
10
|
+
description: 'List or search Astrale patterns, blocks, and themes',
|
|
11
11
|
arguments: [{ name: 'query', description: 'Optional search text', required: false }],
|
|
12
12
|
options: [
|
|
13
13
|
{
|
|
14
14
|
flags: '--type <type>',
|
|
15
|
-
description: 'Restrict to pattern or
|
|
16
|
-
choices: ['pattern', 'block'],
|
|
15
|
+
description: 'Restrict to pattern, block, or theme',
|
|
16
|
+
choices: ['pattern', 'block', 'theme'],
|
|
17
17
|
},
|
|
18
18
|
{ flags: '--limit <n>', description: 'Maximum results', default: '100' },
|
|
19
19
|
{ flags: '--version <version>', description: 'Exact release to inspect' },
|
|
@@ -26,6 +26,17 @@ afterEach(async () => {
|
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
describe('viewer asset resolution', () => {
|
|
29
|
+
test('keeps long placement labels from widening the mounted View viewport', async () => {
|
|
30
|
+
const source = await readFile(
|
|
31
|
+
join(import.meta.dirname, '..', '..', '..', 'viewer', 'index.html'),
|
|
32
|
+
'utf8',
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
expect(source).toContain('#view-label,')
|
|
36
|
+
expect(source).toContain('text-overflow: ellipsis;')
|
|
37
|
+
expect(source).toContain('#frame {\n flex: 1;\n min-width: 0;')
|
|
38
|
+
})
|
|
39
|
+
|
|
29
40
|
test('skips partial candidates and prefers complete package-owned assets', async () => {
|
|
30
41
|
const root = await mkdtemp(join(tmpdir(), 'astrale-view-candidates-'))
|
|
31
42
|
temporaryDirectories.push(root)
|
|
@@ -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
|
+
'913d37eab2f603cc745bd9a1c10763a57bc9c2425d0181958d458b80e4a451d7',
|
|
199
199
|
)
|
|
200
200
|
})
|
|
201
201
|
|
|
@@ -4,7 +4,9 @@ This private CLI owner manages an existing local React project. It does not conn
|
|
|
4
4
|
does not add React, Base UI, Tailwind, shadcn, or the UI runtime to the installed CLI dependency
|
|
5
5
|
graph.
|
|
6
6
|
|
|
7
|
-
One operation resolves one immutable UI commit. Package compatibility and registry reads
|
|
8
|
-
commit, and the detected package manager invokes the release-qualified shadcn CLI on
|
|
9
|
-
|
|
7
|
+
One registry operation resolves one immutable UI commit. Package compatibility and registry reads
|
|
8
|
+
use that commit, and the detected package manager invokes the release-qualified shadcn CLI on
|
|
9
|
+
demand. A local playground-exported theme is instead admitted and copied without network or
|
|
10
|
+
shadcn. Both paths install consumer-owned source, activate themes through one project-relative CSS
|
|
11
|
+
import, and complete application writes before the lock advances. Dry-run, list, and doctor do not
|
|
10
12
|
claim a file mutation.
|
package/src/ui/.spec/laws.ts
CHANGED
|
@@ -28,6 +28,13 @@ export const CLI_UI_EXACT_ITEM_SOURCE = defineLaw({
|
|
|
28
28
|
tests: [{ file: '../__tests__/ui.test.ts', id: 'TEST-CLI-UI-EXACT-ITEM-SOURCE' }],
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
export const CLI_UI_THEME_OWNERSHIP = defineLaw({
|
|
32
|
+
id: 'CLI-UI-THEME-OWNERSHIP',
|
|
33
|
+
statement:
|
|
34
|
+
'A released or locally exported theme becomes one project-contained consumer-owned CSS file, is activated by one relative host stylesheet import, and rolls back file, import, and lock together on failure.',
|
|
35
|
+
tests: [{ file: '../__tests__/ui.test.ts', id: 'TEST-CLI-UI-THEME-OWNERSHIP' }],
|
|
36
|
+
})
|
|
37
|
+
|
|
31
38
|
export const CLI_UI_SEMANTIC_DIFF = defineLaw({
|
|
32
39
|
id: 'CLI-UI-SEMANTIC-DIFF',
|
|
33
40
|
statement:
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
|
|
6
6
|
import { digest, parseUiLock } from '../lock'
|
|
7
7
|
import { UiError, type UiLock, type UiRegistry } from '../model'
|
|
8
|
-
import { addUi, applyPreset, doctorUi, initUi } from '../operations'
|
|
8
|
+
import { addUi, applyPreset, doctorUi, initUi, listUi } from '../operations'
|
|
9
9
|
import { resolveUiRelease } from '../release'
|
|
10
10
|
import { shadcnInvocation } from '../runner'
|
|
11
11
|
|
|
@@ -28,6 +28,102 @@ const registry: UiRegistry = {
|
|
|
28
28
|
},
|
|
29
29
|
],
|
|
30
30
|
}
|
|
31
|
+
const themeCss = `/* Generated from observatory.astrale-theme.json. Consumer-owned after installation. */
|
|
32
|
+
:root,
|
|
33
|
+
[data-ui-theme='observatory'] {
|
|
34
|
+
--ui-background: #ffffff;
|
|
35
|
+
--ui-foreground: #111111;
|
|
36
|
+
--ui-card: #ffffff;
|
|
37
|
+
--ui-card-foreground: #111111;
|
|
38
|
+
--ui-popover: #ffffff;
|
|
39
|
+
--ui-popover-foreground: #111111;
|
|
40
|
+
--ui-primary: #2244aa;
|
|
41
|
+
--ui-primary-foreground: #ffffff;
|
|
42
|
+
--ui-secondary: #eeeeee;
|
|
43
|
+
--ui-secondary-foreground: #111111;
|
|
44
|
+
--ui-muted: #eeeeee;
|
|
45
|
+
--ui-muted-foreground: #555555;
|
|
46
|
+
--ui-accent: #ddeeff;
|
|
47
|
+
--ui-accent-foreground: #111111;
|
|
48
|
+
--ui-destructive: #aa2222;
|
|
49
|
+
--ui-destructive-foreground: #ffffff;
|
|
50
|
+
--ui-border: #dddddd;
|
|
51
|
+
--ui-input: #dddddd;
|
|
52
|
+
--ui-ring: #2244aa;
|
|
53
|
+
--ui-chart-1: #2244aa;
|
|
54
|
+
--ui-chart-2: #228844;
|
|
55
|
+
--ui-chart-3: #aa7722;
|
|
56
|
+
--ui-chart-4: #aa2222;
|
|
57
|
+
--ui-chart-5: #7722aa;
|
|
58
|
+
--ui-font-body: ui-sans-serif;
|
|
59
|
+
--ui-font-heading: ui-serif;
|
|
60
|
+
--ui-radius: 0.5rem;
|
|
61
|
+
--ui-radius-panel: 0.75rem;
|
|
62
|
+
--ui-control-height: 2.25rem;
|
|
63
|
+
--ui-control-height-sm: 2rem;
|
|
64
|
+
--ui-control-height-lg: 2.5rem;
|
|
65
|
+
--ui-shadow-control: none;
|
|
66
|
+
--ui-shadow-panel: none;
|
|
67
|
+
--ui-motion-fast: 120ms;
|
|
68
|
+
--ui-motion-standard: 180ms;
|
|
69
|
+
}
|
|
70
|
+
[data-ui-theme='observatory'].dark {
|
|
71
|
+
--ui-background: #111111;
|
|
72
|
+
--ui-foreground: #ffffff;
|
|
73
|
+
--ui-card: #191919;
|
|
74
|
+
--ui-card-foreground: #ffffff;
|
|
75
|
+
--ui-popover: #191919;
|
|
76
|
+
--ui-popover-foreground: #ffffff;
|
|
77
|
+
--ui-primary: #88aaff;
|
|
78
|
+
--ui-primary-foreground: #111111;
|
|
79
|
+
--ui-secondary: #292929;
|
|
80
|
+
--ui-secondary-foreground: #ffffff;
|
|
81
|
+
--ui-muted: #292929;
|
|
82
|
+
--ui-muted-foreground: #bbbbbb;
|
|
83
|
+
--ui-accent: #334455;
|
|
84
|
+
--ui-accent-foreground: #ffffff;
|
|
85
|
+
--ui-destructive: #ff7777;
|
|
86
|
+
--ui-destructive-foreground: #111111;
|
|
87
|
+
--ui-border: #333333;
|
|
88
|
+
--ui-input: #333333;
|
|
89
|
+
--ui-ring: #88aaff;
|
|
90
|
+
--ui-chart-1: #88aaff;
|
|
91
|
+
--ui-chart-2: #77dd99;
|
|
92
|
+
--ui-chart-3: #ffcc77;
|
|
93
|
+
--ui-chart-4: #ff7777;
|
|
94
|
+
--ui-chart-5: #cc88ff;
|
|
95
|
+
--ui-font-body: ui-sans-serif;
|
|
96
|
+
--ui-font-heading: ui-serif;
|
|
97
|
+
--ui-radius: 0.5rem;
|
|
98
|
+
--ui-radius-panel: 0.75rem;
|
|
99
|
+
--ui-control-height: 2.25rem;
|
|
100
|
+
--ui-control-height-sm: 2rem;
|
|
101
|
+
--ui-control-height-lg: 2.5rem;
|
|
102
|
+
--ui-shadow-control: none;
|
|
103
|
+
--ui-shadow-panel: none;
|
|
104
|
+
--ui-motion-fast: 120ms;
|
|
105
|
+
--ui-motion-standard: 180ms;
|
|
106
|
+
}
|
|
107
|
+
`
|
|
108
|
+
const themeRegistry: UiRegistry = {
|
|
109
|
+
name: 'astrale-ui',
|
|
110
|
+
items: [
|
|
111
|
+
{
|
|
112
|
+
name: 'theme-observatory',
|
|
113
|
+
type: 'registry:theme',
|
|
114
|
+
description: 'An owned theme.',
|
|
115
|
+
dependencies: ['@astrale-os/ui@^0.3.0-beta.0'],
|
|
116
|
+
files: [
|
|
117
|
+
{
|
|
118
|
+
path: 'observatory.css',
|
|
119
|
+
type: 'registry:file',
|
|
120
|
+
target: 'components/astrale/theme/observatory.css',
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
meta: { canonicalAddress: 'theme/observatory', ownership: 'consumer-source' },
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
}
|
|
31
127
|
const compatibility = {
|
|
32
128
|
version: 1,
|
|
33
129
|
shadcn: '4.18.0',
|
|
@@ -74,6 +170,34 @@ function mockFetch(seen: string[] = [], suppliedRegistry: UiRegistry = registry)
|
|
|
74
170
|
}) as typeof fetch
|
|
75
171
|
}
|
|
76
172
|
|
|
173
|
+
function themeFetch(seen: string[] = []): typeof fetch {
|
|
174
|
+
return (async (input: string | URL | Request) => {
|
|
175
|
+
const url = String(input)
|
|
176
|
+
seen.push(url)
|
|
177
|
+
if (url.includes('/git/ref/tags/')) {
|
|
178
|
+
return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
|
|
179
|
+
}
|
|
180
|
+
if (url.endsWith('/tooling/compatibility.json')) return Response.json(compatibility)
|
|
181
|
+
if (url.endsWith('/' + commit + '/registry.json')) {
|
|
182
|
+
return Response.json({ name: 'astrale-ui', include: ['registry/themes/registry.json'] })
|
|
183
|
+
}
|
|
184
|
+
if (url.endsWith('/registry/themes/registry.json')) return Response.json(themeRegistry)
|
|
185
|
+
if (url.endsWith('/registry/public/r/theme-observatory.json')) {
|
|
186
|
+
return Response.json({
|
|
187
|
+
...themeRegistry.items[0],
|
|
188
|
+
files: [
|
|
189
|
+
{
|
|
190
|
+
...themeRegistry.items[0]!.files[0],
|
|
191
|
+
path: 'registry/themes/observatory.css',
|
|
192
|
+
content: themeCss,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
return new Response('not found', { status: 404 })
|
|
198
|
+
}) as typeof fetch
|
|
199
|
+
}
|
|
200
|
+
|
|
77
201
|
function builtItem(item: UiRegistry['items'][number]) {
|
|
78
202
|
return {
|
|
79
203
|
...item,
|
|
@@ -176,6 +300,33 @@ describe('UI release and runner contracts', () => {
|
|
|
176
300
|
)
|
|
177
301
|
})
|
|
178
302
|
|
|
303
|
+
test('admits a release theme as one canonical consumer-owned CSS target', async () => {
|
|
304
|
+
const release = await resolveUiRelease('0.3.0-beta.0', themeFetch())
|
|
305
|
+
expect(release.registry.items).toEqual([
|
|
306
|
+
expect.objectContaining({
|
|
307
|
+
name: 'theme-observatory',
|
|
308
|
+
type: 'registry:theme',
|
|
309
|
+
meta: expect.objectContaining({ canonicalAddress: 'theme/observatory' }),
|
|
310
|
+
files: [
|
|
311
|
+
expect.objectContaining({
|
|
312
|
+
type: 'registry:file',
|
|
313
|
+
path: 'registry/themes/observatory.css',
|
|
314
|
+
target: 'components/astrale/theme/observatory.css',
|
|
315
|
+
}),
|
|
316
|
+
],
|
|
317
|
+
}),
|
|
318
|
+
])
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('lists release themes through the public type filter', async () => {
|
|
322
|
+
const themes = await listUi(
|
|
323
|
+
undefined,
|
|
324
|
+
{ type: 'theme', version: '0.3.0-beta.0' },
|
|
325
|
+
{ fetcher: themeFetch() },
|
|
326
|
+
)
|
|
327
|
+
expect(themes.map((item) => item.meta.canonicalAddress)).toEqual(['theme/observatory'])
|
|
328
|
+
})
|
|
329
|
+
|
|
179
330
|
test('constructs exact on-demand commands for every supported package manager', () => {
|
|
180
331
|
expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
|
|
181
332
|
file: 'pnpm',
|
|
@@ -724,6 +875,166 @@ describe('UI source operations', () => {
|
|
|
724
875
|
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
|
|
725
876
|
})
|
|
726
877
|
|
|
878
|
+
/** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
|
|
879
|
+
test('installs and activates a released theme while recording owned source', async () => {
|
|
880
|
+
const root = await lockedFixture()
|
|
881
|
+
await writeFile(
|
|
882
|
+
path.join(root, 'components.json'),
|
|
883
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
884
|
+
)
|
|
885
|
+
await writeFile(
|
|
886
|
+
path.join(root, 'src/index.css'),
|
|
887
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
888
|
+
)
|
|
889
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
890
|
+
const result = await addUi(
|
|
891
|
+
['theme/observatory'],
|
|
892
|
+
{ project: root, yes: true },
|
|
893
|
+
{
|
|
894
|
+
fetcher: themeFetch(),
|
|
895
|
+
runner: async () => {
|
|
896
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
897
|
+
await writeFile(target, themeCss)
|
|
898
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
expect(result).toMatchObject({ status: 'installed', items: ['theme/observatory'] })
|
|
904
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
905
|
+
"@import '../components/astrale/theme/observatory.css';",
|
|
906
|
+
)
|
|
907
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
908
|
+
expect(written.items['theme/observatory']).toMatchObject({
|
|
909
|
+
address: 'theme/observatory',
|
|
910
|
+
files: { 'components/astrale/theme/observatory.css': digest(themeCss) },
|
|
911
|
+
})
|
|
912
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
/** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
|
|
916
|
+
test('installs a playground-exported theme without registry or shadcn and preserves edits', async () => {
|
|
917
|
+
const root = await lockedFixture()
|
|
918
|
+
const source = path.join(root, 'observatory.css')
|
|
919
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
920
|
+
await writeFile(source, themeCss)
|
|
921
|
+
let invoked = false
|
|
922
|
+
|
|
923
|
+
const planned = await addUi(
|
|
924
|
+
['./observatory.css'],
|
|
925
|
+
{ project: root, dryRun: true },
|
|
926
|
+
{
|
|
927
|
+
fetcher: (async () => {
|
|
928
|
+
invoked = true
|
|
929
|
+
throw new Error('must not fetch')
|
|
930
|
+
}) as unknown as typeof fetch,
|
|
931
|
+
runner: async () => {
|
|
932
|
+
invoked = true
|
|
933
|
+
return { code: 1, stdout: '', stderr: '' }
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
)
|
|
937
|
+
expect(planned).toMatchObject({
|
|
938
|
+
status: 'planned',
|
|
939
|
+
items: ['theme/observatory'],
|
|
940
|
+
activation: {
|
|
941
|
+
file: 'src/index.css',
|
|
942
|
+
import: "@import '../components/astrale/theme/observatory.css';",
|
|
943
|
+
},
|
|
944
|
+
})
|
|
945
|
+
expect(await Bun.file(target).exists()).toBe(false)
|
|
946
|
+
|
|
947
|
+
await addUi(
|
|
948
|
+
['./observatory.css'],
|
|
949
|
+
{ project: root },
|
|
950
|
+
{
|
|
951
|
+
fetcher: (async () => {
|
|
952
|
+
invoked = true
|
|
953
|
+
throw new Error('must not fetch')
|
|
954
|
+
}) as unknown as typeof fetch,
|
|
955
|
+
runner: async () => {
|
|
956
|
+
invoked = true
|
|
957
|
+
return { code: 1, stdout: '', stderr: '' }
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
)
|
|
961
|
+
expect(invoked).toBe(false)
|
|
962
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
963
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
964
|
+
"@import '../components/astrale/theme/observatory.css';",
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
await writeFile(target, 'consumer edit\n')
|
|
968
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
|
|
969
|
+
code: 'UI_LOCAL_CHANGES',
|
|
970
|
+
})
|
|
971
|
+
await addUi(['./observatory.css'], { project: root, overwrite: true, yes: true })
|
|
972
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
test('rejects malformed and symlinked local themes before project mutation', async () => {
|
|
976
|
+
const root = await lockedFixture()
|
|
977
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
978
|
+
await writeFile(path.join(root, 'unsafe.css'), "@import 'https://example.invalid/theme.css';\n")
|
|
979
|
+
await expect(addUi(['./unsafe.css'], { project: root })).rejects.toMatchObject({
|
|
980
|
+
code: 'UI_ITEM_CONFLICT',
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-theme-'))
|
|
984
|
+
temporary.push(outside)
|
|
985
|
+
await writeFile(path.join(outside, 'observatory.css'), themeCss)
|
|
986
|
+
await symlink(path.join(outside, 'observatory.css'), path.join(root, 'linked.css'))
|
|
987
|
+
await expect(addUi(['./linked.css'], { project: root })).rejects.toMatchObject({
|
|
988
|
+
code: 'UI_ITEM_NOT_FOUND',
|
|
989
|
+
})
|
|
990
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
test('rejects a symlinked UI lock before a theme can mutate outside the project', async () => {
|
|
994
|
+
const root = await lockedFixture()
|
|
995
|
+
const source = path.join(root, 'observatory.css')
|
|
996
|
+
await writeFile(source, themeCss)
|
|
997
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
998
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-lock-'))
|
|
999
|
+
temporary.push(outside)
|
|
1000
|
+
const outsideLock = path.join(outside, 'astrale-ui.lock.json')
|
|
1001
|
+
const lockSource = await readFile(lockPath, 'utf8')
|
|
1002
|
+
await writeFile(outsideLock, lockSource)
|
|
1003
|
+
await rm(lockPath)
|
|
1004
|
+
await symlink(outsideLock, lockPath)
|
|
1005
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
1006
|
+
|
|
1007
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
|
|
1008
|
+
code: 'UI_LOCK_INVALID',
|
|
1009
|
+
})
|
|
1010
|
+
expect(await readFile(outsideLock, 'utf8')).toBe(lockSource)
|
|
1011
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
1012
|
+
expect(
|
|
1013
|
+
await Bun.file(path.join(root, 'components/astrale/theme/observatory.css')).exists(),
|
|
1014
|
+
).toBe(false)
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
test('rolls back a newly copied local theme and activation when the lock commit fails', async () => {
|
|
1018
|
+
const root = await lockedFixture()
|
|
1019
|
+
await writeFile(path.join(root, 'observatory.css'), themeCss)
|
|
1020
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
1021
|
+
const cssPath = path.join(root, 'src/index.css')
|
|
1022
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
1023
|
+
const lockBefore = await readFile(lockPath, 'utf8')
|
|
1024
|
+
const cssBefore = await readFile(cssPath, 'utf8')
|
|
1025
|
+
await chmod(lockPath, 0o444)
|
|
1026
|
+
|
|
1027
|
+
try {
|
|
1028
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toBeInstanceOf(Error)
|
|
1029
|
+
} finally {
|
|
1030
|
+
await chmod(lockPath, 0o644)
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
expect(await Bun.file(target).exists()).toBe(false)
|
|
1034
|
+
expect(await readFile(cssPath, 'utf8')).toBe(cssBefore)
|
|
1035
|
+
expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
|
|
1036
|
+
})
|
|
1037
|
+
|
|
727
1038
|
test('successful add records installed file digests and doctor detects later edits', async () => {
|
|
728
1039
|
const root = await lockedFixture()
|
|
729
1040
|
await writeFile(
|
package/src/ui/lock.ts
CHANGED
|
@@ -32,7 +32,9 @@ export function parseUiLock(value: unknown): UiLock {
|
|
|
32
32
|
}
|
|
33
33
|
for (const [address, item] of Object.entries(lock.items)) {
|
|
34
34
|
if (
|
|
35
|
-
!/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]
|
|
35
|
+
!/^(?:(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+|theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/u.test(
|
|
36
|
+
address,
|
|
37
|
+
) ||
|
|
36
38
|
!isRecord(item) ||
|
|
37
39
|
item.address !== address ||
|
|
38
40
|
!isDigest(item.sourceDigest) ||
|
package/src/ui/operations.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { digest, parseUiLock, readUiLock } from './lock'
|
|
|
6
6
|
import {
|
|
7
7
|
UI_PACKAGE,
|
|
8
8
|
UI_PRESETS,
|
|
9
|
+
UI_LOCK_FILE,
|
|
9
10
|
UiError,
|
|
10
11
|
type UiLock,
|
|
11
12
|
type UiPreset,
|
|
@@ -28,6 +29,46 @@ import { defaultUiRunner, shadcnInvocation, type UiRunner } from './runner'
|
|
|
28
29
|
|
|
29
30
|
type Dependencies = { fetcher?: typeof fetch; runner?: UiRunner }
|
|
30
31
|
|
|
32
|
+
const LOCAL_THEME_MAX_BYTES = 131_072
|
|
33
|
+
const THEME_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u
|
|
34
|
+
const REQUIRED_THEME_TOKENS = [
|
|
35
|
+
'background',
|
|
36
|
+
'foreground',
|
|
37
|
+
'card',
|
|
38
|
+
'card-foreground',
|
|
39
|
+
'popover',
|
|
40
|
+
'popover-foreground',
|
|
41
|
+
'primary',
|
|
42
|
+
'primary-foreground',
|
|
43
|
+
'secondary',
|
|
44
|
+
'secondary-foreground',
|
|
45
|
+
'muted',
|
|
46
|
+
'muted-foreground',
|
|
47
|
+
'accent',
|
|
48
|
+
'accent-foreground',
|
|
49
|
+
'destructive',
|
|
50
|
+
'destructive-foreground',
|
|
51
|
+
'border',
|
|
52
|
+
'input',
|
|
53
|
+
'ring',
|
|
54
|
+
'chart-1',
|
|
55
|
+
'chart-2',
|
|
56
|
+
'chart-3',
|
|
57
|
+
'chart-4',
|
|
58
|
+
'chart-5',
|
|
59
|
+
'font-body',
|
|
60
|
+
'font-heading',
|
|
61
|
+
'radius',
|
|
62
|
+
'radius-panel',
|
|
63
|
+
'control-height',
|
|
64
|
+
'control-height-sm',
|
|
65
|
+
'control-height-lg',
|
|
66
|
+
'shadow-control',
|
|
67
|
+
'shadow-panel',
|
|
68
|
+
'motion-fast',
|
|
69
|
+
'motion-standard',
|
|
70
|
+
] as const
|
|
71
|
+
|
|
31
72
|
async function exists(target: string): Promise<boolean> {
|
|
32
73
|
return access(target).then(
|
|
33
74
|
() => true,
|
|
@@ -39,6 +80,60 @@ async function readOptional(target: string): Promise<string | undefined> {
|
|
|
39
80
|
return readFile(target, 'utf8').catch(() => undefined)
|
|
40
81
|
}
|
|
41
82
|
|
|
83
|
+
function isLocalThemeAddress(address: string): boolean {
|
|
84
|
+
return (address.startsWith('./') || address.startsWith('../')) && address.endsWith('.css')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function admitLocalThemeCss(source: string, slug: string): void {
|
|
88
|
+
if (new TextEncoder().encode(source).byteLength > LOCAL_THEME_MAX_BYTES) {
|
|
89
|
+
throw new UiError('UI_ITEM_CONFLICT', 'Local theme CSS exceeds 128 KiB.')
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
!source.includes('Consumer-owned after installation.') ||
|
|
93
|
+
!source.includes("[data-ui-theme='" + slug + "']") ||
|
|
94
|
+
!source.includes("[data-ui-theme='" + slug + "'].dark") ||
|
|
95
|
+
/@import\b|url\s*\(/iu.test(source) ||
|
|
96
|
+
REQUIRED_THEME_TOKENS.some(
|
|
97
|
+
(token) => source.match(new RegExp(`--ui-${token}:`, 'gu'))?.length !== 2,
|
|
98
|
+
)
|
|
99
|
+
) {
|
|
100
|
+
throw new UiError(
|
|
101
|
+
'UI_ITEM_CONFLICT',
|
|
102
|
+
'Local theme CSS is not an admitted Astrale playground export.',
|
|
103
|
+
'Export the CSS from the Astrale UI playground, keep its filename aligned with the theme name, and try again.',
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function themeImport(project: UiProject, target: string): string {
|
|
109
|
+
let relative = path.relative(path.dirname(project.cssPath), target).split(path.sep).join('/')
|
|
110
|
+
if (!relative.startsWith('.')) relative = './' + relative
|
|
111
|
+
return "@import '" + relative + "';"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function activateTheme(current: string, statement: string): string {
|
|
115
|
+
const lines = current
|
|
116
|
+
.split(/\r?\n/u)
|
|
117
|
+
.filter(
|
|
118
|
+
(line) =>
|
|
119
|
+
!/^@import\s+['"][^'"]*components\/astrale\/theme\/[a-z][a-z0-9-]*\.css['"];?\s*$/u.test(
|
|
120
|
+
line,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
const packageImport = lines.reduce(
|
|
124
|
+
(last, line, index) =>
|
|
125
|
+
/^@import\s+['"]@astrale-os\/ui\/(?:theme\.css|presets\/[a-z-]+\.css)['"];?\s*$/u.test(line)
|
|
126
|
+
? index
|
|
127
|
+
: last,
|
|
128
|
+
-1,
|
|
129
|
+
)
|
|
130
|
+
lines.splice(packageImport + 1, 0, statement)
|
|
131
|
+
return lines
|
|
132
|
+
.join('\n')
|
|
133
|
+
.replace(/\n{3,}/gu, '\n\n')
|
|
134
|
+
.replace(/^\n/u, '')
|
|
135
|
+
}
|
|
136
|
+
|
|
42
137
|
function manifestDependencies(manifest: Record<string, unknown>): Record<string, string> {
|
|
43
138
|
return { ...(manifest.dependencies as Record<string, string> | undefined) }
|
|
44
139
|
}
|
|
@@ -191,6 +286,7 @@ export async function initUi(
|
|
|
191
286
|
dependencies: Dependencies = {},
|
|
192
287
|
): Promise<Record<string, unknown>> {
|
|
193
288
|
const project = await discoverUiProject(options.path)
|
|
289
|
+
await assertSafePlannedTarget(project, UI_LOCK_FILE)
|
|
194
290
|
assertSupportedUiProject(project)
|
|
195
291
|
const preset = options.preset ?? 'astrale'
|
|
196
292
|
if (!UI_PRESETS.includes(preset)) {
|
|
@@ -427,6 +523,85 @@ async function lockedRelease(
|
|
|
427
523
|
return { lock, release }
|
|
428
524
|
}
|
|
429
525
|
|
|
526
|
+
async function addLocalTheme(
|
|
527
|
+
address: string,
|
|
528
|
+
project: UiProject,
|
|
529
|
+
options: { dryRun?: boolean; overwrite?: boolean },
|
|
530
|
+
): Promise<Record<string, unknown>> {
|
|
531
|
+
const sourcePath = path.resolve(project.root, address)
|
|
532
|
+
const sourceInfo = await lstat(sourcePath).catch(() => undefined)
|
|
533
|
+
if (!sourceInfo?.isFile() || sourceInfo.isSymbolicLink()) {
|
|
534
|
+
throw new UiError('UI_ITEM_NOT_FOUND', 'Local theme is not a regular file: ' + address)
|
|
535
|
+
}
|
|
536
|
+
const slug = path.basename(sourcePath, '.css')
|
|
537
|
+
if (!THEME_SLUG.test(slug)) {
|
|
538
|
+
throw new UiError(
|
|
539
|
+
'UI_ITEM_CONFLICT',
|
|
540
|
+
'Local theme filename must be a kebab-case theme name.',
|
|
541
|
+
'Rename it to a name such as observatory.css.',
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
const source = await readFile(sourcePath, 'utf8')
|
|
545
|
+
admitLocalThemeCss(source, slug)
|
|
546
|
+
|
|
547
|
+
const lock = await readUiLock(project.uiLockPath)
|
|
548
|
+
const canonicalAddress = 'theme/' + slug
|
|
549
|
+
const relativeTarget = 'components/astrale/theme/' + slug + '.css'
|
|
550
|
+
const target = await assertSafePlannedTarget(project, relativeTarget)
|
|
551
|
+
const existing = await readOptional(target)
|
|
552
|
+
const installed = lock.items[canonicalAddress]
|
|
553
|
+
if (existing !== undefined && !installed && !options.overwrite) {
|
|
554
|
+
throw new UiError(
|
|
555
|
+
'UI_LOCAL_CHANGES',
|
|
556
|
+
'Theme target already exists outside the Astrale UI lock: ' + relativeTarget,
|
|
557
|
+
'Review the file, then repeat with explicit --overwrite --yes.',
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
if (installed && !options.overwrite) {
|
|
561
|
+
const expected = installed.files[relativeTarget]
|
|
562
|
+
if (!expected || digest(existing ?? '') !== expected) {
|
|
563
|
+
throw new UiError(
|
|
564
|
+
'UI_LOCAL_CHANGES',
|
|
565
|
+
'Installed UI file has local changes: ' + relativeTarget,
|
|
566
|
+
'Review the file, then repeat add with explicit --overwrite --yes.',
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const statement = themeImport(project, target)
|
|
571
|
+
const result = {
|
|
572
|
+
status: options.dryRun ? 'planned' : 'installed',
|
|
573
|
+
items: [canonicalAddress],
|
|
574
|
+
files: { [canonicalAddress]: { [relativeTarget]: digest(source) } },
|
|
575
|
+
activation: { file: projectRelative(project, project.cssPath), import: statement },
|
|
576
|
+
source: address,
|
|
577
|
+
}
|
|
578
|
+
if (options.dryRun) return result
|
|
579
|
+
|
|
580
|
+
const snapshots = new Map<string, string | undefined>()
|
|
581
|
+
for (const mutation of [target, project.cssPath, project.uiLockPath]) {
|
|
582
|
+
snapshots.set(mutation, await readOptional(mutation))
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
586
|
+
await writeFile(target, source, 'utf8')
|
|
587
|
+
const css = (await readOptional(project.cssPath)) ?? ''
|
|
588
|
+
await writeFile(project.cssPath, activateTheme(css, statement), 'utf8')
|
|
589
|
+
lock.items[canonicalAddress] = {
|
|
590
|
+
address: canonicalAddress,
|
|
591
|
+
sourceDigest: digest(source),
|
|
592
|
+
files: { [relativeTarget]: digest(source) },
|
|
593
|
+
}
|
|
594
|
+
await writeJson(project.uiLockPath, lock)
|
|
595
|
+
return result
|
|
596
|
+
} catch (error) {
|
|
597
|
+
for (const [mutation, previous] of snapshots) {
|
|
598
|
+
if (previous === undefined) await rm(mutation, { force: true })
|
|
599
|
+
else await writeFile(mutation, previous, 'utf8')
|
|
600
|
+
}
|
|
601
|
+
throw error
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
430
605
|
export async function addUi(
|
|
431
606
|
addresses: string[],
|
|
432
607
|
options: { project?: string; dryRun?: boolean; overwrite?: boolean; yes?: boolean },
|
|
@@ -436,11 +611,7 @@ export async function addUi(
|
|
|
436
611
|
throw new UiError('UI_ITEM_NOT_FOUND', 'No UI item was provided.')
|
|
437
612
|
}
|
|
438
613
|
const project = await discoverUiProject(options.project)
|
|
439
|
-
|
|
440
|
-
const items = addresses.map((address) => findItem(release, address))
|
|
441
|
-
const itemDocuments = await Promise.all(
|
|
442
|
-
items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)),
|
|
443
|
-
)
|
|
614
|
+
await assertSafePlannedTarget(project, UI_LOCK_FILE)
|
|
444
615
|
if (options.overwrite && !options.yes) {
|
|
445
616
|
throw new UiError(
|
|
446
617
|
'UI_LOCAL_CHANGES',
|
|
@@ -448,6 +619,25 @@ export async function addUi(
|
|
|
448
619
|
'Review the locally edited files, then repeat with both --overwrite and --yes.',
|
|
449
620
|
)
|
|
450
621
|
}
|
|
622
|
+
const localThemes = addresses.filter(isLocalThemeAddress)
|
|
623
|
+
if (localThemes.length > 0) {
|
|
624
|
+
if (addresses.length !== 1) {
|
|
625
|
+
throw new UiError(
|
|
626
|
+
'UI_ITEM_CONFLICT',
|
|
627
|
+
'Install one local theme at a time and do not mix local files with registry addresses.',
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
return addLocalTheme(localThemes[0]!, project, options)
|
|
631
|
+
}
|
|
632
|
+
const { lock, release } = await lockedRelease(project, dependencies.fetcher)
|
|
633
|
+
const items = addresses.map((address) => findItem(release, address))
|
|
634
|
+
const themes = items.filter((item) => item.type === 'registry:theme')
|
|
635
|
+
if (themes.length > 1) {
|
|
636
|
+
throw new UiError('UI_ITEM_CONFLICT', 'Only one theme can be activated per add operation.')
|
|
637
|
+
}
|
|
638
|
+
const itemDocuments = await Promise.all(
|
|
639
|
+
items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)),
|
|
640
|
+
)
|
|
451
641
|
await rejectLocalChanges(project, lock, items, options.overwrite === true)
|
|
452
642
|
const targets = (
|
|
453
643
|
await Promise.all(
|
|
@@ -469,7 +659,13 @@ export async function addUi(
|
|
|
469
659
|
])
|
|
470
660
|
const snapshots = new Map<string, string | undefined>()
|
|
471
661
|
if (!options.dryRun) {
|
|
472
|
-
for (const target of [
|
|
662
|
+
for (const target of [
|
|
663
|
+
project.packageJsonPath,
|
|
664
|
+
project.lockPath!,
|
|
665
|
+
project.cssPath,
|
|
666
|
+
project.uiLockPath,
|
|
667
|
+
...targets,
|
|
668
|
+
]) {
|
|
473
669
|
snapshots.set(target, await readOptional(target))
|
|
474
670
|
}
|
|
475
671
|
}
|
|
@@ -494,6 +690,13 @@ export async function addUi(
|
|
|
494
690
|
}
|
|
495
691
|
}
|
|
496
692
|
await pinUiDependency(project, lock.package.version, dependencies.runner ?? defaultUiRunner)
|
|
693
|
+
const theme = themes[0]
|
|
694
|
+
const themeTarget = theme?.files[0]?.target
|
|
695
|
+
if (themeTarget) {
|
|
696
|
+
const target = await safeTarget(project, themeTarget)
|
|
697
|
+
const css = (await readOptional(project.cssPath)) ?? ''
|
|
698
|
+
await writeFile(project.cssPath, activateTheme(css, themeImport(project, target)), 'utf8')
|
|
699
|
+
}
|
|
497
700
|
}
|
|
498
701
|
} catch (error) {
|
|
499
702
|
for (const [target, previous] of snapshots) {
|
|
@@ -513,6 +716,15 @@ export async function addUi(
|
|
|
513
716
|
})),
|
|
514
717
|
command: [invocation.file, ...invocation.args],
|
|
515
718
|
output: result.stdout,
|
|
719
|
+
activation: themes[0]
|
|
720
|
+
? {
|
|
721
|
+
file: projectRelative(project, project.cssPath),
|
|
722
|
+
import: themeImport(
|
|
723
|
+
project,
|
|
724
|
+
targets.find((target) => target.endsWith('.css'))!,
|
|
725
|
+
),
|
|
726
|
+
}
|
|
727
|
+
: undefined,
|
|
516
728
|
}
|
|
517
729
|
}
|
|
518
730
|
|
|
@@ -589,6 +801,15 @@ export async function doctorUi(
|
|
|
589
801
|
checks.push({ check: 'item:' + item.address + ':' + file, ok: actual === expected })
|
|
590
802
|
}
|
|
591
803
|
}
|
|
804
|
+
const themeFiles = Object.values(lock.items)
|
|
805
|
+
.filter((item) => item.address.startsWith('theme/'))
|
|
806
|
+
.flatMap((item) => Object.keys(item.files).filter((file) => file.endsWith('.css')))
|
|
807
|
+
if (themeFiles.length > 0) {
|
|
808
|
+
const active = themeFiles.some((file) =>
|
|
809
|
+
css.includes(themeImport(project, path.join(project.root, file))),
|
|
810
|
+
)
|
|
811
|
+
checks.push({ check: 'theme-active', ok: active })
|
|
812
|
+
}
|
|
592
813
|
}
|
|
593
814
|
return { healthy: checks.every((check) => check.ok), checks }
|
|
594
815
|
}
|
|
@@ -601,6 +822,7 @@ export async function applyPreset(
|
|
|
601
822
|
throw new UiError('UI_ITEM_NOT_FOUND', 'Unknown preset: ' + preset)
|
|
602
823
|
}
|
|
603
824
|
const project = await discoverUiProject(options.project)
|
|
825
|
+
await assertSafePlannedTarget(project, UI_LOCK_FILE)
|
|
604
826
|
const lock = await readUiLock(project.uiLockPath)
|
|
605
827
|
const css = (await readOptional(project.cssPath)) ?? ''
|
|
606
828
|
const next = css.replace(
|
package/src/ui/release.ts
CHANGED
|
@@ -237,10 +237,21 @@ function qualifyRegistryItemPaths(item: unknown, sourceDirectory: string): unkno
|
|
|
237
237
|
function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
|
|
238
238
|
if (!item || typeof item !== 'object') return false
|
|
239
239
|
const candidate = item as Partial<UiRegistry['items'][number]>
|
|
240
|
-
|
|
240
|
+
const address = candidate.meta?.canonicalAddress
|
|
241
|
+
const isTheme =
|
|
242
|
+
candidate.type === 'registry:theme' &&
|
|
241
243
|
typeof candidate.name === 'string' &&
|
|
242
|
-
/^
|
|
244
|
+
/^theme-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(candidate.name) &&
|
|
245
|
+
typeof address === 'string' &&
|
|
246
|
+
/^theme\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(address)
|
|
247
|
+
const isComposition =
|
|
243
248
|
candidate.type === 'registry:block' &&
|
|
249
|
+
typeof candidate.name === 'string' &&
|
|
250
|
+
/^(?:pattern|block)-[a-z0-9-]+$/u.test(candidate.name) &&
|
|
251
|
+
typeof address === 'string' &&
|
|
252
|
+
/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(address)
|
|
253
|
+
return (
|
|
254
|
+
(isTheme || isComposition) &&
|
|
244
255
|
(candidate.dependencies === undefined ||
|
|
245
256
|
(Array.isArray(candidate.dependencies) &&
|
|
246
257
|
candidate.dependencies.every(
|
|
@@ -258,10 +269,14 @@ function isInstallableItem(item: unknown): item is UiRegistry['items'][number] {
|
|
|
258
269
|
typeof file.type === 'string' &&
|
|
259
270
|
typeof file.target === 'string' &&
|
|
260
271
|
file.target.startsWith('components/astrale/') &&
|
|
261
|
-
isSafeRelative(file.target)
|
|
272
|
+
isSafeRelative(file.target) &&
|
|
273
|
+
(!isTheme ||
|
|
274
|
+
(candidate.files?.length === 1 &&
|
|
275
|
+
file.type === 'registry:file' &&
|
|
276
|
+
file.target ===
|
|
277
|
+
'components/astrale/theme/' + address!.slice('theme/'.length) + '.css')),
|
|
262
278
|
) &&
|
|
263
|
-
typeof
|
|
264
|
-
/^(?:pattern|block)\/[a-z0-9-]+\/[a-z0-9-/]+$/u.test(candidate.meta.canonicalAddress)
|
|
279
|
+
typeof address === 'string'
|
|
265
280
|
)
|
|
266
281
|
}
|
|
267
282
|
|
package/viewer/dist/index.html
CHANGED
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
display: flex;
|
|
33
33
|
gap: 12px;
|
|
34
34
|
align-items: center;
|
|
35
|
+
min-width: 0;
|
|
36
|
+
overflow: hidden;
|
|
35
37
|
padding: 6px 10px;
|
|
36
38
|
border-bottom: 1px solid #26262e;
|
|
37
39
|
flex: none;
|
|
@@ -56,6 +58,23 @@
|
|
|
56
58
|
font-weight: 600;
|
|
57
59
|
color: #e8e8f0;
|
|
58
60
|
}
|
|
61
|
+
#view-label,
|
|
62
|
+
#target-label,
|
|
63
|
+
#identity-label {
|
|
64
|
+
min-width: 0;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
text-overflow: ellipsis;
|
|
67
|
+
white-space: nowrap;
|
|
68
|
+
}
|
|
69
|
+
#view-label {
|
|
70
|
+
flex-shrink: 1;
|
|
71
|
+
}
|
|
72
|
+
#target-label {
|
|
73
|
+
flex-shrink: 2;
|
|
74
|
+
}
|
|
75
|
+
#identity-label {
|
|
76
|
+
flex-shrink: 3;
|
|
77
|
+
}
|
|
59
78
|
.dim {
|
|
60
79
|
color: #7a7a86;
|
|
61
80
|
}
|
|
@@ -68,6 +87,8 @@
|
|
|
68
87
|
}
|
|
69
88
|
#frame {
|
|
70
89
|
flex: 1;
|
|
90
|
+
min-width: 0;
|
|
91
|
+
min-height: 0;
|
|
71
92
|
position: relative;
|
|
72
93
|
}
|
|
73
94
|
#frame iframe {
|