@astrale-os/cli 1.0.0-beta.20 → 1.0.0-beta.22
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 +208 -16
- 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 +6 -3
- package/src/ui/.spec/laws.ts +8 -1
- package/src/ui/__tests__/ui.test.ts +320 -2
- package/src/ui/lock.ts +3 -1
- package/src/ui/operations.ts +270 -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.22",
|
|
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,16 +85143,111 @@ 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
|
+
return installThemeCss(slug, source2, digest5(source2), address, project2, options);
|
|
85161
|
+
}
|
|
85162
|
+
async function installThemeCss(slug, source2, sourceDigest, sourceLabel, project2, options) {
|
|
85163
|
+
const lock = await readUiLock(project2.uiLockPath);
|
|
85164
|
+
const canonicalAddress = "theme/" + slug;
|
|
85165
|
+
const relativeTarget = "components/astrale/theme/" + slug + ".css";
|
|
85166
|
+
const target2 = await assertSafePlannedTarget(project2, relativeTarget);
|
|
85167
|
+
const existing = await readOptional(target2);
|
|
85168
|
+
const installed = lock.items[canonicalAddress];
|
|
85169
|
+
if (existing !== undefined && !installed && !options.overwrite) {
|
|
85170
|
+
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.");
|
|
85171
|
+
}
|
|
85172
|
+
if (installed && !options.overwrite) {
|
|
85173
|
+
const expected = installed.files[relativeTarget];
|
|
85174
|
+
if (!expected || digest5(existing ?? "") !== expected) {
|
|
85175
|
+
throw new UiError("UI_LOCAL_CHANGES", "Installed UI file has local changes: " + relativeTarget, "Review the file, then repeat add with explicit --overwrite --yes.");
|
|
85176
|
+
}
|
|
85177
|
+
}
|
|
85178
|
+
const statement = themeImport(project2, target2);
|
|
85179
|
+
const result = {
|
|
85180
|
+
status: options.dryRun ? "planned" : "installed",
|
|
85181
|
+
items: [canonicalAddress],
|
|
85182
|
+
files: { [canonicalAddress]: { [relativeTarget]: digest5(source2) } },
|
|
85183
|
+
activation: { file: projectRelative(project2, project2.cssPath), import: statement },
|
|
85184
|
+
source: sourceLabel
|
|
85185
|
+
};
|
|
85186
|
+
if (options.dryRun)
|
|
85187
|
+
return result;
|
|
85188
|
+
const snapshots = new Map;
|
|
85189
|
+
for (const mutation of [target2, project2.cssPath, project2.uiLockPath]) {
|
|
85190
|
+
snapshots.set(mutation, await readOptional(mutation));
|
|
85191
|
+
}
|
|
85192
|
+
try {
|
|
85193
|
+
await mkdir10(path6.dirname(target2), { recursive: true });
|
|
85194
|
+
await writeFile6(target2, source2, "utf8");
|
|
85195
|
+
const css = await readOptional(project2.cssPath) ?? "";
|
|
85196
|
+
await writeFile6(project2.cssPath, activateTheme(css, statement), "utf8");
|
|
85197
|
+
lock.items[canonicalAddress] = {
|
|
85198
|
+
address: canonicalAddress,
|
|
85199
|
+
sourceDigest,
|
|
85200
|
+
files: { [relativeTarget]: digest5(source2) }
|
|
85201
|
+
};
|
|
85202
|
+
await writeJson(project2.uiLockPath, lock);
|
|
85203
|
+
return result;
|
|
85204
|
+
} catch (error52) {
|
|
85205
|
+
for (const [mutation, previous] of snapshots) {
|
|
85206
|
+
if (previous === undefined)
|
|
85207
|
+
await rm3(mutation, { force: true });
|
|
85208
|
+
else
|
|
85209
|
+
await writeFile6(mutation, previous, "utf8");
|
|
85210
|
+
}
|
|
85211
|
+
throw error52;
|
|
85212
|
+
}
|
|
85213
|
+
}
|
|
85214
|
+
async function addReleasedTheme(item, document, project2, options) {
|
|
85215
|
+
const slug = item.meta.canonicalAddress.slice("theme/".length);
|
|
85216
|
+
const source2 = document.files[0]?.content;
|
|
85217
|
+
if (typeof source2 !== "string") {
|
|
85218
|
+
throw new UiError("UI_REGISTRY_UNAVAILABLE", "The released theme has no admitted CSS source.");
|
|
85219
|
+
}
|
|
85220
|
+
admitLocalThemeCss(source2, slug);
|
|
85221
|
+
return installThemeCss(slug, source2, digest5(JSON.stringify(document)), item.meta.canonicalAddress, project2, options);
|
|
85222
|
+
}
|
|
85116
85223
|
async function addUi(addresses, options, dependencies = {}) {
|
|
85117
85224
|
if (addresses.length === 0) {
|
|
85118
85225
|
throw new UiError("UI_ITEM_NOT_FOUND", "No UI item was provided.");
|
|
85119
85226
|
}
|
|
85120
85227
|
const project2 = await discoverUiProject(options.project);
|
|
85228
|
+
await assertSafePlannedTarget(project2, UI_LOCK_FILE);
|
|
85229
|
+
if (options.overwrite && !options.yes) {
|
|
85230
|
+
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.");
|
|
85231
|
+
}
|
|
85232
|
+
const localThemes = addresses.filter(isLocalThemeAddress);
|
|
85233
|
+
if (localThemes.length > 0) {
|
|
85234
|
+
if (addresses.length !== 1) {
|
|
85235
|
+
throw new UiError("UI_ITEM_CONFLICT", "Install one local theme at a time and do not mix local files with registry addresses.");
|
|
85236
|
+
}
|
|
85237
|
+
return addLocalTheme(localThemes[0], project2, options);
|
|
85238
|
+
}
|
|
85121
85239
|
const { lock, release } = await lockedRelease(project2, dependencies.fetcher);
|
|
85122
85240
|
const items = addresses.map((address) => findItem(release, address));
|
|
85241
|
+
const themes = items.filter((item) => item.type === "registry:theme");
|
|
85242
|
+
if (themes.length > 1) {
|
|
85243
|
+
throw new UiError("UI_ITEM_CONFLICT", "Only one theme can be activated per add operation.");
|
|
85244
|
+
}
|
|
85123
85245
|
const itemDocuments = await Promise.all(items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)));
|
|
85124
|
-
if (
|
|
85125
|
-
|
|
85246
|
+
if (themes.length === 1) {
|
|
85247
|
+
if (items.length !== 1) {
|
|
85248
|
+
throw new UiError("UI_ITEM_CONFLICT", "Install a released theme separately from patterns and blocks.");
|
|
85249
|
+
}
|
|
85250
|
+
return addReleasedTheme(themes[0], itemDocuments[0], project2, options);
|
|
85126
85251
|
}
|
|
85127
85252
|
await rejectLocalChanges(project2, lock, items, options.overwrite === true);
|
|
85128
85253
|
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);
|
|
@@ -85137,7 +85262,13 @@ async function addUi(addresses, options, dependencies = {}) {
|
|
|
85137
85262
|
]);
|
|
85138
85263
|
const snapshots = new Map;
|
|
85139
85264
|
if (!options.dryRun) {
|
|
85140
|
-
for (const target2 of [
|
|
85265
|
+
for (const target2 of [
|
|
85266
|
+
project2.packageJsonPath,
|
|
85267
|
+
project2.lockPath,
|
|
85268
|
+
project2.cssPath,
|
|
85269
|
+
project2.uiLockPath,
|
|
85270
|
+
...targets
|
|
85271
|
+
]) {
|
|
85141
85272
|
snapshots.set(target2, await readOptional(target2));
|
|
85142
85273
|
}
|
|
85143
85274
|
}
|
|
@@ -85154,6 +85285,13 @@ async function addUi(addresses, options, dependencies = {}) {
|
|
|
85154
85285
|
}
|
|
85155
85286
|
}
|
|
85156
85287
|
await pinUiDependency(project2, lock.package.version, dependencies.runner ?? defaultUiRunner);
|
|
85288
|
+
const theme = themes[0];
|
|
85289
|
+
const themeTarget = theme?.files[0]?.target;
|
|
85290
|
+
if (themeTarget) {
|
|
85291
|
+
const target2 = await safeTarget(project2, themeTarget);
|
|
85292
|
+
const css = await readOptional(project2.cssPath) ?? "";
|
|
85293
|
+
await writeFile6(project2.cssPath, activateTheme(css, themeImport(project2, target2)), "utf8");
|
|
85294
|
+
}
|
|
85157
85295
|
}
|
|
85158
85296
|
} catch (error52) {
|
|
85159
85297
|
for (const [target2, previous] of snapshots) {
|
|
@@ -85174,7 +85312,11 @@ async function addUi(addresses, options, dependencies = {}) {
|
|
|
85174
85312
|
files: item.files.map((file2) => file2.target).filter(Boolean)
|
|
85175
85313
|
})),
|
|
85176
85314
|
command: [invocation3.file, ...invocation3.args],
|
|
85177
|
-
output: result.stdout
|
|
85315
|
+
output: result.stdout,
|
|
85316
|
+
activation: themes[0] ? {
|
|
85317
|
+
file: projectRelative(project2, project2.cssPath),
|
|
85318
|
+
import: themeImport(project2, targets.find((target2) => target2.endsWith(".css")))
|
|
85319
|
+
} : undefined
|
|
85178
85320
|
};
|
|
85179
85321
|
}
|
|
85180
85322
|
try {
|
|
@@ -85246,6 +85388,11 @@ async function doctorUi(input) {
|
|
|
85246
85388
|
checks3.push({ check: "item:" + item.address + ":" + file2, ok: actual === expected });
|
|
85247
85389
|
}
|
|
85248
85390
|
}
|
|
85391
|
+
const themeFiles = Object.values(lock.items).filter((item) => item.address.startsWith("theme/")).flatMap((item) => Object.keys(item.files).filter((file2) => file2.endsWith(".css")));
|
|
85392
|
+
if (themeFiles.length > 0) {
|
|
85393
|
+
const active = themeFiles.some((file2) => css.includes(themeImport(project2, path6.join(project2.root, file2))));
|
|
85394
|
+
checks3.push({ check: "theme-active", ok: active });
|
|
85395
|
+
}
|
|
85249
85396
|
}
|
|
85250
85397
|
return { healthy: checks3.every((check3) => check3.ok), checks: checks3 };
|
|
85251
85398
|
}
|
|
@@ -85254,6 +85401,7 @@ async function applyPreset(preset, options) {
|
|
|
85254
85401
|
throw new UiError("UI_ITEM_NOT_FOUND", "Unknown preset: " + preset);
|
|
85255
85402
|
}
|
|
85256
85403
|
const project2 = await discoverUiProject(options.project);
|
|
85404
|
+
await assertSafePlannedTarget(project2, UI_LOCK_FILE);
|
|
85257
85405
|
const lock = await readUiLock(project2.uiLockPath);
|
|
85258
85406
|
const css = await readOptional(project2.cssPath) ?? "";
|
|
85259
85407
|
const next = css.replace(/@astrale-os\/ui\/presets\/[a-z-]+\.css/gu, "@astrale-os/ui/presets/" + preset + ".css");
|
|
@@ -85336,6 +85484,7 @@ async function writeJson(target2, value3) {
|
|
|
85336
85484
|
await writeFile6(target2, JSON.stringify(value3, null, 2) + `
|
|
85337
85485
|
`, "utf8");
|
|
85338
85486
|
}
|
|
85487
|
+
var LOCAL_THEME_MAX_BYTES = 131072, THEME_SLUG, REQUIRED_THEME_TOKENS;
|
|
85339
85488
|
var init_operations3 = __esm(() => {
|
|
85340
85489
|
init_dist();
|
|
85341
85490
|
init_lock();
|
|
@@ -85343,6 +85492,44 @@ var init_operations3 = __esm(() => {
|
|
|
85343
85492
|
init_project3();
|
|
85344
85493
|
init_release();
|
|
85345
85494
|
init_runner();
|
|
85495
|
+
THEME_SLUG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
85496
|
+
REQUIRED_THEME_TOKENS = [
|
|
85497
|
+
"background",
|
|
85498
|
+
"foreground",
|
|
85499
|
+
"card",
|
|
85500
|
+
"card-foreground",
|
|
85501
|
+
"popover",
|
|
85502
|
+
"popover-foreground",
|
|
85503
|
+
"primary",
|
|
85504
|
+
"primary-foreground",
|
|
85505
|
+
"secondary",
|
|
85506
|
+
"secondary-foreground",
|
|
85507
|
+
"muted",
|
|
85508
|
+
"muted-foreground",
|
|
85509
|
+
"accent",
|
|
85510
|
+
"accent-foreground",
|
|
85511
|
+
"destructive",
|
|
85512
|
+
"destructive-foreground",
|
|
85513
|
+
"border",
|
|
85514
|
+
"input",
|
|
85515
|
+
"ring",
|
|
85516
|
+
"chart-1",
|
|
85517
|
+
"chart-2",
|
|
85518
|
+
"chart-3",
|
|
85519
|
+
"chart-4",
|
|
85520
|
+
"chart-5",
|
|
85521
|
+
"font-body",
|
|
85522
|
+
"font-heading",
|
|
85523
|
+
"radius",
|
|
85524
|
+
"radius-panel",
|
|
85525
|
+
"control-height",
|
|
85526
|
+
"control-height-sm",
|
|
85527
|
+
"control-height-lg",
|
|
85528
|
+
"shadow-control",
|
|
85529
|
+
"shadow-panel",
|
|
85530
|
+
"motion-fast",
|
|
85531
|
+
"motion-standard"
|
|
85532
|
+
];
|
|
85346
85533
|
});
|
|
85347
85534
|
|
|
85348
85535
|
// src/ui/index.ts
|
|
@@ -85425,13 +85612,13 @@ var init_list = __esm(() => {
|
|
|
85425
85612
|
init_shared();
|
|
85426
85613
|
list_default = {
|
|
85427
85614
|
name: "list",
|
|
85428
|
-
description: "List or search Astrale patterns and
|
|
85615
|
+
description: "List or search Astrale patterns, blocks, and themes",
|
|
85429
85616
|
arguments: [{ name: "query", description: "Optional search text", required: false }],
|
|
85430
85617
|
options: [
|
|
85431
85618
|
{
|
|
85432
85619
|
flags: "--type <type>",
|
|
85433
|
-
description: "Restrict to pattern or
|
|
85434
|
-
choices: ["pattern", "block"]
|
|
85620
|
+
description: "Restrict to pattern, block, or theme",
|
|
85621
|
+
choices: ["pattern", "block", "theme"]
|
|
85435
85622
|
},
|
|
85436
85623
|
{ flags: "--limit <n>", description: "Maximum results", default: "100" },
|
|
85437
85624
|
{ flags: "--version <version>", description: "Exact release to inspect" },
|
|
@@ -85453,11 +85640,11 @@ var init_add = __esm(() => {
|
|
|
85453
85640
|
init_shared();
|
|
85454
85641
|
add_default = {
|
|
85455
85642
|
name: "add",
|
|
85456
|
-
description: "Install consumer-owned Astrale pattern or
|
|
85643
|
+
description: "Install consumer-owned Astrale pattern, block, or theme source",
|
|
85457
85644
|
arguments: [
|
|
85458
85645
|
{
|
|
85459
85646
|
name: "items",
|
|
85460
|
-
description: "Canonical
|
|
85647
|
+
description: "Canonical registry addresses or a local exported theme CSS file",
|
|
85461
85648
|
required: false,
|
|
85462
85649
|
variadic: true
|
|
85463
85650
|
}
|
|
@@ -85470,8 +85657,8 @@ var init_add = __esm(() => {
|
|
|
85470
85657
|
UI_JSON_OPTION
|
|
85471
85658
|
],
|
|
85472
85659
|
afterHelpText: `
|
|
85473
|
-
Installed source belongs to the application.
|
|
85474
|
-
Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
|
|
85660
|
+
Installed source belongs to the application. Use theme/<name> for a released theme or ./theme.css for a playground export.
|
|
85661
|
+
Ordinary add never overwrites local edits. Run astrale ui doctor before intentionally replacing local source with --overwrite --yes.
|
|
85475
85662
|
`,
|
|
85476
85663
|
action: async (items, options) => runUiCommand(options, async () => {
|
|
85477
85664
|
let selected = items;
|
|
@@ -85506,8 +85693,13 @@ var init_doctor = __esm(() => {
|
|
|
85506
85693
|
name: "doctor",
|
|
85507
85694
|
description: "Verify Astrale UI package, CSS, config, lock, and installed source integrity",
|
|
85508
85695
|
arguments: [{ name: "path", description: "Application directory", required: false }],
|
|
85509
|
-
options: [UI_JSON_OPTION],
|
|
85510
|
-
action: async (path7, options) => runUiCommand(options, () =>
|
|
85696
|
+
options: [UI_PROJECT_OPTION, UI_JSON_OPTION],
|
|
85697
|
+
action: async (path7, options) => runUiCommand(options, () => {
|
|
85698
|
+
if (path7 && options.project) {
|
|
85699
|
+
throw new UiError("UI_ITEM_CONFLICT", "Choose either the positional application path or --project, not both.");
|
|
85700
|
+
}
|
|
85701
|
+
return doctorUi(options.project ?? path7);
|
|
85702
|
+
})
|
|
85511
85703
|
};
|
|
85512
85704
|
});
|
|
85513
85705
|
|
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,10 @@ 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
|
-
|
|
9
|
-
|
|
7
|
+
One registry operation resolves one immutable UI commit. Package compatibility and registry reads
|
|
8
|
+
use that commit. Patterns and blocks invoke the release-qualified shadcn CLI on demand through the
|
|
9
|
+
detected package manager. Released themes copy the exact admitted embedded CSS directly, while a
|
|
10
|
+
local playground export is admitted and copied without registry or shadcn. Both theme paths install
|
|
11
|
+
consumer-owned source and activate it through one project-relative CSS
|
|
12
|
+
import, and complete application writes before the lock advances. Dry-run, list, and doctor do not
|
|
10
13
|
claim a file mutation.
|
package/src/ui/.spec/laws.ts
CHANGED
|
@@ -24,10 +24,17 @@ export const CLI_UI_BOUNDED_REMOTE_DOCUMENTS = defineLaw({
|
|
|
24
24
|
export const CLI_UI_EXACT_ITEM_SOURCE = defineLaw({
|
|
25
25
|
id: 'CLI-UI-EXACT-ITEM-SOURCE',
|
|
26
26
|
statement:
|
|
27
|
-
'An add operation admits the exact built item document against the release index and records
|
|
27
|
+
'An add operation admits the exact built item document against the release index and records its content-bearing source digest before any consumer mutation; only patterns and blocks invoke project tooling.',
|
|
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,38 @@ 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(builtThemeItem())
|
|
187
|
+
}
|
|
188
|
+
return new Response('not found', { status: 404 })
|
|
189
|
+
}) as typeof fetch
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function builtThemeItem() {
|
|
193
|
+
return {
|
|
194
|
+
...themeRegistry.items[0],
|
|
195
|
+
files: [
|
|
196
|
+
{
|
|
197
|
+
...themeRegistry.items[0]!.files[0],
|
|
198
|
+
path: 'registry/themes/observatory.css',
|
|
199
|
+
content: themeCss,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
77
205
|
function builtItem(item: UiRegistry['items'][number]) {
|
|
78
206
|
return {
|
|
79
207
|
...item,
|
|
@@ -176,6 +304,33 @@ describe('UI release and runner contracts', () => {
|
|
|
176
304
|
)
|
|
177
305
|
})
|
|
178
306
|
|
|
307
|
+
test('admits a release theme as one canonical consumer-owned CSS target', async () => {
|
|
308
|
+
const release = await resolveUiRelease('0.3.0-beta.0', themeFetch())
|
|
309
|
+
expect(release.registry.items).toEqual([
|
|
310
|
+
expect.objectContaining({
|
|
311
|
+
name: 'theme-observatory',
|
|
312
|
+
type: 'registry:theme',
|
|
313
|
+
meta: expect.objectContaining({ canonicalAddress: 'theme/observatory' }),
|
|
314
|
+
files: [
|
|
315
|
+
expect.objectContaining({
|
|
316
|
+
type: 'registry:file',
|
|
317
|
+
path: 'registry/themes/observatory.css',
|
|
318
|
+
target: 'components/astrale/theme/observatory.css',
|
|
319
|
+
}),
|
|
320
|
+
],
|
|
321
|
+
}),
|
|
322
|
+
])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('lists release themes through the public type filter', async () => {
|
|
326
|
+
const themes = await listUi(
|
|
327
|
+
undefined,
|
|
328
|
+
{ type: 'theme', version: '0.3.0-beta.0' },
|
|
329
|
+
{ fetcher: themeFetch() },
|
|
330
|
+
)
|
|
331
|
+
expect(themes.map((item) => item.meta.canonicalAddress)).toEqual(['theme/observatory'])
|
|
332
|
+
})
|
|
333
|
+
|
|
179
334
|
test('constructs exact on-demand commands for every supported package manager', () => {
|
|
180
335
|
expect(shadcnInvocation('pnpm', '4.18.0', ['add', 'item'])).toEqual({
|
|
181
336
|
file: 'pnpm',
|
|
@@ -724,6 +879,169 @@ describe('UI source operations', () => {
|
|
|
724
879
|
expect(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8')).toBe(before)
|
|
725
880
|
})
|
|
726
881
|
|
|
882
|
+
/** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
|
|
883
|
+
test('installs and activates a released theme while recording owned source', async () => {
|
|
884
|
+
const root = await lockedFixture()
|
|
885
|
+
await writeFile(
|
|
886
|
+
path.join(root, 'components.json'),
|
|
887
|
+
JSON.stringify({ style: 'base-nova', tailwind: { css: 'src/index.css' } }),
|
|
888
|
+
)
|
|
889
|
+
await writeFile(
|
|
890
|
+
path.join(root, 'src/index.css'),
|
|
891
|
+
"@import '@astrale-os/ui/theme.css';\n@import '@astrale-os/ui/presets/astrale.css';\n",
|
|
892
|
+
)
|
|
893
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
894
|
+
let invoked = false
|
|
895
|
+
const result = await addUi(
|
|
896
|
+
['theme/observatory'],
|
|
897
|
+
{ project: root, yes: true },
|
|
898
|
+
{
|
|
899
|
+
fetcher: themeFetch(),
|
|
900
|
+
runner: async () => {
|
|
901
|
+
invoked = true
|
|
902
|
+
return { code: 0, stdout: '', stderr: '' }
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
expect(invoked).toBe(false)
|
|
908
|
+
expect(result).toMatchObject({ status: 'installed', items: ['theme/observatory'] })
|
|
909
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
910
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
911
|
+
"@import '../components/astrale/theme/observatory.css';",
|
|
912
|
+
)
|
|
913
|
+
const written = JSON.parse(await readFile(path.join(root, 'astrale-ui.lock.json'), 'utf8'))
|
|
914
|
+
expect(written.items['theme/observatory']).toMatchObject({
|
|
915
|
+
address: 'theme/observatory',
|
|
916
|
+
sourceDigest: digest(JSON.stringify(builtThemeItem())),
|
|
917
|
+
files: { 'components/astrale/theme/observatory.css': digest(themeCss) },
|
|
918
|
+
})
|
|
919
|
+
expect((await doctorUi(root)).healthy).toBe(true)
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
/** @evidence TEST-CLI-UI-THEME-OWNERSHIP */
|
|
923
|
+
test('installs a playground-exported theme without registry or shadcn and preserves edits', async () => {
|
|
924
|
+
const root = await lockedFixture()
|
|
925
|
+
const source = path.join(root, 'observatory.css')
|
|
926
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
927
|
+
await writeFile(source, themeCss)
|
|
928
|
+
let invoked = false
|
|
929
|
+
|
|
930
|
+
const planned = await addUi(
|
|
931
|
+
['./observatory.css'],
|
|
932
|
+
{ project: root, dryRun: true },
|
|
933
|
+
{
|
|
934
|
+
fetcher: (async () => {
|
|
935
|
+
invoked = true
|
|
936
|
+
throw new Error('must not fetch')
|
|
937
|
+
}) as unknown as typeof fetch,
|
|
938
|
+
runner: async () => {
|
|
939
|
+
invoked = true
|
|
940
|
+
return { code: 1, stdout: '', stderr: '' }
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
)
|
|
944
|
+
expect(planned).toMatchObject({
|
|
945
|
+
status: 'planned',
|
|
946
|
+
items: ['theme/observatory'],
|
|
947
|
+
activation: {
|
|
948
|
+
file: 'src/index.css',
|
|
949
|
+
import: "@import '../components/astrale/theme/observatory.css';",
|
|
950
|
+
},
|
|
951
|
+
})
|
|
952
|
+
expect(await Bun.file(target).exists()).toBe(false)
|
|
953
|
+
|
|
954
|
+
await addUi(
|
|
955
|
+
['./observatory.css'],
|
|
956
|
+
{ project: root },
|
|
957
|
+
{
|
|
958
|
+
fetcher: (async () => {
|
|
959
|
+
invoked = true
|
|
960
|
+
throw new Error('must not fetch')
|
|
961
|
+
}) as unknown as typeof fetch,
|
|
962
|
+
runner: async () => {
|
|
963
|
+
invoked = true
|
|
964
|
+
return { code: 1, stdout: '', stderr: '' }
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
)
|
|
968
|
+
expect(invoked).toBe(false)
|
|
969
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
970
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toContain(
|
|
971
|
+
"@import '../components/astrale/theme/observatory.css';",
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
await writeFile(target, 'consumer edit\n')
|
|
975
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
|
|
976
|
+
code: 'UI_LOCAL_CHANGES',
|
|
977
|
+
})
|
|
978
|
+
await addUi(['./observatory.css'], { project: root, overwrite: true, yes: true })
|
|
979
|
+
expect(await readFile(target, 'utf8')).toBe(themeCss)
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
test('rejects malformed and symlinked local themes before project mutation', async () => {
|
|
983
|
+
const root = await lockedFixture()
|
|
984
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
985
|
+
await writeFile(path.join(root, 'unsafe.css'), "@import 'https://example.invalid/theme.css';\n")
|
|
986
|
+
await expect(addUi(['./unsafe.css'], { project: root })).rejects.toMatchObject({
|
|
987
|
+
code: 'UI_ITEM_CONFLICT',
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-theme-'))
|
|
991
|
+
temporary.push(outside)
|
|
992
|
+
await writeFile(path.join(outside, 'observatory.css'), themeCss)
|
|
993
|
+
await symlink(path.join(outside, 'observatory.css'), path.join(root, 'linked.css'))
|
|
994
|
+
await expect(addUi(['./linked.css'], { project: root })).rejects.toMatchObject({
|
|
995
|
+
code: 'UI_ITEM_NOT_FOUND',
|
|
996
|
+
})
|
|
997
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
test('rejects a symlinked UI lock before a theme can mutate outside the project', async () => {
|
|
1001
|
+
const root = await lockedFixture()
|
|
1002
|
+
const source = path.join(root, 'observatory.css')
|
|
1003
|
+
await writeFile(source, themeCss)
|
|
1004
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
1005
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'astrale-ui-lock-'))
|
|
1006
|
+
temporary.push(outside)
|
|
1007
|
+
const outsideLock = path.join(outside, 'astrale-ui.lock.json')
|
|
1008
|
+
const lockSource = await readFile(lockPath, 'utf8')
|
|
1009
|
+
await writeFile(outsideLock, lockSource)
|
|
1010
|
+
await rm(lockPath)
|
|
1011
|
+
await symlink(outsideLock, lockPath)
|
|
1012
|
+
const cssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
1013
|
+
|
|
1014
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toMatchObject({
|
|
1015
|
+
code: 'UI_LOCK_INVALID',
|
|
1016
|
+
})
|
|
1017
|
+
expect(await readFile(outsideLock, 'utf8')).toBe(lockSource)
|
|
1018
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(cssBefore)
|
|
1019
|
+
expect(
|
|
1020
|
+
await Bun.file(path.join(root, 'components/astrale/theme/observatory.css')).exists(),
|
|
1021
|
+
).toBe(false)
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
test('rolls back a newly copied local theme and activation when the lock commit fails', async () => {
|
|
1025
|
+
const root = await lockedFixture()
|
|
1026
|
+
await writeFile(path.join(root, 'observatory.css'), themeCss)
|
|
1027
|
+
const lockPath = path.join(root, 'astrale-ui.lock.json')
|
|
1028
|
+
const cssPath = path.join(root, 'src/index.css')
|
|
1029
|
+
const target = path.join(root, 'components/astrale/theme/observatory.css')
|
|
1030
|
+
const lockBefore = await readFile(lockPath, 'utf8')
|
|
1031
|
+
const cssBefore = await readFile(cssPath, 'utf8')
|
|
1032
|
+
await chmod(lockPath, 0o444)
|
|
1033
|
+
|
|
1034
|
+
try {
|
|
1035
|
+
await expect(addUi(['./observatory.css'], { project: root })).rejects.toBeInstanceOf(Error)
|
|
1036
|
+
} finally {
|
|
1037
|
+
await chmod(lockPath, 0o644)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
expect(await Bun.file(target).exists()).toBe(false)
|
|
1041
|
+
expect(await readFile(cssPath, 'utf8')).toBe(cssBefore)
|
|
1042
|
+
expect(await readFile(lockPath, 'utf8')).toBe(lockBefore)
|
|
1043
|
+
})
|
|
1044
|
+
|
|
727
1045
|
test('successful add records installed file digests and doctor detects later edits', async () => {
|
|
728
1046
|
const root = await lockedFixture()
|
|
729
1047
|
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,118 @@ 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
|
+
return installThemeCss(slug, source, digest(source), address, project, options)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function installThemeCss(
|
|
551
|
+
slug: string,
|
|
552
|
+
source: string,
|
|
553
|
+
sourceDigest: string,
|
|
554
|
+
sourceLabel: string,
|
|
555
|
+
project: UiProject,
|
|
556
|
+
options: { dryRun?: boolean; overwrite?: boolean },
|
|
557
|
+
): Promise<Record<string, unknown>> {
|
|
558
|
+
const lock = await readUiLock(project.uiLockPath)
|
|
559
|
+
const canonicalAddress = 'theme/' + slug
|
|
560
|
+
const relativeTarget = 'components/astrale/theme/' + slug + '.css'
|
|
561
|
+
const target = await assertSafePlannedTarget(project, relativeTarget)
|
|
562
|
+
const existing = await readOptional(target)
|
|
563
|
+
const installed = lock.items[canonicalAddress]
|
|
564
|
+
if (existing !== undefined && !installed && !options.overwrite) {
|
|
565
|
+
throw new UiError(
|
|
566
|
+
'UI_LOCAL_CHANGES',
|
|
567
|
+
'Theme target already exists outside the Astrale UI lock: ' + relativeTarget,
|
|
568
|
+
'Review the file, then repeat with explicit --overwrite --yes.',
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
if (installed && !options.overwrite) {
|
|
572
|
+
const expected = installed.files[relativeTarget]
|
|
573
|
+
if (!expected || digest(existing ?? '') !== expected) {
|
|
574
|
+
throw new UiError(
|
|
575
|
+
'UI_LOCAL_CHANGES',
|
|
576
|
+
'Installed UI file has local changes: ' + relativeTarget,
|
|
577
|
+
'Review the file, then repeat add with explicit --overwrite --yes.',
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const statement = themeImport(project, target)
|
|
582
|
+
const result = {
|
|
583
|
+
status: options.dryRun ? 'planned' : 'installed',
|
|
584
|
+
items: [canonicalAddress],
|
|
585
|
+
files: { [canonicalAddress]: { [relativeTarget]: digest(source) } },
|
|
586
|
+
activation: { file: projectRelative(project, project.cssPath), import: statement },
|
|
587
|
+
source: sourceLabel,
|
|
588
|
+
}
|
|
589
|
+
if (options.dryRun) return result
|
|
590
|
+
|
|
591
|
+
const snapshots = new Map<string, string | undefined>()
|
|
592
|
+
for (const mutation of [target, project.cssPath, project.uiLockPath]) {
|
|
593
|
+
snapshots.set(mutation, await readOptional(mutation))
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
597
|
+
await writeFile(target, source, 'utf8')
|
|
598
|
+
const css = (await readOptional(project.cssPath)) ?? ''
|
|
599
|
+
await writeFile(project.cssPath, activateTheme(css, statement), 'utf8')
|
|
600
|
+
lock.items[canonicalAddress] = {
|
|
601
|
+
address: canonicalAddress,
|
|
602
|
+
sourceDigest,
|
|
603
|
+
files: { [relativeTarget]: digest(source) },
|
|
604
|
+
}
|
|
605
|
+
await writeJson(project.uiLockPath, lock)
|
|
606
|
+
return result
|
|
607
|
+
} catch (error) {
|
|
608
|
+
for (const [mutation, previous] of snapshots) {
|
|
609
|
+
if (previous === undefined) await rm(mutation, { force: true })
|
|
610
|
+
else await writeFile(mutation, previous, 'utf8')
|
|
611
|
+
}
|
|
612
|
+
throw error
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function addReleasedTheme(
|
|
617
|
+
item: UiRegistryItem,
|
|
618
|
+
document: UiRegistryItem,
|
|
619
|
+
project: UiProject,
|
|
620
|
+
options: { dryRun?: boolean; overwrite?: boolean },
|
|
621
|
+
): Promise<Record<string, unknown>> {
|
|
622
|
+
const slug = item.meta.canonicalAddress.slice('theme/'.length)
|
|
623
|
+
const source = document.files[0]?.content
|
|
624
|
+
if (typeof source !== 'string') {
|
|
625
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'The released theme has no admitted CSS source.')
|
|
626
|
+
}
|
|
627
|
+
admitLocalThemeCss(source, slug)
|
|
628
|
+
return installThemeCss(
|
|
629
|
+
slug,
|
|
630
|
+
source,
|
|
631
|
+
digest(JSON.stringify(document)),
|
|
632
|
+
item.meta.canonicalAddress,
|
|
633
|
+
project,
|
|
634
|
+
options,
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
430
638
|
export async function addUi(
|
|
431
639
|
addresses: string[],
|
|
432
640
|
options: { project?: string; dryRun?: boolean; overwrite?: boolean; yes?: boolean },
|
|
@@ -436,11 +644,7 @@ export async function addUi(
|
|
|
436
644
|
throw new UiError('UI_ITEM_NOT_FOUND', 'No UI item was provided.')
|
|
437
645
|
}
|
|
438
646
|
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
|
-
)
|
|
647
|
+
await assertSafePlannedTarget(project, UI_LOCK_FILE)
|
|
444
648
|
if (options.overwrite && !options.yes) {
|
|
445
649
|
throw new UiError(
|
|
446
650
|
'UI_LOCAL_CHANGES',
|
|
@@ -448,6 +652,34 @@ export async function addUi(
|
|
|
448
652
|
'Review the locally edited files, then repeat with both --overwrite and --yes.',
|
|
449
653
|
)
|
|
450
654
|
}
|
|
655
|
+
const localThemes = addresses.filter(isLocalThemeAddress)
|
|
656
|
+
if (localThemes.length > 0) {
|
|
657
|
+
if (addresses.length !== 1) {
|
|
658
|
+
throw new UiError(
|
|
659
|
+
'UI_ITEM_CONFLICT',
|
|
660
|
+
'Install one local theme at a time and do not mix local files with registry addresses.',
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
return addLocalTheme(localThemes[0]!, project, options)
|
|
664
|
+
}
|
|
665
|
+
const { lock, release } = await lockedRelease(project, dependencies.fetcher)
|
|
666
|
+
const items = addresses.map((address) => findItem(release, address))
|
|
667
|
+
const themes = items.filter((item) => item.type === 'registry:theme')
|
|
668
|
+
if (themes.length > 1) {
|
|
669
|
+
throw new UiError('UI_ITEM_CONFLICT', 'Only one theme can be activated per add operation.')
|
|
670
|
+
}
|
|
671
|
+
const itemDocuments = await Promise.all(
|
|
672
|
+
items.map((item) => readUiRegistryItem(release, item, dependencies.fetcher)),
|
|
673
|
+
)
|
|
674
|
+
if (themes.length === 1) {
|
|
675
|
+
if (items.length !== 1) {
|
|
676
|
+
throw new UiError(
|
|
677
|
+
'UI_ITEM_CONFLICT',
|
|
678
|
+
'Install a released theme separately from patterns and blocks.',
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
return addReleasedTheme(themes[0]!, itemDocuments[0]!, project, options)
|
|
682
|
+
}
|
|
451
683
|
await rejectLocalChanges(project, lock, items, options.overwrite === true)
|
|
452
684
|
const targets = (
|
|
453
685
|
await Promise.all(
|
|
@@ -469,7 +701,13 @@ export async function addUi(
|
|
|
469
701
|
])
|
|
470
702
|
const snapshots = new Map<string, string | undefined>()
|
|
471
703
|
if (!options.dryRun) {
|
|
472
|
-
for (const target of [
|
|
704
|
+
for (const target of [
|
|
705
|
+
project.packageJsonPath,
|
|
706
|
+
project.lockPath!,
|
|
707
|
+
project.cssPath,
|
|
708
|
+
project.uiLockPath,
|
|
709
|
+
...targets,
|
|
710
|
+
]) {
|
|
473
711
|
snapshots.set(target, await readOptional(target))
|
|
474
712
|
}
|
|
475
713
|
}
|
|
@@ -494,6 +732,13 @@ export async function addUi(
|
|
|
494
732
|
}
|
|
495
733
|
}
|
|
496
734
|
await pinUiDependency(project, lock.package.version, dependencies.runner ?? defaultUiRunner)
|
|
735
|
+
const theme = themes[0]
|
|
736
|
+
const themeTarget = theme?.files[0]?.target
|
|
737
|
+
if (themeTarget) {
|
|
738
|
+
const target = await safeTarget(project, themeTarget)
|
|
739
|
+
const css = (await readOptional(project.cssPath)) ?? ''
|
|
740
|
+
await writeFile(project.cssPath, activateTheme(css, themeImport(project, target)), 'utf8')
|
|
741
|
+
}
|
|
497
742
|
}
|
|
498
743
|
} catch (error) {
|
|
499
744
|
for (const [target, previous] of snapshots) {
|
|
@@ -513,6 +758,15 @@ export async function addUi(
|
|
|
513
758
|
})),
|
|
514
759
|
command: [invocation.file, ...invocation.args],
|
|
515
760
|
output: result.stdout,
|
|
761
|
+
activation: themes[0]
|
|
762
|
+
? {
|
|
763
|
+
file: projectRelative(project, project.cssPath),
|
|
764
|
+
import: themeImport(
|
|
765
|
+
project,
|
|
766
|
+
targets.find((target) => target.endsWith('.css'))!,
|
|
767
|
+
),
|
|
768
|
+
}
|
|
769
|
+
: undefined,
|
|
516
770
|
}
|
|
517
771
|
}
|
|
518
772
|
|
|
@@ -589,6 +843,15 @@ export async function doctorUi(
|
|
|
589
843
|
checks.push({ check: 'item:' + item.address + ':' + file, ok: actual === expected })
|
|
590
844
|
}
|
|
591
845
|
}
|
|
846
|
+
const themeFiles = Object.values(lock.items)
|
|
847
|
+
.filter((item) => item.address.startsWith('theme/'))
|
|
848
|
+
.flatMap((item) => Object.keys(item.files).filter((file) => file.endsWith('.css')))
|
|
849
|
+
if (themeFiles.length > 0) {
|
|
850
|
+
const active = themeFiles.some((file) =>
|
|
851
|
+
css.includes(themeImport(project, path.join(project.root, file))),
|
|
852
|
+
)
|
|
853
|
+
checks.push({ check: 'theme-active', ok: active })
|
|
854
|
+
}
|
|
592
855
|
}
|
|
593
856
|
return { healthy: checks.every((check) => check.ok), checks }
|
|
594
857
|
}
|
|
@@ -601,6 +864,7 @@ export async function applyPreset(
|
|
|
601
864
|
throw new UiError('UI_ITEM_NOT_FOUND', 'Unknown preset: ' + preset)
|
|
602
865
|
}
|
|
603
866
|
const project = await discoverUiProject(options.project)
|
|
867
|
+
await assertSafePlannedTarget(project, UI_LOCK_FILE)
|
|
604
868
|
const lock = await readUiLock(project.uiLockPath)
|
|
605
869
|
const css = (await readOptional(project.cssPath)) ?? ''
|
|
606
870
|
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 {
|