jixoai-ui 0.1.2 → 0.2.0
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/README.md +41 -0
- package/bin/jixoai-ui.mjs +323 -4
- package/bin/upgrade-tasks.mjs +69 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -16,9 +16,50 @@ The official jixoai design-language CLI. It **shares shadcn's
|
|
|
16
16
|
```bash
|
|
17
17
|
npx jixoai-ui init --hue 160 # namespace + config + theme + hue, one shot
|
|
18
18
|
npx jixoai-ui add toc # = shadcn add @jixoai/toc, hue re-applied
|
|
19
|
+
npx jixoai-ui upgrade # refresh locked items + run upgrade tasks
|
|
19
20
|
npx jixoai-ui hue 165 # retheme by changing one number
|
|
20
21
|
npx jixoai-ui config # print the resolved jixoai config
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
Requires `components.json` (run `npx shadcn init` first in fresh projects —
|
|
24
25
|
this CLI extends shadcn's config, it never replaces it).
|
|
26
|
+
|
|
27
|
+
## upgrade
|
|
28
|
+
|
|
29
|
+
`npx jixoai-ui upgrade` pulls the latest version of every installed
|
|
30
|
+
component and runs the idempotent upgrade tasks. Running it again changes
|
|
31
|
+
nothing — a converged second run performs zero writes, so it is safe in CI
|
|
32
|
+
and in any shell loop.
|
|
33
|
+
|
|
34
|
+
- **Lock**: `init`/`add` record every installed item in `jixoai-ui.lock`
|
|
35
|
+
(next to `components.json`) as
|
|
36
|
+
`{ items: { [name]: { files: { [path]: sha256 } } } }`. Paths are resolved
|
|
37
|
+
through `components.json` aliases; hashes cover canonical registry content
|
|
38
|
+
(pre-hue, pre-task). A missing or empty lock fails with exit code 1 and
|
|
39
|
+
tells you to `add` first.
|
|
40
|
+
- **Refresh**: every locked item is fetched from `registries["@jixoai"]`
|
|
41
|
+
with `{name}` replaced (`file://` URLs work for local registries). A file
|
|
42
|
+
is written only when its registry sha256 differs from the locked one;
|
|
43
|
+
identical content is skipped and counted as unchanged. Network, HTTP, and
|
|
44
|
+
JSON failures abort with an explicit error and exit code 1.
|
|
45
|
+
- **Hue**: after the writes the brand hue is re-applied to `jixoai.css`.
|
|
46
|
+
- **Tasks**: `bin/upgrade-tasks.mjs` exports the task array
|
|
47
|
+
`[{ name, item?, applies(content, ctx), run(ctx) }]`. A task fires only
|
|
48
|
+
while its legacy pattern still exists (`applies`), so re-running always
|
|
49
|
+
converges:
|
|
50
|
+
- `legacy-import-paths` — `@lib/toc-engine` → `$lib/toc-engine` and
|
|
51
|
+
`../lib/toc.css` → `$lib/toc.css` (only where the old specifier exists).
|
|
52
|
+
- `spine-axis` (item `toc`) — `left: 2px` → `left: 0px` in the toc css.
|
|
53
|
+
- `scroll-margin-cleanup` — diagnostic only: warns when the app-level css
|
|
54
|
+
declares both `scroll-margin-top` and `scroll-padding-top` (the offsets
|
|
55
|
+
stack; which one owns the offset is an app decision jixoai-ui never
|
|
56
|
+
makes for you). It never edits files, so it re-reports on every run
|
|
57
|
+
until the redundancy is removed.
|
|
58
|
+
- **Summary**: `updated N / unchanged M / tasks ran X, skipped Y`, then the
|
|
59
|
+
lock hashes are updated.
|
|
60
|
+
|
|
61
|
+
CI usage — always upgrade, build, and test against the freshest components:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx jixoai-ui upgrade && npm run build && npm test
|
|
65
|
+
```
|
package/bin/jixoai-ui.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* jixoai-ui — the official jixoai design-language CLI (bin/jixoai-ui.mjs).
|
|
4
4
|
*
|
|
5
5
|
* Orthogonal intents (2026-08-20): components.json extension management;
|
|
6
|
-
* brand-hue application; registry add delegation.
|
|
6
|
+
* brand-hue application; registry add delegation; locked idempotent upgrades.
|
|
7
7
|
*
|
|
8
8
|
* Config contract (Owner decision, 2026-08-20): the CLI SHARES shadcn's
|
|
9
9
|
* components.json and extends it with a non-conflicting `jixoai` object:
|
|
@@ -16,18 +16,34 @@
|
|
|
16
16
|
*
|
|
17
17
|
* The registry URL points at the official Pages-hosted registry; hue lives
|
|
18
18
|
* in ONE config field and is written into the installed jixoai.css
|
|
19
|
-
* (--brand-hue) on every init/add/hue run.
|
|
19
|
+
* (--brand-hue) on every init/add/hue/upgrade run.
|
|
20
|
+
*
|
|
21
|
+
* Install manifest (Owner decision, 2026-08-20): successful init/add runs
|
|
22
|
+
* record every installed item in `jixoai-ui.lock` (next to components.json):
|
|
23
|
+
*
|
|
24
|
+
* { "items": { "toc": { "files": { "src/lib/ui/toc.svelte": "<sha256>" } } } }
|
|
25
|
+
*
|
|
26
|
+
* Keys are install paths resolved through components.json aliases; hashes
|
|
27
|
+
* cover CANONICAL registry content (pre-hue, pre-task). `upgrade` fetches
|
|
28
|
+
* each locked item from registries["@jixoai"], writes only files whose
|
|
29
|
+
* registry sha256 differs from the locked one, re-applies hue, then runs
|
|
30
|
+
* the idempotent upgrade tasks (bin/upgrade-tasks.mjs) — a converged
|
|
31
|
+
* second run performs zero writes.
|
|
20
32
|
*/
|
|
21
33
|
|
|
22
34
|
import { spawnSync } from "node:child_process";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
37
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
25
38
|
import process from "node:process";
|
|
26
39
|
|
|
40
|
+
import { upgradeTasks } from "./upgrade-tasks.mjs";
|
|
41
|
+
|
|
27
42
|
const REGISTRY_URL = "https://ui.jixoai.com/r/{name}.json";
|
|
28
43
|
const NAMESPACE = "@jixoai";
|
|
29
44
|
const THEME_ITEM = "jixoai-theme";
|
|
30
45
|
const DEFAULT_HUE = 0;
|
|
46
|
+
const LOCK_NAME = "jixoai-ui.lock";
|
|
31
47
|
|
|
32
48
|
const USAGE = `jixoai-ui — the jixoai design language CLI
|
|
33
49
|
|
|
@@ -36,9 +52,15 @@ Commands:
|
|
|
36
52
|
jixoai config block, install the theme,
|
|
37
53
|
and apply the brand hue
|
|
38
54
|
jixoai-ui hue <degrees> set the project brand hue (config + css)
|
|
55
|
+
jixoai-ui adopt <item...> baseline hand-installed items into the
|
|
56
|
+
lock (first upgrade then syncs to canon)
|
|
39
57
|
jixoai-ui add <item...> install registry items (delegates to
|
|
40
58
|
\`shadcn add ${NAMESPACE}/<item>\`), then
|
|
41
59
|
re-applies the brand hue
|
|
60
|
+
jixoai-ui upgrade refresh every locked item to the latest
|
|
61
|
+
registry content, re-apply the brand
|
|
62
|
+
hue, and run the idempotent upgrade
|
|
63
|
+
tasks (repeat runs perform zero writes)
|
|
42
64
|
jixoai-ui config print the resolved jixoai config
|
|
43
65
|
|
|
44
66
|
The CLI extends shadcn's components.json — run \`npx shadcn init\` first in
|
|
@@ -128,6 +150,257 @@ function hueFromArgs(args, fallback = DEFAULT_HUE) {
|
|
|
128
150
|
return Math.round(value);
|
|
129
151
|
}
|
|
130
152
|
|
|
153
|
+
/* ── install manifest (jixoai-ui.lock) + shared install/upgrade core ── */
|
|
154
|
+
|
|
155
|
+
function sha256(text) {
|
|
156
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toPosix(path) {
|
|
160
|
+
return path.split("\\").join("/");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function registryUrlFor(config) {
|
|
164
|
+
const url = config.registries?.[NAMESPACE] ?? REGISTRY_URL;
|
|
165
|
+
if (!url.includes("{name}")) {
|
|
166
|
+
fail(`the ${NAMESPACE} registry url must contain a {name} template (got \`${url}\`)`);
|
|
167
|
+
}
|
|
168
|
+
return url;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function fetchText(url) {
|
|
172
|
+
// file:// registries are read from disk (local/offline development);
|
|
173
|
+
// everything else goes through global fetch.
|
|
174
|
+
if (url.startsWith("file:")) {
|
|
175
|
+
return readFileSync(new URL(url), "utf8");
|
|
176
|
+
}
|
|
177
|
+
const response = await fetch(url);
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
throw new Error(`HTTP ${response.status}`);
|
|
180
|
+
}
|
|
181
|
+
return response.text();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function fetchRegistryItem(registryUrl, name) {
|
|
185
|
+
const url = registryUrl.replace("{name}", name);
|
|
186
|
+
let raw;
|
|
187
|
+
try {
|
|
188
|
+
raw = await fetchText(url);
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
throw new Error(`cannot fetch registry item \`${name}\` from ${url}: ${cause.message}`);
|
|
191
|
+
}
|
|
192
|
+
let json;
|
|
193
|
+
try {
|
|
194
|
+
json = JSON.parse(raw);
|
|
195
|
+
} catch {
|
|
196
|
+
throw new Error(`registry item \`${name}\` (${url}) is not valid JSON`);
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(json.files)) {
|
|
199
|
+
throw new Error(`registry item \`${name}\` (${url}) has no files array`);
|
|
200
|
+
}
|
|
201
|
+
return json;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function assertRegistryFiles(name, files) {
|
|
205
|
+
for (const file of files) {
|
|
206
|
+
if (typeof file.target !== "string" || typeof file.content !== "string") {
|
|
207
|
+
throw new Error(`registry item \`${name}\` has a file without string target/content`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveInstallPath(target, config, cwd) {
|
|
213
|
+
// registry targets are alias-relative ("@ui/toc.svelte" → aliases.ui) or
|
|
214
|
+
// plain project-relative paths; mirrors how shadcn places registry files.
|
|
215
|
+
const match = /^@([\w.$-]+)(?:\/(.+))?$/.exec(target);
|
|
216
|
+
if (match) {
|
|
217
|
+
const base = config.aliases?.[match[1]];
|
|
218
|
+
if (typeof base !== "string") {
|
|
219
|
+
throw new Error(`cannot place \`${target}\`: components.json has no aliases.${match[1]}`);
|
|
220
|
+
}
|
|
221
|
+
return resolve(cwd, join(base, match[2] ?? ""));
|
|
222
|
+
}
|
|
223
|
+
return resolve(cwd, target);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function lockInstallKey(target, config, cwd) {
|
|
227
|
+
return toPosix(relative(cwd, resolveInstallPath(target, config, cwd)));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readLock(cwd, { required }) {
|
|
231
|
+
const path = join(cwd, LOCK_NAME);
|
|
232
|
+
if (!existsSync(path)) {
|
|
233
|
+
if (required) {
|
|
234
|
+
fail(
|
|
235
|
+
`${LOCK_NAME} not found — nothing to upgrade. Install components first: \`npx jixoai-ui add <item>\``,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return { path, lock: { items: {} }, existed: false };
|
|
239
|
+
}
|
|
240
|
+
let lock;
|
|
241
|
+
try {
|
|
242
|
+
lock = JSON.parse(readFileSync(path, "utf8"));
|
|
243
|
+
} catch (cause) {
|
|
244
|
+
fail(`${LOCK_NAME} is not valid JSON: ${cause.message}`);
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
typeof lock !== "object" || lock === null ||
|
|
248
|
+
typeof lock.items !== "object" || lock.items === null
|
|
249
|
+
) {
|
|
250
|
+
fail(
|
|
251
|
+
`${LOCK_NAME} must contain an \`items\` map ({ items: { [name]: { files: { [path]: sha256 } } } })`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return { path, lock, existed: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function writeLock(path, lock) {
|
|
258
|
+
writeFileSync(path, JSON.stringify(lock, null, 2) + "\n");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Record freshly installed items in the lock. Shared by add/init; upgrade
|
|
263
|
+
* reuses the same fetch/hash/place helpers. The install itself already
|
|
264
|
+
* succeeded, so a recording failure warns instead of failing the command.
|
|
265
|
+
*/
|
|
266
|
+
async function recordInstalledItems(cwd, config, names) {
|
|
267
|
+
const registryUrl = registryUrlFor(config);
|
|
268
|
+
const { path, lock, existed } = readLock(cwd, { required: false });
|
|
269
|
+
let recorded = 0;
|
|
270
|
+
for (const name of names) {
|
|
271
|
+
try {
|
|
272
|
+
const item = await fetchRegistryItem(registryUrl, name);
|
|
273
|
+
assertRegistryFiles(name, item.files);
|
|
274
|
+
const files = {};
|
|
275
|
+
for (const file of item.files) {
|
|
276
|
+
files[lockInstallKey(file.target, config, cwd)] = sha256(file.content);
|
|
277
|
+
}
|
|
278
|
+
lock.items[name] = { ...(lock.items[name] ?? {}), files };
|
|
279
|
+
recorded++;
|
|
280
|
+
console.log(`jixoai-ui: locked ${name} (${Object.keys(files).length} file(s)) → ${path}`);
|
|
281
|
+
} catch (cause) {
|
|
282
|
+
console.warn(
|
|
283
|
+
`jixoai-ui: ${cause.message} — ${name} not recorded in ${LOCK_NAME}; \`upgrade\` will skip it`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (recorded > 0 || existed) {
|
|
288
|
+
writeLock(path, lock);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* ── upgrade ── */
|
|
293
|
+
|
|
294
|
+
function appCssCandidates(cwd, config, lock) {
|
|
295
|
+
// site-scope files upgrade tasks may inspect: the app-level css pointed
|
|
296
|
+
// at by components.json (tailwind.css) plus conventional locations,
|
|
297
|
+
// minus anything already covered by the lock.
|
|
298
|
+
const locked = new Set();
|
|
299
|
+
for (const item of Object.values(lock.items)) {
|
|
300
|
+
for (const key of Object.keys(item.files ?? {})) locked.add(key);
|
|
301
|
+
}
|
|
302
|
+
const candidates = new Set();
|
|
303
|
+
const tailwindCss = config.tailwind?.css;
|
|
304
|
+
if (typeof tailwindCss === "string" && tailwindCss) candidates.add(toPosix(tailwindCss));
|
|
305
|
+
for (const fallback of ["src/app.css", "app.css", "src/app.postcss", "src/styles/globals.css"]) {
|
|
306
|
+
candidates.add(fallback);
|
|
307
|
+
}
|
|
308
|
+
return [...candidates].filter((key) => !locked.has(key) && existsSync(resolve(cwd, key)));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function taskContext(cwd, item, key) {
|
|
312
|
+
const filePath = resolve(cwd, key);
|
|
313
|
+
return {
|
|
314
|
+
cwd,
|
|
315
|
+
item, // registry item name, or null for site-scope entries
|
|
316
|
+
path: key,
|
|
317
|
+
filePath,
|
|
318
|
+
get content() {
|
|
319
|
+
return readFileSync(filePath, "utf8");
|
|
320
|
+
},
|
|
321
|
+
read: () => readFileSync(filePath, "utf8"),
|
|
322
|
+
write: (next) => writeFileSync(filePath, next),
|
|
323
|
+
log: (message) => console.log(`jixoai-ui: ${message}`),
|
|
324
|
+
warn: (message) => console.warn(`jixoai-ui: ${message}`),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function runUpgradeTasks(cwd, config, lock) {
|
|
329
|
+
const contexts = [];
|
|
330
|
+
for (const [name, item] of Object.entries(lock.items)) {
|
|
331
|
+
for (const key of Object.keys(item.files ?? {})) {
|
|
332
|
+
if (existsSync(resolve(cwd, key))) contexts.push(taskContext(cwd, name, key));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const key of appCssCandidates(cwd, config, lock)) {
|
|
336
|
+
contexts.push(taskContext(cwd, null, key));
|
|
337
|
+
}
|
|
338
|
+
let ran = 0;
|
|
339
|
+
let skipped = 0;
|
|
340
|
+
for (const task of upgradeTasks) {
|
|
341
|
+
let taskRan = false;
|
|
342
|
+
for (const ctx of contexts) {
|
|
343
|
+
if (task.item && task.item !== ctx.item) continue;
|
|
344
|
+
if (!task.applies(ctx.read(), ctx)) continue;
|
|
345
|
+
task.run(ctx);
|
|
346
|
+
taskRan = true;
|
|
347
|
+
}
|
|
348
|
+
if (taskRan) ran++;
|
|
349
|
+
else skipped++;
|
|
350
|
+
}
|
|
351
|
+
return { ran, skipped };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function runUpgrade(cwd, config) {
|
|
355
|
+
const registryUrl = registryUrlFor(config);
|
|
356
|
+
const { path: lockPath, lock } = readLock(cwd, { required: true });
|
|
357
|
+
const names = Object.keys(lock.items);
|
|
358
|
+
if (names.length === 0) {
|
|
359
|
+
fail(`${LOCK_NAME} records no items — install one first: \`npx jixoai-ui add <item>\``);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let updated = 0;
|
|
363
|
+
let unchanged = 0;
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
let item;
|
|
366
|
+
try {
|
|
367
|
+
item = await fetchRegistryItem(registryUrl, name);
|
|
368
|
+
} catch (cause) {
|
|
369
|
+
fail(`upgrade aborted while fetching \`${name}\`: ${cause.message}`);
|
|
370
|
+
}
|
|
371
|
+
assertRegistryFiles(name, item.files);
|
|
372
|
+
const previous = lock.items[name]?.files ?? {};
|
|
373
|
+
const files = {};
|
|
374
|
+
for (const file of item.files) {
|
|
375
|
+
const key = lockInstallKey(file.target, config, cwd);
|
|
376
|
+
const hash = sha256(file.content);
|
|
377
|
+
files[key] = hash;
|
|
378
|
+
if (previous[key] === hash && existsSync(resolve(cwd, key))) {
|
|
379
|
+
unchanged++; // registry content identical to the locked install
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const filePath = resolve(cwd, key);
|
|
383
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
384
|
+
writeFileSync(filePath, file.content);
|
|
385
|
+
updated++;
|
|
386
|
+
console.log(`jixoai-ui: updated ${name} → ${key}`);
|
|
387
|
+
}
|
|
388
|
+
lock.items[name] = { files };
|
|
389
|
+
writeLock(lockPath, lock); // persist per item so an abort keeps progress
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
applyHue(themeCssPath(config, cwd), config.jixoai?.brandHue ?? DEFAULT_HUE);
|
|
393
|
+
|
|
394
|
+
const tasks = runUpgradeTasks(cwd, config, lock);
|
|
395
|
+
|
|
396
|
+
writeLock(lockPath, lock);
|
|
397
|
+
console.log(
|
|
398
|
+
`jixoai-ui: upgrade complete — updated ${updated}, unchanged ${unchanged}, ` +
|
|
399
|
+
`tasks ran ${tasks.ran}, skipped ${tasks.skipped}`,
|
|
400
|
+
);
|
|
401
|
+
console.log(`jixoai-ui: ${LOCK_NAME} updated → ${lockPath}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
131
404
|
const [command, ...rest] = process.argv.slice(2);
|
|
132
405
|
const cwd = process.cwd();
|
|
133
406
|
|
|
@@ -141,6 +414,7 @@ switch (command) {
|
|
|
141
414
|
console.log(`jixoai-ui: ${NAMESPACE} namespace + jixoai config written → ${path}`);
|
|
142
415
|
shadcn(["add", `${NAMESPACE}/${THEME_ITEM}`], cwd, path, config);
|
|
143
416
|
applyHue(themeCssPath(config, cwd), hue);
|
|
417
|
+
await recordInstalledItems(cwd, readConfig(cwd).config, [THEME_ITEM]);
|
|
144
418
|
break;
|
|
145
419
|
}
|
|
146
420
|
case "hue": {
|
|
@@ -159,6 +433,51 @@ switch (command) {
|
|
|
159
433
|
shadcn(["add", `${NAMESPACE}/${item}`], cwd, path, readConfig(cwd).config);
|
|
160
434
|
}
|
|
161
435
|
applyHue(themeCssPath(config, cwd), hue);
|
|
436
|
+
await recordInstalledItems(cwd, readConfig(cwd).config, rest);
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
case "adopt": {
|
|
440
|
+
// Hand-installed sites (same-source copies) have no lock yet. adopt
|
|
441
|
+
// baselines the CURRENT disk content of the named items into the lock:
|
|
442
|
+
// the first `upgrade` afterwards diffs registry canon against this
|
|
443
|
+
// baseline, applies changes + hue, and the lock flips to canonical
|
|
444
|
+
// hashes — subsequent upgrades are fully idempotent.
|
|
445
|
+
const names = rest.filter((a) => !a.startsWith("--"));
|
|
446
|
+
if (names.length === 0) {
|
|
447
|
+
fail("adopt needs item names (e.g. `adopt toc jixoai-theme`) — items whose files live at their components.json targets");
|
|
448
|
+
}
|
|
449
|
+
const { config } = readConfig(cwd);
|
|
450
|
+
const { path, lock } = readLock(cwd, { required: false });
|
|
451
|
+
let recorded = 0;
|
|
452
|
+
for (const name of names) {
|
|
453
|
+
const item = await fetchRegistryItem(registryUrlFor(config), name);
|
|
454
|
+
assertRegistryFiles(name, item.files);
|
|
455
|
+
const files = {};
|
|
456
|
+
const missing = [];
|
|
457
|
+
for (const file of item.files) {
|
|
458
|
+
const abs = resolveInstallPath(file.target, config, cwd);
|
|
459
|
+
if (!existsSync(abs)) { missing.push(file.target); continue; }
|
|
460
|
+
files[lockInstallKey(file.target, config, cwd)] = sha256(readFileSync(abs, "utf8"));
|
|
461
|
+
}
|
|
462
|
+
if (missing.length === item.files.length) {
|
|
463
|
+
console.warn(`jixoai-ui: no files of ${name} found at their targets — skipped`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (missing.length > 0) {
|
|
467
|
+
console.warn(`jixoai-ui: ${name} partially present (missing ${missing.join(", ")}) — locked the rest; partial files will restore on next upgrade`);
|
|
468
|
+
}
|
|
469
|
+
lock.items[name] = { files };
|
|
470
|
+
recorded++;
|
|
471
|
+
console.log(`jixoai-ui: adopted ${name} (${Object.keys(files).length} file(s)) → ${path}`);
|
|
472
|
+
}
|
|
473
|
+
if (recorded === 0) fail("nothing adopted");
|
|
474
|
+
writeLock(path, lock);
|
|
475
|
+
console.log("jixoai-ui: baseline recorded — run `jixoai-ui upgrade` to sync to registry canon");
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case "upgrade": {
|
|
479
|
+
const { config } = readConfig(cwd);
|
|
480
|
+
await runUpgrade(cwd, config);
|
|
162
481
|
break;
|
|
163
482
|
}
|
|
164
483
|
case "config": {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jixoai-ui upgrade tasks (bin/upgrade-tasks.mjs).
|
|
3
|
+
*
|
|
4
|
+
* Contract (Owner decision, 2026-08-20): every task is IDEMPOTENT —
|
|
5
|
+
* `applies(content, ctx)` returns true only while the legacy pattern still
|
|
6
|
+
* exists, and `run(ctx)` rewrites exactly that pattern. Re-running
|
|
7
|
+
* `jixoai-ui upgrade` therefore converges to zero writes.
|
|
8
|
+
*
|
|
9
|
+
* Task shape: { name, item?, applies(content, ctx) → boolean, run(ctx) }.
|
|
10
|
+
* `item` (optional) scopes the task to one registry item; without it the
|
|
11
|
+
* task sees every locked file plus the site-scope candidates (app-level
|
|
12
|
+
* css). `ctx` exposes { cwd, item, path, filePath, content (getter),
|
|
13
|
+
* read(), write(next), log(msg), warn(msg) }.
|
|
14
|
+
*/
|
|
15
|
+
export const upgradeTasks = [
|
|
16
|
+
{
|
|
17
|
+
name: "legacy-import-paths",
|
|
18
|
+
// pre-0.2.0 toc installs imported the engine/css via bare @lib or
|
|
19
|
+
// relative specifiers; the components now use SvelteKit $lib aliases.
|
|
20
|
+
applies(content) {
|
|
21
|
+
return content.includes("@lib/toc-engine") || content.includes("../lib/toc.css");
|
|
22
|
+
},
|
|
23
|
+
run(ctx) {
|
|
24
|
+
const next = ctx
|
|
25
|
+
.read()
|
|
26
|
+
.replaceAll("@lib/toc-engine", "$lib/toc-engine")
|
|
27
|
+
.replaceAll("../lib/toc.css", "$lib/toc.css");
|
|
28
|
+
ctx.write(next);
|
|
29
|
+
ctx.log(`task legacy-import-paths: rewrote toc imports to $lib aliases → ${ctx.path}`);
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "spine-axis",
|
|
34
|
+
item: "toc",
|
|
35
|
+
// the desktop Rule Tracker spine sits ON the node axis (left: 0), not
|
|
36
|
+
// 2px off it. Anchored to declaration position (^ + indentation only)
|
|
37
|
+
// so prose comments ("spine sits left: 2px …") and other declarations
|
|
38
|
+
// (border-left: 2px) are never touched.
|
|
39
|
+
applies(content, ctx) {
|
|
40
|
+
return ctx.path.endsWith(".css") && /^[ \t]*left:[ \t]*2px/m.test(content);
|
|
41
|
+
},
|
|
42
|
+
run(ctx) {
|
|
43
|
+
const next = ctx.read().replace(
|
|
44
|
+
/^([ \t]*)left:[ \t]*2px/gm,
|
|
45
|
+
(_match, indent) => `${indent}left: 0px`,
|
|
46
|
+
);
|
|
47
|
+
ctx.write(next);
|
|
48
|
+
ctx.log(`task spine-axis: moved the spine onto the node axis (left: 2px → 0px) → ${ctx.path}`);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "scroll-margin-cleanup",
|
|
53
|
+
// DIAGNOSTIC ONLY — never edits files. scroll-margin-top on the
|
|
54
|
+
// anchors and scroll-padding-top on the scroll container STACK (see
|
|
55
|
+
// the "named line law" in toc.css), so a site carrying both doubles
|
|
56
|
+
// its anchor landings past the toc line. Which of the two owns the
|
|
57
|
+
// offset is an app-level layout decision jixoai-ui must not make for
|
|
58
|
+
// you, so this task only warns — and because it fixes nothing, it
|
|
59
|
+
// re-reports on every run until the owner removes the redundancy.
|
|
60
|
+
applies(content) {
|
|
61
|
+
return content.includes("scroll-margin-top") && content.includes("scroll-padding-top");
|
|
62
|
+
},
|
|
63
|
+
run(ctx) {
|
|
64
|
+
ctx.warn(
|
|
65
|
+
`${ctx.path} declares both scroll-margin-top and scroll-padding-top — the offsets stack and anchor jumps land past the toc line. Keep scroll-padding-top on the scroll container (the jixoai toc consumes --jx-toc-line that way) and remove the target-side scroll-margin-top. jixoai-ui never edits app-level css automatically.`,
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jixoai-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Official jixoai design-language CLI: initializes the @jixoai shadcn registry namespace, manages the jixoai extension fields in components.json,
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Official jixoai design-language CLI: initializes the @jixoai shadcn registry namespace, manages the jixoai extension fields in components.json, applies the per-project brand hue, and performs locked idempotent upgrades.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|