basalt-ui 1.23.0 → 1.24.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/AGENTS.md +31 -17
- package/CHANGELOG.md +23 -0
- package/MIGRATING.md +222 -6
- package/README.md +159 -49
- package/agent/rules/basalt-app.md +8 -2
- package/agent/rules/basalt-charts.md +38 -2
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-data.md +45 -13
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-query.md +57 -0
- package/agent/rules/basalt-tokens.md +18 -5
- package/agent/skills/basalt-app/SKILL.md +32 -12
- package/agent/skills/basalt-charts/SKILL.md +4 -2
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/dist/charts/kinds/BandStrip.d.ts +20 -6
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +59 -6
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +13 -0
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +269 -27
- package/dist/dashboard/empty-state.d.ts +6 -2
- package/dist/dashboard/empty-state.d.ts.map +1 -1
- package/dist/dashboard/empty-state.js +1 -1
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +8 -0
- package/dist/dashboard/query-state.d.ts +96 -0
- package/dist/dashboard/query-state.d.ts.map +1 -0
- package/dist/dashboard/query-state.js +123 -0
- package/dist/dashboard/query-state.test.d.ts +2 -0
- package/dist/dashboard/query-state.test.d.ts.map +1 -0
- package/dist/data/data-table.d.ts +61 -4
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +41 -4
- package/dist/data/data-table.test.d.ts +2 -0
- package/dist/data/data-table.test.d.ts.map +1 -0
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +50 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/query/error-message.d.ts +41 -0
- package/dist/query/error-message.d.ts.map +1 -0
- package/dist/query/error-message.js +30 -0
- package/dist/query/error-message.test.d.ts +2 -0
- package/dist/query/error-message.test.d.ts.map +1 -0
- package/dist/query/index.d.ts +1 -19
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +2 -14
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/charts/kinds/BandStrip.test.tsx +87 -5
- package/src/charts/kinds/BandStrip.tsx +105 -12
- package/src/charts/kinds/MirroredBars.test.tsx +39 -0
- package/src/charts/kinds/MirroredBars.tsx +38 -2
- package/src/cli/check-theme.test.ts +34 -0
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +496 -41
- package/src/cli/tokens-css.test.ts +129 -0
- package/src/cli/toolchain-wiring.test.ts +146 -0
- package/src/dashboard/empty-state.tsx +9 -3
- package/src/dashboard/index.ts +11 -0
- package/src/dashboard/query-state.test.tsx +152 -0
- package/src/dashboard/query-state.tsx +272 -0
- package/src/data/data-table.test.tsx +158 -0
- package/src/data/data-table.tsx +123 -5
- package/src/data/table.ts +6 -1
- package/src/guard/check-source.test.ts +214 -0
- package/src/guard/index.ts +169 -15
- package/src/index.ts +9 -0
- package/src/query/error-message.test.ts +67 -0
- package/src/query/error-message.ts +70 -0
- package/src/query/index.ts +2 -34
- package/src/surfaces.ts +1 -1
package/src/cli/index.ts
CHANGED
|
@@ -365,8 +365,14 @@ function readBasaltConfig(cwd: string): BasaltConfig {
|
|
|
365
365
|
* Extensions the walker collects. `html?`/`webmanifest` close argo's finding that raw hex ships in
|
|
366
366
|
* `index.html`'s `theme-color` and the webmanifest's `theme_color`/`background_color` — the two
|
|
367
367
|
* places whose colors nothing re-derives on a scheme change, and which the scan never reached.
|
|
368
|
+
*
|
|
369
|
+
* `astro`/`jsx`/`vue` close the same gap one component layer up. rollhook's marketing site is
|
|
370
|
+
* Astro: `apps/marketing/src` holds two `.astro` templates that are its ENTIRE markup layer, and
|
|
371
|
+
* check-theme reported a clean 4-file scan without ever opening either. The colour and typography
|
|
372
|
+
* kinds apply to them exactly as they do to `.html`, which was already scanned; `.jsx` and `.vue`
|
|
373
|
+
* were missing from the same regex, so a plain-JS or Vue consumer was unguarded end to end.
|
|
368
374
|
*/
|
|
369
|
-
const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/
|
|
375
|
+
const SCANNABLE_EXT = /\.(?:tsx?|jsx|astro|vue|css|html?|webmanifest)$/
|
|
370
376
|
|
|
371
377
|
/**
|
|
372
378
|
* Recursively collect scannable files under a root, skipping dependency/build dirs. Node+Bun-safe.
|
|
@@ -539,6 +545,36 @@ function descendantProjects(cwd: string, depth = 2): string[] {
|
|
|
539
545
|
return out
|
|
540
546
|
}
|
|
541
547
|
|
|
548
|
+
/**
|
|
549
|
+
* The nearest ANCESTOR of `cwd`, within the same repo, that is a basalt project — or null.
|
|
550
|
+
*
|
|
551
|
+
* The descend direction was handled and announced; ascend was not, and it fabricated instead. From
|
|
552
|
+
* `apps/demo` in a two-root repo, `readBasaltConfig` answered `{}`, `resolveRoots` substituted the
|
|
553
|
+
* built-in `['src']`, and check-theme scanned 22 of the repo's 44 guarded files, reported
|
|
554
|
+
* `✓ no off-palette colors`, and printed the invention back as `basalt.roots (src)`. Its
|
|
555
|
+
* `--audit-allows` then reported `0 live` for a repo carrying a live waiver — a clean slate from
|
|
556
|
+
* the one command whose entire job is to deny one. `doctor`, standing in that same directory,
|
|
557
|
+
* already resolved the parent install and named it, so the two commands disagreed about the same
|
|
558
|
+
* repo.
|
|
559
|
+
*
|
|
560
|
+
* Bounded by {@link findRepoRoot}, inclusive: an ancestor outside the repo is not this project's
|
|
561
|
+
* config. `repoRoot === cwd` means either cwd IS the root (nothing above it to ascend to) or there
|
|
562
|
+
* is no `.git` anywhere (no bound to walk within) — both answer null, which leaves a standalone
|
|
563
|
+
* unconfigured consumer on the built-in defaults exactly as before.
|
|
564
|
+
*/
|
|
565
|
+
function ascendantProject(cwd: string): string | null {
|
|
566
|
+
const repoRoot = findRepoRoot(cwd)
|
|
567
|
+
if (repoRoot === cwd) return null
|
|
568
|
+
let current = dirname(cwd)
|
|
569
|
+
for (;;) {
|
|
570
|
+
if (hasBasaltProject(current)) return current
|
|
571
|
+
if (current === repoRoot) return null
|
|
572
|
+
const parent = dirname(current)
|
|
573
|
+
if (parent === current) return null
|
|
574
|
+
current = parent
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
542
578
|
function resolveProjectDir(cwd: string): ProjectResolution {
|
|
543
579
|
const override = process.env['BASALT_CWD']
|
|
544
580
|
if (override !== undefined && override.length > 0) {
|
|
@@ -558,6 +594,13 @@ function resolveProjectDir(cwd: string): ProjectResolution {
|
|
|
558
594
|
return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
|
|
559
595
|
}
|
|
560
596
|
if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates }
|
|
597
|
+
|
|
598
|
+
// Nothing at cwd and nothing below it: look UP before falling back to a default. See
|
|
599
|
+
// {@link ascendantProject} — the fabricated `roots: ['src']` this replaces is what let a scan
|
|
600
|
+
// pass over half a repo and an audit report zero waivers for a repo that has one.
|
|
601
|
+
const above = ascendantProject(cwd)
|
|
602
|
+
if (above !== null) return { dir: above, relocatedFrom: cwd, ambiguous: null }
|
|
603
|
+
|
|
561
604
|
return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
562
605
|
}
|
|
563
606
|
|
|
@@ -1352,10 +1395,25 @@ function readFrameworkVersion(pkgRoot: string): string {
|
|
|
1352
1395
|
}
|
|
1353
1396
|
}
|
|
1354
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* How a seeded script, a seeded CI step, or a piece of doctor advice should invoke the CLI.
|
|
1400
|
+
*
|
|
1401
|
+
* The LOCAL bin wherever basalt actually resolved; `bunx basalt-ui` only as the pre-install
|
|
1402
|
+
* fallback. `bunx` does not re-resolve a package it has cached, so a seeded `bunx basalt-ui` lets a
|
|
1403
|
+
* consumer pin one version and be gated by another — which is exactly what happened across three
|
|
1404
|
+
* consumer repos, in CI, in package.json scripts and in a `.claude` PreToolUse hook, all of them
|
|
1405
|
+
* placed here. Every emitted invocation goes through this function so there is one place for it to
|
|
1406
|
+
* be right.
|
|
1407
|
+
*/
|
|
1408
|
+
function basaltBinCommand(install: BasaltInstall, cwd: string): string {
|
|
1409
|
+
if (install.dir === null) return 'bunx basalt-ui'
|
|
1410
|
+
const binDir = resolve(install.dir, '..', '.bin')
|
|
1411
|
+
return `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1355
1414
|
/** Build the template-variable map from the framework root + consumer cwd/config. */
|
|
1356
1415
|
function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
|
|
1357
1416
|
const install = findBasaltInstall(cwd)
|
|
1358
|
-
const binDir = install.dir === null ? null : resolve(install.dir, '..', '.bin')
|
|
1359
1417
|
return {
|
|
1360
1418
|
APP_NAME: readPackageName(cwd),
|
|
1361
1419
|
BASALT_VERSION: readFrameworkVersion(pkgRoot),
|
|
@@ -1364,10 +1422,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
|
|
|
1364
1422
|
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
|
|
1365
1423
|
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
|
|
1366
1424
|
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
|
|
1367
|
-
BASALT_BIN:
|
|
1368
|
-
binDir === null
|
|
1369
|
-
? 'bunx basalt-ui'
|
|
1370
|
-
: `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`,
|
|
1425
|
+
BASALT_BIN: basaltBinCommand(install, cwd),
|
|
1371
1426
|
}
|
|
1372
1427
|
}
|
|
1373
1428
|
|
|
@@ -2542,7 +2597,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
2542
2597
|
` "PreToolUse": [\n` +
|
|
2543
2598
|
` {\n` +
|
|
2544
2599
|
` "matcher": "Write|Edit|MultiEdit",\n` +
|
|
2545
|
-
` "hooks": [{ "type": "command", "command": "
|
|
2600
|
+
` "hooks": [{ "type": "command", "command": "${ctx.vars.BASALT_BIN} guard-hook" }]\n` +
|
|
2546
2601
|
` }\n` +
|
|
2547
2602
|
` ]\n` +
|
|
2548
2603
|
` }`,
|
|
@@ -2603,6 +2658,21 @@ type SyncOptions = {
|
|
|
2603
2658
|
* install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
|
|
2604
2659
|
* "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
|
|
2605
2660
|
*/
|
|
2661
|
+
/**
|
|
2662
|
+
* The sentence `sync` prints when the cwd carries no install but an ancestor does.
|
|
2663
|
+
*
|
|
2664
|
+
* Shared with `doctor` deliberately. The two commands already share `resolveProjectDir`, but not
|
|
2665
|
+
* the ADVICE: `sync` named the parent install (1.22.0's "stop scaffolding a second consumer" fix)
|
|
2666
|
+
* while `doctor`, in the same directory, kept prescribing `basalt-ui init` — so following doctor
|
|
2667
|
+
* literally performed the exact mistake that fix exists to prevent. One function, one sentence.
|
|
2668
|
+
*/
|
|
2669
|
+
function parentInstallAdvice(cwd: string, parent: string, verb: string): string {
|
|
2670
|
+
return (
|
|
2671
|
+
`This repo's install is at ${relativePosix(cwd, parent)} (${parent}) — run ${verb} there, ` +
|
|
2672
|
+
'or set BASALT_CWD to it.'
|
|
2673
|
+
)
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2606
2676
|
function findManifestAbove(dir: string): string | null {
|
|
2607
2677
|
let current = resolve(dir, '..')
|
|
2608
2678
|
for (;;) {
|
|
@@ -2681,8 +2751,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
2681
2751
|
(elsewhere === null
|
|
2682
2752
|
? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
|
|
2683
2753
|
'package really is meant to be a consumer.'
|
|
2684
|
-
: `
|
|
2685
|
-
'sync there, or set BASALT_CWD to it.'),
|
|
2754
|
+
: ` ${parentInstallAdvice(cwd, elsewhere, 'sync')}`),
|
|
2686
2755
|
)
|
|
2687
2756
|
return 1
|
|
2688
2757
|
}
|
|
@@ -3299,8 +3368,10 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
3299
3368
|
* demonstrably not the installed package — see the check).
|
|
3300
3369
|
* 5. The running CLI's own version matches the installed basalt-ui (catches a stale
|
|
3301
3370
|
* `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
|
|
3302
|
-
* 9. `basaltAppPlugin`
|
|
3303
|
-
*
|
|
3371
|
+
* 9. The icon files `basaltAppPlugin` (basalt-ui/vite) references exist under the app package's
|
|
3372
|
+
* public/ — the package found via `basalt.roots`, not just cwd, so a root-invoked run still
|
|
3373
|
+
* reaches it. Never silently omitted: no plugin call anywhere is a pass that says so, a
|
|
3374
|
+
* plugin call with no public/ beside it is a SKIPPED.
|
|
3304
3375
|
* 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
|
|
3305
3376
|
* exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
|
|
3306
3377
|
* skew slips through unnoticed.
|
|
@@ -3365,6 +3436,10 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3365
3436
|
// ── Profile ────────────────────────────────────────────────────────────────
|
|
3366
3437
|
const manifestAbs = resolve(cwd, MANIFEST_PATH)
|
|
3367
3438
|
const manifestExists = existsSync(manifestAbs)
|
|
3439
|
+
// Non-null ⇒ this directory is a package UNDER a configured repo, not an unscaffolded consumer.
|
|
3440
|
+
// Every remedy below that would otherwise say "run `basalt-ui init`" points at the parent
|
|
3441
|
+
// instead — see {@link parentInstallAdvice}.
|
|
3442
|
+
const parentInstall = manifestExists ? null : findManifestAbove(cwd)
|
|
3368
3443
|
const profile = inferredProfile(cwd, cfg, flags)
|
|
3369
3444
|
if (profile === 'tokens-only') {
|
|
3370
3445
|
lines.push(
|
|
@@ -3388,6 +3463,12 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3388
3463
|
pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
|
|
3389
3464
|
} else if (manifestExists) {
|
|
3390
3465
|
pass(`${MANIFEST_PATH} exists`)
|
|
3466
|
+
} else if (parentInstall !== null) {
|
|
3467
|
+
fail(
|
|
3468
|
+
`${MANIFEST_PATH} missing here, but this is not an unscaffolded consumer. ` +
|
|
3469
|
+
`${parentInstallAdvice(cwd, parentInstall, 'doctor')} \`basalt-ui init\` is NOT the fix: ` +
|
|
3470
|
+
'it would scaffold a SECOND consumer beside the real one.',
|
|
3471
|
+
)
|
|
3391
3472
|
} else {
|
|
3392
3473
|
fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
|
|
3393
3474
|
}
|
|
@@ -3549,7 +3630,11 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3549
3630
|
if (oxlintrcRaw === null) {
|
|
3550
3631
|
fail(
|
|
3551
3632
|
'.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
|
|
3552
|
-
'active.
|
|
3633
|
+
'active. ' +
|
|
3634
|
+
(parentInstall === null
|
|
3635
|
+
? 'Run `basalt-ui init` to seed it.'
|
|
3636
|
+
: `${parentInstallAdvice(cwd, parentInstall, 'doctor')} The preset is seeded once, at ` +
|
|
3637
|
+
'the install — not per package.'),
|
|
3553
3638
|
)
|
|
3554
3639
|
} else {
|
|
3555
3640
|
// JSONC: oxlint accepts comments and real consumer configs carry them.
|
|
@@ -3642,40 +3727,81 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3642
3727
|
`${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
|
|
3643
3728
|
'command runs check-theme — the theme guard is not gating commits. Either add ' +
|
|
3644
3729
|
`"extends: [${lefthookCorrect}]", or add your own command running ` +
|
|
3645
|
-
|
|
3730
|
+
`\`${basaltBinCommand(install, cwd)} check-theme\` (with \`root:\` if it must run in a ` +
|
|
3731
|
+
'subdirectory).',
|
|
3646
3732
|
)
|
|
3647
3733
|
} else {
|
|
3648
3734
|
warn(
|
|
3649
3735
|
`${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
|
|
3650
3736
|
'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
|
|
3651
3737
|
'config it never read — treat it as advisory. If the guard really is unwired, add ' +
|
|
3652
|
-
`"extends: [${lefthookCorrect}]" or your own command running
|
|
3738
|
+
`"extends: [${lefthookCorrect}]" or your own command running ` +
|
|
3739
|
+
`\`${basaltBinCommand(install, cwd)} check-theme\`.`,
|
|
3653
3740
|
)
|
|
3654
3741
|
}
|
|
3655
3742
|
|
|
3656
|
-
// ── Warn check 8:
|
|
3657
|
-
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references
|
|
3658
|
-
//
|
|
3659
|
-
//
|
|
3660
|
-
//
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
const
|
|
3672
|
-
if (
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3743
|
+
// ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
|
|
3744
|
+
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
|
|
3745
|
+
// (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
|
|
3746
|
+
// is read (see {@link readAppIconsOption}) rather than assumed.
|
|
3747
|
+
//
|
|
3748
|
+
// It used to read `cwd` alone, and in a monorepo the vite config lives in the app PACKAGE while
|
|
3749
|
+
// the only invocation that exits 0 is the repo root. The check therefore vanished from the root
|
|
3750
|
+
// run with no line at all — the failure mode SKIPPED exists to eliminate — leaving no single
|
|
3751
|
+
// invocation that both passes and checks the icons. It now resolves the app package the same way
|
|
3752
|
+
// the scan does, off `basalt.roots`; see {@link findAppPluginDir}.
|
|
3753
|
+
if (profile === 'framework') {
|
|
3754
|
+
const appDir = findAppPluginDir(cwd, cfg)
|
|
3755
|
+
const iconsIn = appDir ?? cwd
|
|
3756
|
+
const icons = readAppIconsOption(iconsIn)
|
|
3757
|
+
const here = appDir === null || appDir === cwd ? '' : `${relativePosix(cwd, appDir)}/`
|
|
3758
|
+
const publicDir = resolve(iconsIn, 'public')
|
|
3759
|
+
if (appDir === null && !existsSync(publicDir)) {
|
|
3760
|
+
// No `basaltAppPlugin(` anywhere under the declared roots and no public/ here: nothing in
|
|
3761
|
+
// this project references icon files, so there is nothing to check — and saying so is the
|
|
3762
|
+
// point. A check that vanishes without a word is indistinguishable from one that passed.
|
|
3763
|
+
pass('app icons: no `basaltAppPlugin(` under this project and no public/ — nothing to check')
|
|
3764
|
+
} else if (icons.kind === 'none') {
|
|
3765
|
+
pass('basaltAppPlugin is configured with no icons — nothing to check')
|
|
3766
|
+
} else if (!existsSync(publicDir)) {
|
|
3767
|
+
skip(
|
|
3768
|
+
`app icons — ${here === '' ? 'this package' : here.replace(/\/$/, '')} configures ` +
|
|
3769
|
+
'basaltAppPlugin, but there is no `public/` beside its vite config, so the files it ' +
|
|
3770
|
+
"references cannot be located. Use Vite's public-dir convention, or set `icons: false` " +
|
|
3771
|
+
'if this app ships none.',
|
|
3676
3772
|
)
|
|
3773
|
+
} else if (icons.kind === 'explicit') {
|
|
3774
|
+
// The app NAMED its icons, so those are the only files it owes. Checking the six defaults
|
|
3775
|
+
// here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
|
|
3776
|
+
const missing = icons.sources.filter(
|
|
3777
|
+
(src) => !existsSync(resolve(publicDir, src.replace(/^\//, ''))),
|
|
3778
|
+
)
|
|
3779
|
+
if (missing.length > 0) {
|
|
3780
|
+
warn(
|
|
3781
|
+
`basaltAppPlugin declares icon(s) that are not in ${here}public/: ${missing.join(', ')} — ` +
|
|
3782
|
+
'the manifest and the head links will point at a 404. Place them, or drop them from ' +
|
|
3783
|
+
'the `icons` array.',
|
|
3784
|
+
)
|
|
3785
|
+
} else {
|
|
3786
|
+
pass(
|
|
3787
|
+
`${here}public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's ` +
|
|
3788
|
+
'`icons` option names',
|
|
3789
|
+
)
|
|
3790
|
+
}
|
|
3677
3791
|
} else {
|
|
3678
|
-
|
|
3792
|
+
const dir = icons.kind === 'default' ? icons.dir : null
|
|
3793
|
+
const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
|
|
3794
|
+
const shown = dir === null ? `${here}public/` : `${here}public/${dir.replace(/^\//, '')}`
|
|
3795
|
+
const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
|
|
3796
|
+
if (missingIcons.length > 0) {
|
|
3797
|
+
warn(
|
|
3798
|
+
`${shown} is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
|
|
3799
|
+
`them (e.g. via realfavicongenerator.net) and place them at the ${shown} root, or name ` +
|
|
3800
|
+
"the icons this app really has via the plugin's `icons` array.",
|
|
3801
|
+
)
|
|
3802
|
+
} else {
|
|
3803
|
+
pass(`${shown} has all of basaltAppPlugin's default icon files`)
|
|
3804
|
+
}
|
|
3679
3805
|
}
|
|
3680
3806
|
}
|
|
3681
3807
|
|
|
@@ -3882,6 +4008,137 @@ export function info(flags: string[]): number {
|
|
|
3882
4008
|
return 0
|
|
3883
4009
|
}
|
|
3884
4010
|
|
|
4011
|
+
/** The six filenames `basaltAppPlugin` references when `icons` is left at its default. */
|
|
4012
|
+
const DEFAULT_APP_ICON_FILES = [
|
|
4013
|
+
'favicon.ico',
|
|
4014
|
+
'favicon.svg',
|
|
4015
|
+
'favicon-96x96.png',
|
|
4016
|
+
'apple-touch-icon.png',
|
|
4017
|
+
'web-app-manifest-192x192.png',
|
|
4018
|
+
'web-app-manifest-512x512.png',
|
|
4019
|
+
] as const
|
|
4020
|
+
|
|
4021
|
+
const VITE_CONFIG_FILES = [
|
|
4022
|
+
'vite.config.ts',
|
|
4023
|
+
'vite.config.mts',
|
|
4024
|
+
'vite.config.cts',
|
|
4025
|
+
'vite.config.js',
|
|
4026
|
+
'vite.config.mjs',
|
|
4027
|
+
'vite.config.cjs',
|
|
4028
|
+
] as const
|
|
4029
|
+
|
|
4030
|
+
/**
|
|
4031
|
+
* The directory whose vite config actually calls `basaltAppPlugin(` — cwd, or the app package the
|
|
4032
|
+
* declared roots point into. Null when no vite config under this project calls it.
|
|
4033
|
+
*
|
|
4034
|
+
* `basalt.roots: ["apps/web/src"]` already names where the app is; the icon check just never used
|
|
4035
|
+
* it, so from the repo root — the only invocation that can exit 0 on a monorepo layout — it found
|
|
4036
|
+
* no vite config and silently emitted nothing. Each root is walked from its own directory up to
|
|
4037
|
+
* cwd, nearest first, so `apps/web/src` resolves to `apps/web`, the package that owns both the
|
|
4038
|
+
* vite config and the `public/` tree.
|
|
4039
|
+
*/
|
|
4040
|
+
function findAppPluginDir(cwd: string, cfg: BasaltConfig): string | null {
|
|
4041
|
+
const seen = new Set<string>()
|
|
4042
|
+
const candidates: string[] = []
|
|
4043
|
+
const add = (dir: string): void => {
|
|
4044
|
+
if (seen.has(dir)) return
|
|
4045
|
+
seen.add(dir)
|
|
4046
|
+
candidates.push(dir)
|
|
4047
|
+
}
|
|
4048
|
+
add(cwd)
|
|
4049
|
+
for (const root of resolveRoots(cfg)) {
|
|
4050
|
+
let current = resolve(cwd, root)
|
|
4051
|
+
// A root that escapes cwd (`../shared`) is not this project's app package — never walk out.
|
|
4052
|
+
while (current !== cwd && !relative(cwd, current).startsWith('..')) {
|
|
4053
|
+
add(current)
|
|
4054
|
+
const parent = dirname(current)
|
|
4055
|
+
if (parent === current) break
|
|
4056
|
+
current = parent
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
for (const dir of candidates) {
|
|
4060
|
+
const configPath = VITE_CONFIG_FILES.map((f) => resolve(dir, f)).find((f) => existsSync(f))
|
|
4061
|
+
if (configPath === undefined) continue
|
|
4062
|
+
if (readIfExists(configPath)?.includes('basaltAppPlugin(') === true) return dir
|
|
4063
|
+
}
|
|
4064
|
+
return null
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
/**
|
|
4068
|
+
* What `basaltAppPlugin`'s `icons` option says this app actually has.
|
|
4069
|
+
*
|
|
4070
|
+
* `unknown` = no vite config, no `basaltAppPlugin(` call, or an `icons` value this bounded read
|
|
4071
|
+
* cannot classify — all three fall back to the six-default check, so the gate can only ever get
|
|
4072
|
+
* NARROWER than it was, never blinder.
|
|
4073
|
+
*/
|
|
4074
|
+
type AppIconsOption =
|
|
4075
|
+
| { kind: 'default'; dir: string | null }
|
|
4076
|
+
| { kind: 'none' }
|
|
4077
|
+
| { kind: 'explicit'; sources: readonly string[] }
|
|
4078
|
+
| { kind: 'unknown' }
|
|
4079
|
+
|
|
4080
|
+
/** The balanced `(`…`)` / `{`…`}` / `[`…`]` span starting at `open`, or null if it never closes. */
|
|
4081
|
+
function balancedSpan(text: string, open: number): string | null {
|
|
4082
|
+
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' }
|
|
4083
|
+
const closer = pairs[text[open] as string]
|
|
4084
|
+
if (closer === undefined) return null
|
|
4085
|
+
let depth = 0
|
|
4086
|
+
for (let i = open; i < text.length; i++) {
|
|
4087
|
+
const ch = text[i] as string
|
|
4088
|
+
if (ch === text[open]) depth++
|
|
4089
|
+
else if (ch === closer) {
|
|
4090
|
+
depth--
|
|
4091
|
+
if (depth === 0) return text.slice(open, i + 1)
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
return null
|
|
4095
|
+
}
|
|
4096
|
+
|
|
4097
|
+
/**
|
|
4098
|
+
* Read the `icons` option out of the consumer's vite config.
|
|
4099
|
+
*
|
|
4100
|
+
* 1.23.0 let `icons` name an app's REAL icon files; doctor's icon check kept demanding the six
|
|
4101
|
+
* default filenames and never read the option, so adopting the release's headline feature produced
|
|
4102
|
+
* a brand-new warning — on the very repo the feature was written for. A warning that fires BECAUSE
|
|
4103
|
+
* you adopted the new API teaches people to ignore doctor.
|
|
4104
|
+
*
|
|
4105
|
+
* doctor does not run the build, so the plugin's resolved answer is not available to it; this is a
|
|
4106
|
+
* bounded textual read of the config file, the same shape the guard uses on source. It is
|
|
4107
|
+
* deliberately conservative: anything it cannot classify returns `unknown` and the old check runs.
|
|
4108
|
+
*/
|
|
4109
|
+
function readAppIconsOption(cwd: string): AppIconsOption {
|
|
4110
|
+
const configPath = VITE_CONFIG_FILES.map((f) => resolve(cwd, f)).find((f) => existsSync(f))
|
|
4111
|
+
if (configPath === undefined) return { kind: 'unknown' }
|
|
4112
|
+
const text = readIfExists(configPath)
|
|
4113
|
+
if (text === null) return { kind: 'unknown' }
|
|
4114
|
+
const call = text.indexOf('basaltAppPlugin(')
|
|
4115
|
+
if (call === -1) return { kind: 'unknown' }
|
|
4116
|
+
const args = balancedSpan(text, call + 'basaltAppPlugin'.length)
|
|
4117
|
+
if (args === null) return { kind: 'unknown' }
|
|
4118
|
+
const key = args.search(/\bicons\s*:/)
|
|
4119
|
+
if (key === -1) return { kind: 'default', dir: null }
|
|
4120
|
+
const valueStart = args.indexOf(':', key) + 1
|
|
4121
|
+
const rest = args.slice(valueStart).trimStart()
|
|
4122
|
+
if (rest.startsWith('false')) return { kind: 'none' }
|
|
4123
|
+
const offset = args.length - rest.length
|
|
4124
|
+
if (rest.startsWith('[')) {
|
|
4125
|
+
const list = balancedSpan(args, offset)
|
|
4126
|
+
if (list === null) return { kind: 'unknown' }
|
|
4127
|
+
const sources = [...list.matchAll(/\bsrc\s*:\s*['"`]([^'"`]+)['"`]/g)].map(
|
|
4128
|
+
(m) => m[1] as string,
|
|
4129
|
+
)
|
|
4130
|
+
// An empty array means "this app has no icons" to the plugin, same as `false`.
|
|
4131
|
+
return sources.length === 0 ? { kind: 'none' } : { kind: 'explicit', sources }
|
|
4132
|
+
}
|
|
4133
|
+
if (rest.startsWith('{')) {
|
|
4134
|
+
const obj = balancedSpan(args, offset)
|
|
4135
|
+
if (obj === null) return { kind: 'unknown' }
|
|
4136
|
+
const dir = /\bdir\s*:\s*['"`]([^'"`]+)['"`]/.exec(obj)
|
|
4137
|
+
return { kind: 'default', dir: dir === null ? null : (dir[1] as string) }
|
|
4138
|
+
}
|
|
4139
|
+
return { kind: 'unknown' }
|
|
4140
|
+
}
|
|
4141
|
+
|
|
3885
4142
|
/** Read the value that follows a `--flag` in an argv slice; `undefined` when absent or terminal. */
|
|
3886
4143
|
function flagValue(flags: string[], name: string): string | undefined {
|
|
3887
4144
|
const i = flags.indexOf(name)
|
|
@@ -3904,6 +4161,84 @@ function generatedHeader(version: string, command: string, flags: string[]): str
|
|
|
3904
4161
|
return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
|
|
3905
4162
|
}
|
|
3906
4163
|
|
|
4164
|
+
/**
|
|
4165
|
+
* The provenance line, split so only the VERSION token can be neutralized.
|
|
4166
|
+
*
|
|
4167
|
+
* Deliberately the same shape as the guard's `GENERATED_PROVENANCE_LINE` (`src/guard/index.ts`),
|
|
4168
|
+
* which is what the `@generated` exemption tests line 2 against — the two must keep agreeing, so
|
|
4169
|
+
* this is written to match, not to be lenient. A line that does not match is left VERBATIM, which
|
|
4170
|
+
* is what makes a deleted, reworded or forged header fail `--check` instead of being blanked into
|
|
4171
|
+
* agreement.
|
|
4172
|
+
*/
|
|
4173
|
+
const PROVENANCE_VERSION =
|
|
4174
|
+
/^(\/\* basalt-ui )(\d+\.\d+\.\d+\S*)( — `basalt-ui (?:tokens:css|fonts:css)[^`]*` \*\/)$/
|
|
4175
|
+
|
|
4176
|
+
/** The provenance line's version token, or null when line 2 is not a provenance line at all. */
|
|
4177
|
+
function provenanceVersion(css: string): string | null {
|
|
4178
|
+
const match = PROVENANCE_VERSION.exec(css.split('\n')[1] ?? '')
|
|
4179
|
+
return match === null ? null : (match[2] as string)
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
/**
|
|
4183
|
+
* The generated file with only the provenance line's VERSION TOKEN neutralized — what `--check`
|
|
4184
|
+
* actually compares.
|
|
4185
|
+
*
|
|
4186
|
+
* `--check` used to gate the whole file byte-for-byte, so every basalt-ui release forced a
|
|
4187
|
+
* mandatory no-op commit in every consumer that commits a generated sheet. 1.23.1 fixed that by
|
|
4188
|
+
* blanking the whole of line 2 — and line 2 is the line that carries the exact invocation, which
|
|
4189
|
+
* line 1 tells the reader to regenerate with. The gate stopped verifying the one instruction it
|
|
4190
|
+
* hands out: rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
|
|
4191
|
+
* --with-legacy-aliases` and `--check` passed clean.
|
|
4192
|
+
*
|
|
4193
|
+
* So the version token is replaced by a sentinel and the rest of the line still gates. A release
|
|
4194
|
+
* alone never trips the check; a changed flag, a reworded header, and a header that is simply gone
|
|
4195
|
+
* all do.
|
|
4196
|
+
*
|
|
4197
|
+
* The version STAYS in the file rather than being dropped, because the consumer this command exists
|
|
4198
|
+
* for — a framework-free site running `bunx basalt-ui tokens:css` with no basalt in its dependency
|
|
4199
|
+
* tree at all — has no other record of which version emitted it, and no `doctor` to ask.
|
|
4200
|
+
*
|
|
4201
|
+
* Line-aligned, never dropped, so the reported line counts still match the files on disk. The
|
|
4202
|
+
* `@generated` exemption is untouched by all of this: nothing here changes what
|
|
4203
|
+
* {@link generatedHeader} emits, and {@link PROVENANCE_VERSION} is matched to the guard's own
|
|
4204
|
+
* provenance regex rather than replacing it.
|
|
4205
|
+
*/
|
|
4206
|
+
function withoutProvenanceVersion(css: string): string {
|
|
4207
|
+
const lines = css.split('\n')
|
|
4208
|
+
const line = lines[1]
|
|
4209
|
+
if (line === undefined) return css
|
|
4210
|
+
const match = PROVENANCE_VERSION.exec(line)
|
|
4211
|
+
if (match === null) return css
|
|
4212
|
+
lines[1] = `${match[1] as string}<version>${match[3] as string}`
|
|
4213
|
+
return lines.join('\n')
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
/** The leading `major.minor.patch` of a version string as numbers, or null when it has none. */
|
|
4217
|
+
function versionTriple(version: string): number[] | null {
|
|
4218
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version)
|
|
4219
|
+
return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
/**
|
|
4223
|
+
* How the on-disk provenance version relates to the one that just ran — measured, not assumed.
|
|
4224
|
+
*
|
|
4225
|
+
* The success message asserted the file "still names an older basalt-ui" without ever reading the
|
|
4226
|
+
* token, so `0.0.1-nonsense` got the same sentence. Both sides are parsed; the numeric triples are
|
|
4227
|
+
* compared and anything that does not order cleanly is reported as merely different.
|
|
4228
|
+
*/
|
|
4229
|
+
function describeVersionSkew(onDisk: string, current: string): string {
|
|
4230
|
+
const a = versionTriple(onDisk)
|
|
4231
|
+
const b = versionTriple(current)
|
|
4232
|
+
if (a === null || b === null) return 'a different'
|
|
4233
|
+
for (let i = 0; i < 3; i++) {
|
|
4234
|
+
const left = a[i] as number
|
|
4235
|
+
const right = b[i] as number
|
|
4236
|
+
if (left < right) return 'an older'
|
|
4237
|
+
if (left > right) return 'a newer'
|
|
4238
|
+
}
|
|
4239
|
+
return 'a different'
|
|
4240
|
+
}
|
|
4241
|
+
|
|
3907
4242
|
/**
|
|
3908
4243
|
* Space- and number-normalize the argument list of a legacy color function so the emitted file
|
|
3909
4244
|
* survives a normal repo's formatter.
|
|
@@ -3966,14 +4301,46 @@ function emitGeneratedCss(content: string, flags: string[], cwd: string, command
|
|
|
3966
4301
|
)
|
|
3967
4302
|
return 1
|
|
3968
4303
|
}
|
|
3969
|
-
if (onDisk === content) {
|
|
3970
|
-
|
|
4304
|
+
if (withoutProvenanceVersion(onDisk) === withoutProvenanceVersion(content)) {
|
|
4305
|
+
const diskVersion = provenanceVersion(onDisk)
|
|
4306
|
+
const currentVersion = provenanceVersion(content)
|
|
4307
|
+
const skew =
|
|
4308
|
+
diskVersion !== null && currentVersion !== null && diskVersion !== currentVersion
|
|
4309
|
+
? ` (Its provenance line names basalt-ui ${diskVersion}, ` +
|
|
4310
|
+
`${describeVersionSkew(diskVersion, currentVersion)} release than the ` +
|
|
4311
|
+
`${currentVersion} that just ran — only that version token is excluded from the ` +
|
|
4312
|
+
'comparison. Re-run without --check whenever you want it refreshed.)'
|
|
4313
|
+
: ''
|
|
4314
|
+
console.log(`✓ ${command} --check: ${shown} is up to date.${skew}`)
|
|
3971
4315
|
return 0
|
|
3972
4316
|
}
|
|
4317
|
+
// Name the provenance line when it is the ONLY thing that moved: the diff is one line the
|
|
4318
|
+
// reader is about to scroll past, and a rewritten invocation is the failure that bought this
|
|
4319
|
+
// branch — line 1 tells them to regenerate with line 2, so line 2 has to be true.
|
|
4320
|
+
const diskLines = onDisk.split('\n')
|
|
4321
|
+
const wantLines = content.split('\n')
|
|
4322
|
+
const provenanceOnly =
|
|
4323
|
+
diskLines.length === wantLines.length &&
|
|
4324
|
+
diskLines.every((line, i) => i === 1 || line === wantLines[i]) &&
|
|
4325
|
+
diskLines[1] !== wantLines[1]
|
|
4326
|
+
if (provenanceOnly) {
|
|
4327
|
+
console.error(
|
|
4328
|
+
`✖ ${command} --check: ${shown}'s \`@generated\` provenance line does not match the ` +
|
|
4329
|
+
'command that produced this content — the token values agree, line 2 does not.\n' +
|
|
4330
|
+
` on disk: ${diskLines[1]}\n` +
|
|
4331
|
+
` emitted: ${wantLines[1]}\n` +
|
|
4332
|
+
'Line 1 tells the reader to regenerate with line 2, so line 2 is gated: run the emitted ' +
|
|
4333
|
+
'invocation without --check and commit the result, or change the flags this check runs ' +
|
|
4334
|
+
'with to the ones the file was really built from. Only the version token is exempt.',
|
|
4335
|
+
)
|
|
4336
|
+
return 1
|
|
4337
|
+
}
|
|
3973
4338
|
console.error(
|
|
3974
4339
|
`✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
|
|
3975
|
-
`(on disk ${
|
|
3976
|
-
're-run the same command without --check and commit the result.'
|
|
4340
|
+
`(on disk ${diskLines.length} lines, emitted ${wantLines.length}) — ` +
|
|
4341
|
+
're-run the same command without --check and commit the result. Only the `@generated` ' +
|
|
4342
|
+
"provenance line's VERSION token is excluded from this comparison, so a release alone " +
|
|
4343
|
+
'never trips it — but its flags do.',
|
|
3977
4344
|
)
|
|
3978
4345
|
return 1
|
|
3979
4346
|
}
|
|
@@ -4217,9 +4584,68 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
4217
4584
|
return 0
|
|
4218
4585
|
}
|
|
4219
4586
|
|
|
4587
|
+
/**
|
|
4588
|
+
* Every flag each subcommand accepts — the table the unknown-flag gate reads.
|
|
4589
|
+
*
|
|
4590
|
+
* An unrecognized flag used to be SILENTLY IGNORED and the command exited 0: `check-theme
|
|
4591
|
+
* --audit-allow` scanned normally and reported success, `doctor --json` printed the human report.
|
|
4592
|
+
* That is the same fail-open shape `--version` had, and it is worse on a CLI agents drive
|
|
4593
|
+
* programmatically, where a typo'd gate reads as a passing gate.
|
|
4594
|
+
*/
|
|
4595
|
+
const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
|
4596
|
+
init: ['--with-router', '--with-query', '--merge-lint'],
|
|
4597
|
+
sync: ['--force', '--check', '--tokens-only', '--framework'],
|
|
4598
|
+
'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
|
|
4599
|
+
'check-coverage': [],
|
|
4600
|
+
info: ['--json'],
|
|
4601
|
+
doctor: ['--tokens-only', '--framework'],
|
|
4602
|
+
'guard-hook': [],
|
|
4603
|
+
'tokens:css': [
|
|
4604
|
+
'--out',
|
|
4605
|
+
'--check',
|
|
4606
|
+
'--selector-attribute',
|
|
4607
|
+
'--selector-class',
|
|
4608
|
+
'--light-class',
|
|
4609
|
+
'--dark-value',
|
|
4610
|
+
'--light-value',
|
|
4611
|
+
'--default-scheme',
|
|
4612
|
+
'--media-fallback',
|
|
4613
|
+
'--only',
|
|
4614
|
+
'--no-legacy-aliases',
|
|
4615
|
+
],
|
|
4616
|
+
'fonts:css': ['--out', '--check'],
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
/** Flags whose VALUE is the next argv token — skipped by the gate so `--out dist/x.css` does not
|
|
4620
|
+
* read `dist/x.css` as a second flag. */
|
|
4621
|
+
const VALUE_FLAGS = new Set([
|
|
4622
|
+
'--out',
|
|
4623
|
+
'--selector-attribute',
|
|
4624
|
+
'--selector-class',
|
|
4625
|
+
'--light-class',
|
|
4626
|
+
'--dark-value',
|
|
4627
|
+
'--light-value',
|
|
4628
|
+
'--default-scheme',
|
|
4629
|
+
'--only',
|
|
4630
|
+
])
|
|
4631
|
+
|
|
4632
|
+
/** The first flag `cmd` does not accept, or null. Unknown commands return null — the dispatcher's
|
|
4633
|
+
* default branch already names those. */
|
|
4634
|
+
function unknownFlag(cmd: string, flags: readonly string[]): string | null {
|
|
4635
|
+
const allowed = COMMAND_FLAGS[cmd]
|
|
4636
|
+
if (allowed === undefined) return null
|
|
4637
|
+
for (let i = 0; i < flags.length; i++) {
|
|
4638
|
+
const flag = flags[i] as string
|
|
4639
|
+
if (!flag.startsWith('-')) continue
|
|
4640
|
+
if (!allowed.includes(flag)) return flag
|
|
4641
|
+
if (VALUE_FLAGS.has(flag)) i++
|
|
4642
|
+
}
|
|
4643
|
+
return null
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4220
4646
|
/** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
|
|
4221
4647
|
const USAGE =
|
|
4222
|
-
'Usage: basalt-ui
|
|
4648
|
+
'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
|
|
4223
4649
|
' sync [--force] [--check] [--tokens-only|--framework] |\n' +
|
|
4224
4650
|
' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
|
|
4225
4651
|
' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
|
|
@@ -4250,7 +4676,10 @@ const USAGE =
|
|
|
4250
4676
|
'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
|
|
4251
4677
|
'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
|
|
4252
4678
|
'--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
|
|
4253
|
-
'Every subcommand accepts --help / -h to print this message and exit without running
|
|
4679
|
+
'Every subcommand accepts --help / -h to print this message and exit without running.\n' +
|
|
4680
|
+
'--version / -v prints the resolved package version and exits 0 — the one command that proves\n' +
|
|
4681
|
+
'WHICH basalt-ui ran, which a `bunx` cache can otherwise make a lie. An unknown command or an\n' +
|
|
4682
|
+
'unrecognized flag exits 1 and names what it did not understand; neither is ever ignored.'
|
|
4254
4683
|
|
|
4255
4684
|
/**
|
|
4256
4685
|
* CLI dispatcher — parses argv (subcommand + flags) and returns the command's exit code. The bin
|
|
@@ -4275,6 +4704,24 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4275
4704
|
console.log(USAGE)
|
|
4276
4705
|
return 0
|
|
4277
4706
|
}
|
|
4707
|
+
// Before dispatch, for the same reason --help is: this answers "which basalt-ui is running" and
|
|
4708
|
+
// must never run a command to do it. `bunx` does not re-resolve a cached package, so a consumer
|
|
4709
|
+
// can pin 1.23.0, invoke `bunx basalt-ui`, and be gated by a months-old copy — the version is the
|
|
4710
|
+
// only thing that catches it, and it has to be one bare line an agent can compare.
|
|
4711
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
4712
|
+
console.log(readFrameworkVersion(packageRoot()))
|
|
4713
|
+
return 0
|
|
4714
|
+
}
|
|
4715
|
+
if (cmd !== undefined) {
|
|
4716
|
+
const bad = unknownFlag(cmd, flags)
|
|
4717
|
+
if (bad !== null) {
|
|
4718
|
+
console.error(
|
|
4719
|
+
`basalt-ui ${cmd}: unrecognized flag '${bad}'. ` +
|
|
4720
|
+
`Accepted: ${(COMMAND_FLAGS[cmd] ?? []).join(' ') || '(none)'} --help\n`,
|
|
4721
|
+
)
|
|
4722
|
+
return 1
|
|
4723
|
+
}
|
|
4724
|
+
}
|
|
4278
4725
|
switch (cmd) {
|
|
4279
4726
|
case 'init':
|
|
4280
4727
|
return init(cwd, {
|
|
@@ -4302,6 +4749,14 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4302
4749
|
case 'fonts:css':
|
|
4303
4750
|
return fontsCss(flags, cwd)
|
|
4304
4751
|
default:
|
|
4752
|
+
// Named, not just implied by a usage dump: a full-screen help block with no error line reads
|
|
4753
|
+
// like the command ran and chose to print help. Reported by a consumer who passed
|
|
4754
|
+
// `"check-theme --audit-allows"` as ONE argument and could not tell what had been rejected.
|
|
4755
|
+
console.error(
|
|
4756
|
+
cmd === undefined
|
|
4757
|
+
? 'basalt-ui: no command given.\n'
|
|
4758
|
+
: `basalt-ui: unknown command '${cmd}'.\n`,
|
|
4759
|
+
)
|
|
4305
4760
|
console.error(USAGE)
|
|
4306
4761
|
return 1
|
|
4307
4762
|
}
|