basalt-ui 1.22.0 → 1.23.1
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/AGENTS.md +29 -12
- package/CHANGELOG.md +28 -0
- package/MIGRATING.md +207 -12
- package/README.md +74 -21
- package/agent/rules/basalt-app.md +33 -3
- package/agent/rules/basalt-charts.md +78 -7
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-tokens.md +35 -6
- package/agent/skills/basalt-app/SKILL.md +25 -12
- package/agent/skills/basalt-charts/SKILL.md +27 -10
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/agent/templates/DESIGN.md.tpl +3 -1
- package/configs/oxlint-plugin.js +49 -8
- package/configs/oxlint-plugin.test.ts +88 -31
- package/dist/charts/hooks/useBandPlot.d.ts +138 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
- package/dist/charts/hooks/useBandPlot.js +127 -0
- package/dist/charts/index.d.ts +4 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +14 -0
- package/dist/charts/kinds/BandStrip.d.ts +127 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +271 -0
- package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
- package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
- package/dist/charts/kinds/Bars.d.ts +5 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +2 -0
- package/dist/charts/kinds/MirroredBars.d.ts +95 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +328 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +5 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -0
- package/dist/charts/kinds/StackedArea.d.ts +5 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts +5 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -0
- package/dist/charts/primitives/CartesianChart.d.ts +18 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/HatchPattern.d.ts +31 -0
- package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
- package/dist/charts/primitives/HatchPattern.js +31 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +228 -31
- package/dist/cli/shipped-versions.test.d.ts +2 -0
- package/dist/cli/shipped-versions.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +60 -13
- package/dist/vite.d.ts +42 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +67 -30
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/hooks/useBandPlot.ts +302 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/kinds/BandStrip.test.tsx +282 -0
- package/src/charts/kinds/BandStrip.tsx +457 -0
- package/src/charts/kinds/Bars.tsx +7 -1
- package/src/charts/kinds/DualPanel.test.tsx +10 -4
- package/src/charts/kinds/MirroredBars.test.tsx +232 -0
- package/src/charts/kinds/MirroredBars.tsx +491 -0
- package/src/charts/kinds/MultiLine.tsx +7 -1
- package/src/charts/kinds/StackedArea.tsx +7 -1
- package/src/charts/kinds/ZonedLine.tsx +7 -1
- package/src/charts/primitives/CartesianChart.test.tsx +86 -4
- package/src/charts/primitives/CartesianChart.tsx +28 -3
- package/src/charts/primitives/HatchPattern.tsx +55 -0
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +436 -52
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/tokens-css.test.ts +62 -0
- package/src/cli/toolchain-wiring.test.ts +171 -4
- package/src/cli/waiver-audit.test.ts +7 -3
- package/src/guard/check-source.test.ts +211 -42
- package/src/guard/index.ts +149 -23
- package/src/vite.test.ts +79 -0
- package/src/vite.ts +141 -39
package/src/cli/index.ts
CHANGED
|
@@ -511,6 +511,34 @@ type ProjectResolution = {
|
|
|
511
511
|
ambiguous: string[] | null
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Basalt projects in `cwd`'s immediate subtree — the fallback for a repo that declares no
|
|
516
|
+
* `workspaces` field at all.
|
|
517
|
+
*
|
|
518
|
+
* {@link collectWorkspacePackages} is the primary source and it is exact, but it answers `[]` for
|
|
519
|
+
* every repo whose root `package.json` carries no `workspaces` key. linewatch is that shape: the
|
|
520
|
+
* entire basalt consumer lives in `web/`, with no workspace declaration anywhere. Measured at its
|
|
521
|
+
* repo root, all three project-scoped commands got that wrong in a different way — `check-theme`
|
|
522
|
+
* printed `✓ no off-palette colors` having scanned ZERO files, `doctor` inferred `tokens-only` for
|
|
523
|
+
* a full Mantine consumer, and `sync` refused while naming `basalt-ui init` as the remedy, which is
|
|
524
|
+
* the one command that WOULD have written a competing install at the root. A gate that reports
|
|
525
|
+
* green having looked at nothing is the failure this resolver exists to prevent, so the search
|
|
526
|
+
* falls back to the layout when there is no declaration to read.
|
|
527
|
+
*
|
|
528
|
+
* Bounded to two levels (`web/`, `apps/admin/`), dot-directories and `node_modules` skipped, and it
|
|
529
|
+
* never descends INTO a project it found — a consumer's own nested package is not a rival candidate.
|
|
530
|
+
*/
|
|
531
|
+
function descendantProjects(cwd: string, depth = 2): string[] {
|
|
532
|
+
if (depth === 0) return []
|
|
533
|
+
const out: string[] = []
|
|
534
|
+
for (const name of subdirNames(cwd, { skipDotDirs: true })) {
|
|
535
|
+
const dir = resolve(cwd, name)
|
|
536
|
+
if (hasBasaltProject(dir)) out.push(dir)
|
|
537
|
+
else out.push(...descendantProjects(dir, depth - 1))
|
|
538
|
+
}
|
|
539
|
+
return out
|
|
540
|
+
}
|
|
541
|
+
|
|
514
542
|
function resolveProjectDir(cwd: string): ProjectResolution {
|
|
515
543
|
const override = process.env['BASALT_CWD']
|
|
516
544
|
if (override !== undefined && override.length > 0) {
|
|
@@ -519,9 +547,13 @@ function resolveProjectDir(cwd: string): ProjectResolution {
|
|
|
519
547
|
}
|
|
520
548
|
if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
521
549
|
|
|
522
|
-
const
|
|
550
|
+
const declared = collectWorkspacePackages(cwd)
|
|
523
551
|
.packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir))
|
|
524
552
|
.map((pkg) => pkg.dir)
|
|
553
|
+
// The workspace declaration wins whenever there is one — it is exact, and a repo that declares
|
|
554
|
+
// its packages has already said where they are. The layout scan only answers for the repos that
|
|
555
|
+
// declare nothing; see descendantProjects.
|
|
556
|
+
const candidates = declared.length > 0 ? declared : descendantProjects(cwd)
|
|
525
557
|
if (candidates.length === 1) {
|
|
526
558
|
return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
|
|
527
559
|
}
|
|
@@ -692,7 +724,7 @@ export function checkTheme(
|
|
|
692
724
|
if (project.ambiguous !== null) {
|
|
693
725
|
console.error(
|
|
694
726
|
`✖ basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
695
|
-
`
|
|
727
|
+
`packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
696
728
|
'run it from one of them, or set BASALT_CWD to pick.',
|
|
697
729
|
)
|
|
698
730
|
return 1
|
|
@@ -1176,13 +1208,21 @@ function auditAllows(
|
|
|
1176
1208
|
`\n${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} ` +
|
|
1177
1209
|
'unaccountable (reported as theme-allow-unscoped by a normal run).',
|
|
1178
1210
|
)
|
|
1179
|
-
// The audit reads exactly what `check-theme` reads, and that is `
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1211
|
+
// The audit reads exactly what `check-theme` reads, and that is `scannableFiles` — which is
|
|
1212
|
+
// WIDER than `basalt.roots`: it also takes each root's sibling `index.html` and `public/` tree
|
|
1213
|
+
// (see appShellFiles) and anything named in `basalt.include`. Two consumers reported a live
|
|
1214
|
+
// `public/site.webmanifest` waiver under a scope line saying files outside `roots` are not
|
|
1215
|
+
// audited; the line was wrong, not the scan. Saying the scope out loud is the difference between
|
|
1216
|
+
// "0 dead" and "0 dead, over these files" — so it has to name every class actually reached.
|
|
1217
|
+
const includes = cfg.include ?? []
|
|
1182
1218
|
lines.push(
|
|
1183
|
-
`Scope: the ${scanned.length} file(s) check-theme scans under basalt.roots ` +
|
|
1184
|
-
`(${resolveRoots(cfg).join(', ')})
|
|
1185
|
-
|
|
1219
|
+
`Scope: the ${scanned.length} file(s) check-theme scans — everything under basalt.roots ` +
|
|
1220
|
+
`(${resolveRoots(cfg).join(', ')}), plus each root's sibling index.html and public/ tree, ` +
|
|
1221
|
+
(includes.length === 0
|
|
1222
|
+
? 'plus anything named in basalt.include (none). '
|
|
1223
|
+
: `plus basalt.include (${includes.join(', ')}). `) +
|
|
1224
|
+
'A waiver in a file none of those reach is not audited — widen roots, or name it in ' +
|
|
1225
|
+
'basalt.include, or accept that nothing polices that file.',
|
|
1186
1226
|
)
|
|
1187
1227
|
if (dead > 0) {
|
|
1188
1228
|
lines.push(
|
|
@@ -1312,10 +1352,25 @@ function readFrameworkVersion(pkgRoot: string): string {
|
|
|
1312
1352
|
}
|
|
1313
1353
|
}
|
|
1314
1354
|
|
|
1355
|
+
/**
|
|
1356
|
+
* How a seeded script, a seeded CI step, or a piece of doctor advice should invoke the CLI.
|
|
1357
|
+
*
|
|
1358
|
+
* The LOCAL bin wherever basalt actually resolved; `bunx basalt-ui` only as the pre-install
|
|
1359
|
+
* fallback. `bunx` does not re-resolve a package it has cached, so a seeded `bunx basalt-ui` lets a
|
|
1360
|
+
* consumer pin one version and be gated by another — which is exactly what happened across three
|
|
1361
|
+
* consumer repos, in CI, in package.json scripts and in a `.claude` PreToolUse hook, all of them
|
|
1362
|
+
* placed here. Every emitted invocation goes through this function so there is one place for it to
|
|
1363
|
+
* be right.
|
|
1364
|
+
*/
|
|
1365
|
+
function basaltBinCommand(install: BasaltInstall, cwd: string): string {
|
|
1366
|
+
if (install.dir === null) return 'bunx basalt-ui'
|
|
1367
|
+
const binDir = resolve(install.dir, '..', '.bin')
|
|
1368
|
+
return `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1315
1371
|
/** Build the template-variable map from the framework root + consumer cwd/config. */
|
|
1316
1372
|
function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
|
|
1317
1373
|
const install = findBasaltInstall(cwd)
|
|
1318
|
-
const binDir = install.dir === null ? null : resolve(install.dir, '..', '.bin')
|
|
1319
1374
|
return {
|
|
1320
1375
|
APP_NAME: readPackageName(cwd),
|
|
1321
1376
|
BASALT_VERSION: readFrameworkVersion(pkgRoot),
|
|
@@ -1324,10 +1379,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
|
|
|
1324
1379
|
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
|
|
1325
1380
|
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
|
|
1326
1381
|
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
|
|
1327
|
-
BASALT_BIN:
|
|
1328
|
-
binDir === null
|
|
1329
|
-
? 'bunx basalt-ui'
|
|
1330
|
-
: `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`,
|
|
1382
|
+
BASALT_BIN: basaltBinCommand(install, cwd),
|
|
1331
1383
|
}
|
|
1332
1384
|
}
|
|
1333
1385
|
|
|
@@ -1743,6 +1795,34 @@ function migrateLegacyOxfmt(cwd: string, pkgRoot: string, manifest: Manifest): v
|
|
|
1743
1795
|
if (shipped !== null && onDisk === shipped) unlinkSync(legacyAbs)
|
|
1744
1796
|
}
|
|
1745
1797
|
|
|
1798
|
+
/** The frozen `> Managed by basalt-ui (1.9.0).` opener every pre-fix DESIGN.md seed carries. */
|
|
1799
|
+
const DESIGN_VERSION_LINE = /^(> Managed by basalt-ui) \(\d+\.\d+\.\d+\)\./m
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Strip the frozen version out of an already-seeded `DESIGN.md` opener.
|
|
1803
|
+
*
|
|
1804
|
+
* `DESIGN.md` is a `seed`: written once, then consumer-owned and never reconciled. It opened with
|
|
1805
|
+
* `> Managed by basalt-ui ({{BASALT_VERSION}})`, which is accurate for exactly as long as it takes
|
|
1806
|
+
* to run one upgrade — measured across the seven consumers the same line read 1.0.0, 1.9.0, 1.21.0
|
|
1807
|
+
* and 1.22.0 under an identical 1.22.0 install, and three rounds of reports treated that as four
|
|
1808
|
+
* separate doc bugs. It was one: a version number in a file nothing ever rewrites.
|
|
1809
|
+
*
|
|
1810
|
+
* Dropping it from the template fixes new scaffolds only — every existing consumer would carry its
|
|
1811
|
+
* stale number forever, since sync does not touch a seed. So sync deletes the parenthetical in
|
|
1812
|
+
* place, and only that: not a re-seed, not a three-way merge. Everything else in the file is left
|
|
1813
|
+
* exactly as the consumer wrote it, and a DESIGN.md whose opener was already rewritten by hand
|
|
1814
|
+
* does not match at all.
|
|
1815
|
+
*/
|
|
1816
|
+
function migrateDesignVersionLine(cwd: string): void {
|
|
1817
|
+
const dest = resolve(cwd, 'DESIGN.md')
|
|
1818
|
+
const onDisk = readIfExists(dest)
|
|
1819
|
+
if (onDisk === null || !DESIGN_VERSION_LINE.test(onDisk)) return
|
|
1820
|
+
writeFileEnsuringDir(
|
|
1821
|
+
dest,
|
|
1822
|
+
onDisk.replace(DESIGN_VERSION_LINE, '$1 — run `basalt-ui doctor` for the version.'),
|
|
1823
|
+
)
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1746
1826
|
function writeFileEnsuringDir(abs: string, content: string): void {
|
|
1747
1827
|
mkdirSync(dirname(abs), { recursive: true })
|
|
1748
1828
|
writeFileSync(abs, content)
|
|
@@ -2474,7 +2554,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
2474
2554
|
` "PreToolUse": [\n` +
|
|
2475
2555
|
` {\n` +
|
|
2476
2556
|
` "matcher": "Write|Edit|MultiEdit",\n` +
|
|
2477
|
-
` "hooks": [{ "type": "command", "command": "
|
|
2557
|
+
` "hooks": [{ "type": "command", "command": "${ctx.vars.BASALT_BIN} guard-hook" }]\n` +
|
|
2478
2558
|
` }\n` +
|
|
2479
2559
|
` ]\n` +
|
|
2480
2560
|
` }`,
|
|
@@ -2519,7 +2599,12 @@ function readOxlintPresetPlugins(pkgRoot: string): string[] {
|
|
|
2519
2599
|
}
|
|
2520
2600
|
}
|
|
2521
2601
|
|
|
2522
|
-
type SyncOptions = {
|
|
2602
|
+
type SyncOptions = {
|
|
2603
|
+
force?: boolean
|
|
2604
|
+
check?: boolean
|
|
2605
|
+
/** The raw flag list, read for `--tokens-only` / `--framework` exactly as check-theme reads it. */
|
|
2606
|
+
flags?: readonly string[]
|
|
2607
|
+
}
|
|
2523
2608
|
|
|
2524
2609
|
/**
|
|
2525
2610
|
* The nearest ANCESTOR of `dir` (exclusive) carrying a basalt manifest, or null.
|
|
@@ -2530,6 +2615,21 @@ type SyncOptions = { force?: boolean; check?: boolean }
|
|
|
2530
2615
|
* install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
|
|
2531
2616
|
* "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
|
|
2532
2617
|
*/
|
|
2618
|
+
/**
|
|
2619
|
+
* The sentence `sync` prints when the cwd carries no install but an ancestor does.
|
|
2620
|
+
*
|
|
2621
|
+
* Shared with `doctor` deliberately. The two commands already share `resolveProjectDir`, but not
|
|
2622
|
+
* the ADVICE: `sync` named the parent install (1.22.0's "stop scaffolding a second consumer" fix)
|
|
2623
|
+
* while `doctor`, in the same directory, kept prescribing `basalt-ui init` — so following doctor
|
|
2624
|
+
* literally performed the exact mistake that fix exists to prevent. One function, one sentence.
|
|
2625
|
+
*/
|
|
2626
|
+
function parentInstallAdvice(cwd: string, parent: string, verb: string): string {
|
|
2627
|
+
return (
|
|
2628
|
+
`This repo's install is at ${relativePosix(cwd, parent)} (${parent}) — run ${verb} there, ` +
|
|
2629
|
+
'or set BASALT_CWD to it.'
|
|
2630
|
+
)
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2533
2633
|
function findManifestAbove(dir: string): string | null {
|
|
2534
2634
|
let current = resolve(dir, '..')
|
|
2535
2635
|
for (;;) {
|
|
@@ -2554,11 +2654,16 @@ function findManifestAbove(dir: string): string | null {
|
|
|
2554
2654
|
* `sync` does not get to make silently.
|
|
2555
2655
|
*/
|
|
2556
2656
|
export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
|
|
2657
|
+
const syncFlags = opts.flags ?? []
|
|
2658
|
+
if (conflictingProfileFlags(syncFlags)) {
|
|
2659
|
+
console.error('basalt-ui sync: --tokens-only and --framework are alternatives — pass one.')
|
|
2660
|
+
return 1
|
|
2661
|
+
}
|
|
2557
2662
|
const project = resolveProjectDir(invocationCwd)
|
|
2558
2663
|
if (project.ambiguous !== null) {
|
|
2559
2664
|
console.error(
|
|
2560
2665
|
`✖ basalt-ui sync: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
2561
|
-
`
|
|
2666
|
+
`packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
2562
2667
|
'run it from one of them, or set BASALT_CWD to pick.',
|
|
2563
2668
|
)
|
|
2564
2669
|
return 1
|
|
@@ -2571,6 +2676,24 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
2571
2676
|
)
|
|
2572
2677
|
}
|
|
2573
2678
|
|
|
2679
|
+
// Profile before the refusal, because in a tokens-only consumer the refusal's own advice is
|
|
2680
|
+
// wrong: it names `basalt-ui init`, and `doctor` in the same directory at the same version says
|
|
2681
|
+
// in so many words that init is NOT the fix there — it places a Mantine doctrine the consumer
|
|
2682
|
+
// has no use for. Mirrors doctor's manifest row verbatim, and exits 0 so `sync --check` is
|
|
2683
|
+
// wirable into a tokens-only repo's CI (the one drift gate every other consumer runs).
|
|
2684
|
+
//
|
|
2685
|
+
// DECLARED, never inferred — the same asymmetry check-theme applies, for the same reason: the
|
|
2686
|
+
// answer SILENCES the whole reconciliation, and inferring it from a missing @mantine/core would
|
|
2687
|
+
// turn a framework consumer that keeps Mantine in a sibling package into a silent no-op.
|
|
2688
|
+
if (declaredProfile(readBasaltConfig(cwd), syncFlags) === 'tokens-only') {
|
|
2689
|
+
console.log(
|
|
2690
|
+
`basalt-ui sync: ${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to ` +
|
|
2691
|
+
'reconcile. The token layer is refreshed by re-running `basalt-ui tokens:css`, and ' +
|
|
2692
|
+
'`tokens:css --check` is its drift gate. Pass --framework to force the full profile.',
|
|
2693
|
+
)
|
|
2694
|
+
return 0
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2574
2697
|
// The refusal runs BEFORE reconcileRoots, which WRITES `basalt.roots` into package.json — that
|
|
2575
2698
|
// key is half the damage: a second `basalt` block means check-theme scans a different tree
|
|
2576
2699
|
// depending on which directory it was invoked from.
|
|
@@ -2585,8 +2708,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
2585
2708
|
(elsewhere === null
|
|
2586
2709
|
? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
|
|
2587
2710
|
'package really is meant to be a consumer.'
|
|
2588
|
-
: `
|
|
2589
|
-
'sync there, or set BASALT_CWD to it.'),
|
|
2711
|
+
: ` ${parentInstallAdvice(cwd, elsewhere, 'sync')}`),
|
|
2590
2712
|
)
|
|
2591
2713
|
return 1
|
|
2592
2714
|
}
|
|
@@ -2595,7 +2717,10 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
2595
2717
|
const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
|
|
2596
2718
|
const ctx = renderContext(cwd)
|
|
2597
2719
|
const manifest = readManifest(cwd)
|
|
2598
|
-
if (!opts.check)
|
|
2720
|
+
if (!opts.check) {
|
|
2721
|
+
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
2722
|
+
migrateDesignVersionLine(cwd)
|
|
2723
|
+
}
|
|
2599
2724
|
const peers = resolvePeerFlags(cwd, {})
|
|
2600
2725
|
const placement = resolvePlacement(cwd)
|
|
2601
2726
|
const files = managedFiles(peers, placement)
|
|
@@ -3228,8 +3353,8 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3228
3353
|
if (project.ambiguous !== null) {
|
|
3229
3354
|
console.error(
|
|
3230
3355
|
`\nbasalt-ui doctor — ${invocationCwd}\n\n` +
|
|
3231
|
-
` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length}
|
|
3232
|
-
`
|
|
3356
|
+
` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} packages ` +
|
|
3357
|
+
`below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
3233
3358
|
'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
|
|
3234
3359
|
'1 hard failure(s), 0 warning(s).\n',
|
|
3235
3360
|
)
|
|
@@ -3266,6 +3391,10 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3266
3391
|
// ── Profile ────────────────────────────────────────────────────────────────
|
|
3267
3392
|
const manifestAbs = resolve(cwd, MANIFEST_PATH)
|
|
3268
3393
|
const manifestExists = existsSync(manifestAbs)
|
|
3394
|
+
// Non-null ⇒ this directory is a package UNDER a configured repo, not an unscaffolded consumer.
|
|
3395
|
+
// Every remedy below that would otherwise say "run `basalt-ui init`" points at the parent
|
|
3396
|
+
// instead — see {@link parentInstallAdvice}.
|
|
3397
|
+
const parentInstall = manifestExists ? null : findManifestAbove(cwd)
|
|
3269
3398
|
const profile = inferredProfile(cwd, cfg, flags)
|
|
3270
3399
|
if (profile === 'tokens-only') {
|
|
3271
3400
|
lines.push(
|
|
@@ -3289,6 +3418,12 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3289
3418
|
pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
|
|
3290
3419
|
} else if (manifestExists) {
|
|
3291
3420
|
pass(`${MANIFEST_PATH} exists`)
|
|
3421
|
+
} else if (parentInstall !== null) {
|
|
3422
|
+
fail(
|
|
3423
|
+
`${MANIFEST_PATH} missing here, but this is not an unscaffolded consumer. ` +
|
|
3424
|
+
`${parentInstallAdvice(cwd, parentInstall, 'doctor')} \`basalt-ui init\` is NOT the fix: ` +
|
|
3425
|
+
'it would scaffold a SECOND consumer beside the real one.',
|
|
3426
|
+
)
|
|
3292
3427
|
} else {
|
|
3293
3428
|
fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
|
|
3294
3429
|
}
|
|
@@ -3450,7 +3585,11 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3450
3585
|
if (oxlintrcRaw === null) {
|
|
3451
3586
|
fail(
|
|
3452
3587
|
'.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
|
|
3453
|
-
'active.
|
|
3588
|
+
'active. ' +
|
|
3589
|
+
(parentInstall === null
|
|
3590
|
+
? 'Run `basalt-ui init` to seed it.'
|
|
3591
|
+
: `${parentInstallAdvice(cwd, parentInstall, 'doctor')} The preset is seeded once, at ` +
|
|
3592
|
+
'the install — not per package.'),
|
|
3454
3593
|
)
|
|
3455
3594
|
} else {
|
|
3456
3595
|
// JSONC: oxlint accepts comments and real consumer configs carry them.
|
|
@@ -3543,40 +3682,61 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3543
3682
|
`${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
|
|
3544
3683
|
'command runs check-theme — the theme guard is not gating commits. Either add ' +
|
|
3545
3684
|
`"extends: [${lefthookCorrect}]", or add your own command running ` +
|
|
3546
|
-
|
|
3685
|
+
`\`${basaltBinCommand(install, cwd)} check-theme\` (with \`root:\` if it must run in a ` +
|
|
3686
|
+
'subdirectory).',
|
|
3547
3687
|
)
|
|
3548
3688
|
} else {
|
|
3549
3689
|
warn(
|
|
3550
3690
|
`${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
|
|
3551
3691
|
'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
|
|
3552
3692
|
'config it never read — treat it as advisory. If the guard really is unwired, add ' +
|
|
3553
|
-
`"extends: [${lefthookCorrect}]" or your own command running
|
|
3693
|
+
`"extends: [${lefthookCorrect}]" or your own command running ` +
|
|
3694
|
+
`\`${basaltBinCommand(install, cwd)} check-theme\`.`,
|
|
3554
3695
|
)
|
|
3555
3696
|
}
|
|
3556
3697
|
|
|
3557
|
-
// ── Warn check 8:
|
|
3558
|
-
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references
|
|
3559
|
-
//
|
|
3560
|
-
//
|
|
3561
|
-
//
|
|
3698
|
+
// ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
|
|
3699
|
+
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
|
|
3700
|
+
// (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
|
|
3701
|
+
// is read (see {@link readAppIconsOption}) rather than assumed. Only runs when a public/ dir
|
|
3702
|
+
// exists at all: apps that don't use the plugin, or don't use Vite's public-dir convention, get
|
|
3703
|
+
// no false warning.
|
|
3562
3704
|
const publicDir = resolve(cwd, 'public')
|
|
3563
3705
|
if (profile === 'framework' && existsSync(publicDir)) {
|
|
3564
|
-
const
|
|
3565
|
-
|
|
3566
|
-
'
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
'
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
const missingIcons = iconFiles.filter((f) => !existsSync(resolve(publicDir, f)))
|
|
3573
|
-
if (missingIcons.length > 0) {
|
|
3574
|
-
warn(
|
|
3575
|
-
`public/ is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
|
|
3576
|
-
'them (e.g. via realfavicongenerator.net) and place them at the public/ root.',
|
|
3706
|
+
const icons = readAppIconsOption(cwd)
|
|
3707
|
+
if (icons.kind === 'none') {
|
|
3708
|
+
pass('basaltAppPlugin is configured with no icons — nothing to check')
|
|
3709
|
+
} else if (icons.kind === 'explicit') {
|
|
3710
|
+
// The app NAMED its icons, so those are the only files it owes. Checking the six defaults
|
|
3711
|
+
// here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
|
|
3712
|
+
const missing = icons.sources.filter(
|
|
3713
|
+
(src) => !existsSync(resolve(publicDir, src.replace(/^\//, ''))),
|
|
3577
3714
|
)
|
|
3715
|
+
if (missing.length > 0) {
|
|
3716
|
+
warn(
|
|
3717
|
+
`basaltAppPlugin declares icon(s) that are not in public/: ${missing.join(', ')} — the ` +
|
|
3718
|
+
'manifest and the head links will point at a 404. Place them, or drop them from the ' +
|
|
3719
|
+
'`icons` array.',
|
|
3720
|
+
)
|
|
3721
|
+
} else {
|
|
3722
|
+
pass(
|
|
3723
|
+
`public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's \`icons\` option names`,
|
|
3724
|
+
)
|
|
3725
|
+
}
|
|
3578
3726
|
} else {
|
|
3579
|
-
|
|
3727
|
+
const dir = icons.kind === 'default' ? icons.dir : null
|
|
3728
|
+
const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
|
|
3729
|
+
const shown = dir === null ? 'public/' : `public/${dir.replace(/^\//, '')}`
|
|
3730
|
+
const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
|
|
3731
|
+
if (missingIcons.length > 0) {
|
|
3732
|
+
warn(
|
|
3733
|
+
`${shown} is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
|
|
3734
|
+
`them (e.g. via realfavicongenerator.net) and place them at the ${shown} root, or name ` +
|
|
3735
|
+
"the icons this app really has via the plugin's `icons` array.",
|
|
3736
|
+
)
|
|
3737
|
+
} else {
|
|
3738
|
+
pass(`${shown} has all of basaltAppPlugin's default icon files`)
|
|
3739
|
+
}
|
|
3580
3740
|
}
|
|
3581
3741
|
}
|
|
3582
3742
|
|
|
@@ -3783,6 +3943,100 @@ export function info(flags: string[]): number {
|
|
|
3783
3943
|
return 0
|
|
3784
3944
|
}
|
|
3785
3945
|
|
|
3946
|
+
/** The six filenames `basaltAppPlugin` references when `icons` is left at its default. */
|
|
3947
|
+
const DEFAULT_APP_ICON_FILES = [
|
|
3948
|
+
'favicon.ico',
|
|
3949
|
+
'favicon.svg',
|
|
3950
|
+
'favicon-96x96.png',
|
|
3951
|
+
'apple-touch-icon.png',
|
|
3952
|
+
'web-app-manifest-192x192.png',
|
|
3953
|
+
'web-app-manifest-512x512.png',
|
|
3954
|
+
] as const
|
|
3955
|
+
|
|
3956
|
+
const VITE_CONFIG_FILES = [
|
|
3957
|
+
'vite.config.ts',
|
|
3958
|
+
'vite.config.mts',
|
|
3959
|
+
'vite.config.cts',
|
|
3960
|
+
'vite.config.js',
|
|
3961
|
+
'vite.config.mjs',
|
|
3962
|
+
'vite.config.cjs',
|
|
3963
|
+
] as const
|
|
3964
|
+
|
|
3965
|
+
/**
|
|
3966
|
+
* What `basaltAppPlugin`'s `icons` option says this app actually has.
|
|
3967
|
+
*
|
|
3968
|
+
* `unknown` = no vite config, no `basaltAppPlugin(` call, or an `icons` value this bounded read
|
|
3969
|
+
* cannot classify — all three fall back to the six-default check, so the gate can only ever get
|
|
3970
|
+
* NARROWER than it was, never blinder.
|
|
3971
|
+
*/
|
|
3972
|
+
type AppIconsOption =
|
|
3973
|
+
| { kind: 'default'; dir: string | null }
|
|
3974
|
+
| { kind: 'none' }
|
|
3975
|
+
| { kind: 'explicit'; sources: readonly string[] }
|
|
3976
|
+
| { kind: 'unknown' }
|
|
3977
|
+
|
|
3978
|
+
/** The balanced `(`…`)` / `{`…`}` / `[`…`]` span starting at `open`, or null if it never closes. */
|
|
3979
|
+
function balancedSpan(text: string, open: number): string | null {
|
|
3980
|
+
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' }
|
|
3981
|
+
const closer = pairs[text[open] as string]
|
|
3982
|
+
if (closer === undefined) return null
|
|
3983
|
+
let depth = 0
|
|
3984
|
+
for (let i = open; i < text.length; i++) {
|
|
3985
|
+
const ch = text[i] as string
|
|
3986
|
+
if (ch === text[open]) depth++
|
|
3987
|
+
else if (ch === closer) {
|
|
3988
|
+
depth--
|
|
3989
|
+
if (depth === 0) return text.slice(open, i + 1)
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
return null
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
/**
|
|
3996
|
+
* Read the `icons` option out of the consumer's vite config.
|
|
3997
|
+
*
|
|
3998
|
+
* 1.23.0 let `icons` name an app's REAL icon files; doctor's icon check kept demanding the six
|
|
3999
|
+
* default filenames and never read the option, so adopting the release's headline feature produced
|
|
4000
|
+
* a brand-new warning — on the very repo the feature was written for. A warning that fires BECAUSE
|
|
4001
|
+
* you adopted the new API teaches people to ignore doctor.
|
|
4002
|
+
*
|
|
4003
|
+
* doctor does not run the build, so the plugin's resolved answer is not available to it; this is a
|
|
4004
|
+
* bounded textual read of the config file, the same shape the guard uses on source. It is
|
|
4005
|
+
* deliberately conservative: anything it cannot classify returns `unknown` and the old check runs.
|
|
4006
|
+
*/
|
|
4007
|
+
function readAppIconsOption(cwd: string): AppIconsOption {
|
|
4008
|
+
const configPath = VITE_CONFIG_FILES.map((f) => resolve(cwd, f)).find((f) => existsSync(f))
|
|
4009
|
+
if (configPath === undefined) return { kind: 'unknown' }
|
|
4010
|
+
const text = readIfExists(configPath)
|
|
4011
|
+
if (text === null) return { kind: 'unknown' }
|
|
4012
|
+
const call = text.indexOf('basaltAppPlugin(')
|
|
4013
|
+
if (call === -1) return { kind: 'unknown' }
|
|
4014
|
+
const args = balancedSpan(text, call + 'basaltAppPlugin'.length)
|
|
4015
|
+
if (args === null) return { kind: 'unknown' }
|
|
4016
|
+
const key = args.search(/\bicons\s*:/)
|
|
4017
|
+
if (key === -1) return { kind: 'default', dir: null }
|
|
4018
|
+
const valueStart = args.indexOf(':', key) + 1
|
|
4019
|
+
const rest = args.slice(valueStart).trimStart()
|
|
4020
|
+
if (rest.startsWith('false')) return { kind: 'none' }
|
|
4021
|
+
const offset = args.length - rest.length
|
|
4022
|
+
if (rest.startsWith('[')) {
|
|
4023
|
+
const list = balancedSpan(args, offset)
|
|
4024
|
+
if (list === null) return { kind: 'unknown' }
|
|
4025
|
+
const sources = [...list.matchAll(/\bsrc\s*:\s*['"`]([^'"`]+)['"`]/g)].map(
|
|
4026
|
+
(m) => m[1] as string,
|
|
4027
|
+
)
|
|
4028
|
+
// An empty array means "this app has no icons" to the plugin, same as `false`.
|
|
4029
|
+
return sources.length === 0 ? { kind: 'none' } : { kind: 'explicit', sources }
|
|
4030
|
+
}
|
|
4031
|
+
if (rest.startsWith('{')) {
|
|
4032
|
+
const obj = balancedSpan(args, offset)
|
|
4033
|
+
if (obj === null) return { kind: 'unknown' }
|
|
4034
|
+
const dir = /\bdir\s*:\s*['"`]([^'"`]+)['"`]/.exec(obj)
|
|
4035
|
+
return { kind: 'default', dir: dir === null ? null : (dir[1] as string) }
|
|
4036
|
+
}
|
|
4037
|
+
return { kind: 'unknown' }
|
|
4038
|
+
}
|
|
4039
|
+
|
|
3786
4040
|
/** Read the value that follows a `--flag` in an argv slice; `undefined` when absent or terminal. */
|
|
3787
4041
|
function flagValue(flags: string[], name: string): string | undefined {
|
|
3788
4042
|
const i = flags.indexOf(name)
|
|
@@ -3805,6 +4059,32 @@ function generatedHeader(version: string, command: string, flags: string[]): str
|
|
|
3805
4059
|
return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
|
|
3806
4060
|
}
|
|
3807
4061
|
|
|
4062
|
+
/**
|
|
4063
|
+
* The generated file with its provenance line (line 2) blanked — what `--check` actually compares.
|
|
4064
|
+
*
|
|
4065
|
+
* Line 2 carries the EMITTING VERSION, and `--check` gated the whole file byte-for-byte, so every
|
|
4066
|
+
* basalt-ui release forced a mandatory no-op commit in every consumer that commits a generated
|
|
4067
|
+
* sheet: regenerate, one line changes, nothing else, ship it. That is the rot pattern removed from
|
|
4068
|
+
* DESIGN.md this cycle, made worse by a gate that is byte-equality.
|
|
4069
|
+
*
|
|
4070
|
+
* The version STAYS in the file rather than being dropped the way DESIGN.md's was, because the
|
|
4071
|
+
* consumer this command exists for — a framework-free site running `bunx basalt-ui tokens:css` with
|
|
4072
|
+
* no basalt in its dependency tree at all — has no other record of which version emitted it, and no
|
|
4073
|
+
* `doctor` to ask. What changes is that the line no longer gates: drift is measured over the token
|
|
4074
|
+
* VALUES, which is the only part of the file a release can meaningfully change.
|
|
4075
|
+
*
|
|
4076
|
+
* Blanked, not dropped, so the two sides stay line-aligned and the reported line counts still match
|
|
4077
|
+
* the files on disk. The `@generated` exemption is untouched by all of this — the guard requires
|
|
4078
|
+
* line 1 verbatim and line 2 parsing as the provenance line, and both are still emitted exactly as
|
|
4079
|
+
* {@link generatedHeader} builds them.
|
|
4080
|
+
*/
|
|
4081
|
+
function withoutProvenanceLine(css: string): string {
|
|
4082
|
+
const lines = css.split('\n')
|
|
4083
|
+
if (lines.length < 2) return css
|
|
4084
|
+
lines[1] = ''
|
|
4085
|
+
return lines.join('\n')
|
|
4086
|
+
}
|
|
4087
|
+
|
|
3808
4088
|
/**
|
|
3809
4089
|
* Space- and number-normalize the argument list of a legacy color function so the emitted file
|
|
3810
4090
|
* survives a normal repo's formatter.
|
|
@@ -3867,14 +4147,22 @@ function emitGeneratedCss(content: string, flags: string[], cwd: string, command
|
|
|
3867
4147
|
)
|
|
3868
4148
|
return 1
|
|
3869
4149
|
}
|
|
3870
|
-
if (onDisk === content) {
|
|
3871
|
-
|
|
4150
|
+
if (withoutProvenanceLine(onDisk) === withoutProvenanceLine(content)) {
|
|
4151
|
+
const staleProvenance = onDisk.split('\n')[1] !== content.split('\n')[1]
|
|
4152
|
+
console.log(
|
|
4153
|
+
`✓ ${command} --check: ${shown} is up to date.` +
|
|
4154
|
+
(staleProvenance
|
|
4155
|
+
? ' (Its provenance line still names an older basalt-ui; that line is deliberately not ' +
|
|
4156
|
+
'gated — re-run without --check whenever you want it refreshed.)'
|
|
4157
|
+
: ''),
|
|
4158
|
+
)
|
|
3872
4159
|
return 0
|
|
3873
4160
|
}
|
|
3874
4161
|
console.error(
|
|
3875
4162
|
`✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
|
|
3876
4163
|
`(on disk ${onDisk.split('\n').length} lines, emitted ${content.split('\n').length}) — ` +
|
|
3877
|
-
|
|
4164
|
+
"re-run the same command without --check and commit the result. The `@generated` header's " +
|
|
4165
|
+
'provenance line is excluded from this comparison, so a version bump alone never trips it.',
|
|
3878
4166
|
)
|
|
3879
4167
|
return 1
|
|
3880
4168
|
}
|
|
@@ -4118,9 +4406,69 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
4118
4406
|
return 0
|
|
4119
4407
|
}
|
|
4120
4408
|
|
|
4409
|
+
/**
|
|
4410
|
+
* Every flag each subcommand accepts — the table the unknown-flag gate reads.
|
|
4411
|
+
*
|
|
4412
|
+
* An unrecognized flag used to be SILENTLY IGNORED and the command exited 0: `check-theme
|
|
4413
|
+
* --audit-allow` scanned normally and reported success, `doctor --json` printed the human report.
|
|
4414
|
+
* That is the same fail-open shape `--version` had, and it is worse on a CLI agents drive
|
|
4415
|
+
* programmatically, where a typo'd gate reads as a passing gate.
|
|
4416
|
+
*/
|
|
4417
|
+
const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
|
4418
|
+
init: ['--with-router', '--with-query', '--merge-lint'],
|
|
4419
|
+
sync: ['--force', '--check', '--tokens-only', '--framework'],
|
|
4420
|
+
'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
|
|
4421
|
+
'check-coverage': [],
|
|
4422
|
+
info: ['--json'],
|
|
4423
|
+
doctor: ['--tokens-only', '--framework'],
|
|
4424
|
+
'guard-hook': [],
|
|
4425
|
+
'tokens:css': [
|
|
4426
|
+
'--out',
|
|
4427
|
+
'--check',
|
|
4428
|
+
'--selector-attribute',
|
|
4429
|
+
'--selector-class',
|
|
4430
|
+
'--light-class',
|
|
4431
|
+
'--dark-value',
|
|
4432
|
+
'--light-value',
|
|
4433
|
+
'--default-scheme',
|
|
4434
|
+
'--media-fallback',
|
|
4435
|
+
'--only',
|
|
4436
|
+
'--no-legacy-aliases',
|
|
4437
|
+
],
|
|
4438
|
+
'fonts:css': ['--out', '--check'],
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
/** Flags whose VALUE is the next argv token — skipped by the gate so `--out dist/x.css` does not
|
|
4442
|
+
* read `dist/x.css` as a second flag. */
|
|
4443
|
+
const VALUE_FLAGS = new Set([
|
|
4444
|
+
'--out',
|
|
4445
|
+
'--selector-attribute',
|
|
4446
|
+
'--selector-class',
|
|
4447
|
+
'--light-class',
|
|
4448
|
+
'--dark-value',
|
|
4449
|
+
'--light-value',
|
|
4450
|
+
'--default-scheme',
|
|
4451
|
+
'--only',
|
|
4452
|
+
])
|
|
4453
|
+
|
|
4454
|
+
/** The first flag `cmd` does not accept, or null. Unknown commands return null — the dispatcher's
|
|
4455
|
+
* default branch already names those. */
|
|
4456
|
+
function unknownFlag(cmd: string, flags: readonly string[]): string | null {
|
|
4457
|
+
const allowed = COMMAND_FLAGS[cmd]
|
|
4458
|
+
if (allowed === undefined) return null
|
|
4459
|
+
for (let i = 0; i < flags.length; i++) {
|
|
4460
|
+
const flag = flags[i] as string
|
|
4461
|
+
if (!flag.startsWith('-')) continue
|
|
4462
|
+
if (!allowed.includes(flag)) return flag
|
|
4463
|
+
if (VALUE_FLAGS.has(flag)) i++
|
|
4464
|
+
}
|
|
4465
|
+
return null
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4121
4468
|
/** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
|
|
4122
4469
|
const USAGE =
|
|
4123
|
-
'Usage: basalt-ui
|
|
4470
|
+
'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
|
|
4471
|
+
' sync [--force] [--check] [--tokens-only|--framework] |\n' +
|
|
4124
4472
|
' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
|
|
4125
4473
|
' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
|
|
4126
4474
|
'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
|
|
@@ -4143,10 +4491,17 @@ const USAGE =
|
|
|
4143
4491
|
'doctor [--tokens-only|--framework]\n' +
|
|
4144
4492
|
' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
|
|
4145
4493
|
' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
|
|
4146
|
-
'
|
|
4147
|
-
'
|
|
4148
|
-
'
|
|
4149
|
-
'
|
|
4494
|
+
'sync [--force] [--check] [--tokens-only|--framework]\n' +
|
|
4495
|
+
' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
|
|
4496
|
+
' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
|
|
4497
|
+
'check-theme / doctor / sync all resolve their project the same way: they honour BASALT_CWD, and\n' +
|
|
4498
|
+
'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
|
|
4499
|
+
'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
|
|
4500
|
+
'--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
|
|
4501
|
+
'Every subcommand accepts --help / -h to print this message and exit without running.\n' +
|
|
4502
|
+
'--version / -v prints the resolved package version and exits 0 — the one command that proves\n' +
|
|
4503
|
+
'WHICH basalt-ui ran, which a `bunx` cache can otherwise make a lie. An unknown command or an\n' +
|
|
4504
|
+
'unrecognized flag exits 1 and names what it did not understand; neither is ever ignored.'
|
|
4150
4505
|
|
|
4151
4506
|
/**
|
|
4152
4507
|
* CLI dispatcher — parses argv (subcommand + flags) and returns the command's exit code. The bin
|
|
@@ -4171,6 +4526,24 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4171
4526
|
console.log(USAGE)
|
|
4172
4527
|
return 0
|
|
4173
4528
|
}
|
|
4529
|
+
// Before dispatch, for the same reason --help is: this answers "which basalt-ui is running" and
|
|
4530
|
+
// must never run a command to do it. `bunx` does not re-resolve a cached package, so a consumer
|
|
4531
|
+
// can pin 1.23.0, invoke `bunx basalt-ui`, and be gated by a months-old copy — the version is the
|
|
4532
|
+
// only thing that catches it, and it has to be one bare line an agent can compare.
|
|
4533
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
4534
|
+
console.log(readFrameworkVersion(packageRoot()))
|
|
4535
|
+
return 0
|
|
4536
|
+
}
|
|
4537
|
+
if (cmd !== undefined) {
|
|
4538
|
+
const bad = unknownFlag(cmd, flags)
|
|
4539
|
+
if (bad !== null) {
|
|
4540
|
+
console.error(
|
|
4541
|
+
`basalt-ui ${cmd}: unrecognized flag '${bad}'. ` +
|
|
4542
|
+
`Accepted: ${(COMMAND_FLAGS[cmd] ?? []).join(' ') || '(none)'} --help\n`,
|
|
4543
|
+
)
|
|
4544
|
+
return 1
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4174
4547
|
switch (cmd) {
|
|
4175
4548
|
case 'init':
|
|
4176
4549
|
return init(cwd, {
|
|
@@ -4179,7 +4552,10 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4179
4552
|
mergeLint: flags.includes('--merge-lint'),
|
|
4180
4553
|
})
|
|
4181
4554
|
case 'sync':
|
|
4182
|
-
return sync(
|
|
4555
|
+
return sync(
|
|
4556
|
+
{ force: flags.includes('--force'), check: flags.includes('--check'), flags },
|
|
4557
|
+
cwd,
|
|
4558
|
+
)
|
|
4183
4559
|
case 'check-theme':
|
|
4184
4560
|
return checkTheme(cwd, flags)
|
|
4185
4561
|
case 'check-coverage':
|
|
@@ -4195,6 +4571,14 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4195
4571
|
case 'fonts:css':
|
|
4196
4572
|
return fontsCss(flags, cwd)
|
|
4197
4573
|
default:
|
|
4574
|
+
// Named, not just implied by a usage dump: a full-screen help block with no error line reads
|
|
4575
|
+
// like the command ran and chose to print help. Reported by a consumer who passed
|
|
4576
|
+
// `"check-theme --audit-allows"` as ONE argument and could not tell what had been rejected.
|
|
4577
|
+
console.error(
|
|
4578
|
+
cmd === undefined
|
|
4579
|
+
? 'basalt-ui: no command given.\n'
|
|
4580
|
+
: `basalt-ui: unknown command '${cmd}'.\n`,
|
|
4581
|
+
)
|
|
4198
4582
|
console.error(USAGE)
|
|
4199
4583
|
return 1
|
|
4200
4584
|
}
|