@vegastack/design 0.1.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.
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env node
2
+ // vegastack-design verify — fail-closed registry trust preflight for consumers.
3
+ //
4
+ // Run this BEFORE `shadcn add @vegastack/<name>` to prove the registry item you are
5
+ // about to copy in is the exact artifact VegaStack published — AND run it again
6
+ // (--post-write) AFTER `shadcn add` to prove the files copied onto your disk are
7
+ // byte-faithful to that exact verified item. The post-write pass is what closes the
8
+ // time-of-check/time-of-use (TOCTOU) window: `shadcn add` re-fetches the item from the
9
+ // registry, so a registry compromised/changed BETWEEN the preflight and the copy-in
10
+ // would otherwise be undetected. Comparing the copied files against the bytes we
11
+ // hash/signature-verified — not a re-fetch — slams that window shut.
12
+ //
13
+ // This is the SHIPPED, self-contained verifier delivered as a `bin` from the public
14
+ // `@vegastack/design` package. It has NO repo-relative imports — the canonical item-hash
15
+ // logic is inlined below and is kept byte-for-byte identical to the repo source of truth
16
+ // (tooling/registry-hash.mjs). Consumers have no `tooling/` dir, so they MUST use this.
17
+ //
18
+ // Modes:
19
+ // • full (default) — verify the Sigstore-signed manifest against the pinned GitHub
20
+ // OIDC release identity (via `cosign verify-blob`), THEN recompute
21
+ // and compare the item hash. Aborts on signature OR hash mismatch.
22
+ // Requires the deployed signed manifest + the `cosign` CLI.
23
+ // • --hash-only — SKIP the cosign signature step; ONLY fetch the manifest and verify
24
+ // the item hash against it (and against the item's own meta.integrity).
25
+ // For local dev / environments without cosign or before the signed
26
+ // manifest is deployed. This trusts the manifest's transport; it does
27
+ // NOT prove provenance — use full mode for the real trust boundary.
28
+ // • --post-write — OFFLINE. No network. Re-read a PREVIOUSLY verified item JSON saved
29
+ // by a prior pre-write run (--item) and compare every file in it
30
+ // against the file `shadcn add` actually wrote on disk (located via
31
+ // the item's `target`, rooted at --target-dir). Fail-closed on ANY
32
+ // byte mismatch, modulo the one legitimate transform shadcn applies
33
+ // (import-alias rewriting — see below). This is the TOCTOU closer.
34
+ //
35
+ // Pre-write modes (full / --hash-only) accept --save <path> to persist the EXACT verified
36
+ // item bytes for the post-write pass. Without --save they default to a temp file whose path
37
+ // is printed (and reused as the post-write default), so the fail-closed flow works with zero
38
+ // extra flags.
39
+ //
40
+ // Usage:
41
+ // # 1) pre-write: verify + SAVE the trusted item bytes
42
+ // npx --package=@vegastack/design vegastack-design verify --hash-only --save /tmp/vega-button.json button
43
+ // # 2) shadcn add @vegastack/button (copies files onto disk, rewriting aliases)
44
+ // # 3) post-write: prove the copied files match the SAVED item, fail-closed
45
+ // npx --package=@vegastack/design vegastack-design verify --post-write --item /tmp/vega-button.json --target-dir .
46
+ // vegastack-design verify --help
47
+ //
48
+ // Env:
49
+ // VEGASTACK_REGISTRY registry base URL (default https://design.vegastack.com/r)
50
+ // CF_ACCESS_CLIENT_ID Cloudflare Access service-token id (registry is service-token-only)
51
+ // CF_ACCESS_CLIENT_SECRET Cloudflare Access service-token secret
52
+ // VEGASTACK_SIGNER_REPO OIDC signer repo (default VegaStack/vegastack-design) [full mode]
53
+ // VEGASTACK_SIGNER_REF OIDC signer ref (default refs/heads/main) [full mode]
54
+ import { execFileSync } from 'node:child_process';
55
+ import { writeFileSync, readFileSync } from 'node:fs';
56
+ import { tmpdir } from 'node:os';
57
+ import { join, resolve, basename } from 'node:path';
58
+ import { createHash } from 'node:crypto';
59
+
60
+ // ── canonical item hash (INLINED from tooling/registry-hash.mjs — keep IDENTICAL) ──
61
+ // Covers the WHOLE item (not just file content); meta.integrity is excluded so the hash
62
+ // is self-describing and stable. The provenance header (`// @vegastack <name>@<version>
63
+ // sha256-<sha>`, requirements §157.3) is line 1 of every shipped file — including this item's
64
+ // `files[].content` and the file shadcn copies on disk. Because that header EMBEDS the item's
65
+ // own sha, the hash MUST be computed over the HEADERLESS content (strip the header line + its
66
+ // blank separator); otherwise the embedded sha could never equal the hash. The post-write
67
+ // compare sees the header in BOTH the saved item content and the copied file, so it still
68
+ // matches line-for-line.
69
+ const PROVENANCE_HEADER_RE = /^\/\/ @vegastack \S+@\S+ sha256-\S+\n(?:\n)?/;
70
+
71
+ function stripProvenanceHeader(content) {
72
+ if (typeof content !== 'string') return content;
73
+ return content.replace(PROVENANCE_HEADER_RE, '');
74
+ }
75
+
76
+ function canonical(o) {
77
+ if (Array.isArray(o)) return o.map(canonical);
78
+ if (o && typeof o === 'object') {
79
+ return Object.fromEntries(Object.keys(o).sort().map((k) => [k, canonical(o[k])]));
80
+ }
81
+ return o;
82
+ }
83
+
84
+ function itemHash(item) {
85
+ const { meta = {}, files, ...rest } = item;
86
+ const m = { ...meta };
87
+ delete m.integrity;
88
+ const f = Array.isArray(files)
89
+ ? files.map((file) =>
90
+ file && typeof file === 'object' && 'content' in file
91
+ ? { ...file, content: stripProvenanceHeader(file.content) }
92
+ : file,
93
+ )
94
+ : files;
95
+ return (
96
+ 'sha256-' +
97
+ createHash('sha256')
98
+ .update(JSON.stringify(canonical({ ...rest, ...(f !== undefined ? { files: f } : {}), meta: m })))
99
+ .digest('base64')
100
+ );
101
+ }
102
+ // ───────────────────────────────────────────────────────────────────────────────────
103
+
104
+ // ── post-write file comparison (alias-rewrite-aware, fail-closed) ────────────────────
105
+ //
106
+ // GUARANTEE: comparePostWrite proves the file `shadcn add` wrote on disk is byte-identical
107
+ // to the file embedded in the (already hash+signature-verified) registry item, EXCEPT for
108
+ // the single transform shadcn is allowed to apply on copy-in: import-alias rewriting. Every
109
+ // non-import line must match byte-for-byte. An import (or `export … from`) line may differ
110
+ // ONLY in its module-specifier string, and ONLY when BOTH the original and the on-disk
111
+ // specifier are "aliased" (start with one of the sanctioned alias roots below, i.e. the
112
+ // registry's source aliases or a consumer alias) — that is the exact set of specifiers
113
+ // shadcn is permitted to rewrite. Anything else — a changed import target that is NOT a
114
+ // sanctioned alias rewrite, a changed bare/relative specifier, any altered non-specifier
115
+ // token on an import line, any altered non-import line, a missing file, or a line-count
116
+ // change — is a mismatch and fails closed.
117
+ //
118
+ // LIMIT: this proves byte-faithfulness modulo alias rewriting; it does NOT re-derive the
119
+ // consumer's exact alias map (we don't read their components.json), so a rewrite from one
120
+ // sanctioned alias root to another sanctioned alias root is accepted as legitimate. Tampering
121
+ // that masquerades as an alias rewrite would still have to (a) leave every other byte of every
122
+ // file untouched and (b) only ever change a sanctioned-alias module specifier into another
123
+ // sanctioned-alias module specifier — it cannot inject code, change logic, or alter any
124
+ // non-import line without being caught.
125
+
126
+ // Alias roots shadcn may legitimately rewrite. These are the registry's source aliases
127
+ // (`@/components`, `@/lib`, `@/hooks`, `@/registry`, `@/ui`) and the consumer-side forms
128
+ // they get rewritten to, including package-import (`#…`) and tilde (`~/…`) styles. A
129
+ // specifier is "aliased" if it starts with one of these roots.
130
+ const ALIAS_ROOTS = [
131
+ '@/components',
132
+ '@/lib',
133
+ '@/hooks',
134
+ '@/registry',
135
+ '@/ui',
136
+ '@/utils',
137
+ '#components',
138
+ '#lib',
139
+ '#hooks',
140
+ '#ui',
141
+ '#utils',
142
+ '~/components',
143
+ '~/lib',
144
+ '~/hooks',
145
+ '~/ui',
146
+ '~/utils',
147
+ ];
148
+
149
+ function isAliasedSpecifier(spec) {
150
+ return ALIAS_ROOTS.some((root) => spec === root || spec.startsWith(root + '/'));
151
+ }
152
+
153
+ // Canonicalize an aliased specifier by stripping ONLY the leading alias-style symbol
154
+ // (`@/` ↔ `#` ↔ `~/`). shadcn's alias rewrite changes the root SYMBOL but never the
155
+ // category or the path after it, so two specifiers are a legitimate rewrite of each other
156
+ // IFF their canonical remainders are byte-identical. Without this, `@/components/ui/button`
157
+ // → `@/components/ui/evil` (or any cross-target repoint) would masquerade as a "sanctioned
158
+ // alias rewrite" and pass the TOCTOU verifier — a supply-chain bypass (Codex R8 CRITICAL).
159
+ function aliasCanonical(spec) {
160
+ if (spec.startsWith('@/') || spec.startsWith('~/')) return spec.slice(2);
161
+ if (spec.startsWith('#')) return spec.slice(1);
162
+ return spec;
163
+ }
164
+
165
+ // A change from one specifier to another is a SANCTIONED alias rewrite only when BOTH are
166
+ // aliased AND they canonicalize to the same category+path (only the root symbol differs).
167
+ function isSanctionedAliasRewrite(expSpec, actSpec) {
168
+ return (
169
+ isAliasedSpecifier(expSpec) &&
170
+ isAliasedSpecifier(actSpec) &&
171
+ aliasCanonical(expSpec) === aliasCanonical(actSpec)
172
+ );
173
+ }
174
+
175
+ // Pull the module specifier out of an `import … from '<spec>'`, a side-effect
176
+ // `import '<spec>'`, or an `export … from '<spec>'` line. Returns null if the line is not
177
+ // such a statement. We match the LAST quoted string after a `from`/bare-import keyword so
178
+ // the rest of the line (identifiers, type-modifiers, etc.) is compared literally.
179
+ const IMPORT_FROM_RE = /^(\s*(?:import|export)\b[\s\S]*?\bfrom\s*)(['"])([^'"]*)\2(\s*;?\s*)$/;
180
+ const IMPORT_BARE_RE = /^(\s*import\s*)(['"])([^'"]*)\2(\s*;?\s*)$/;
181
+ // MULTILINE import/export: the CLOSING line `} from '<spec>';` (the named bindings span the prior
182
+ // lines — which shadcn never rewrites and so compare literally — while ONLY this closing line carries
183
+ // the rewritten module specifier). Without this, the rewritten specifier on a multiline import's
184
+ // closing line is misread as a non-import mutation and a legitimate alias rewrite fails the verifier.
185
+ const IMPORT_CLOSE_RE = /^(\s*}\s*from\s*)(['"])([^'"]*)\2(\s*;?\s*)$/;
186
+
187
+ function parseImportLine(line) {
188
+ let m = IMPORT_FROM_RE.exec(line);
189
+ if (m) return { head: m[1], quote: m[2], spec: m[3], tail: m[4] };
190
+ m = IMPORT_BARE_RE.exec(line);
191
+ if (m) return { head: m[1], quote: m[2], spec: m[3], tail: m[4] };
192
+ m = IMPORT_CLOSE_RE.exec(line);
193
+ if (m) return { head: m[1], quote: m[2], spec: m[3], tail: m[4] };
194
+ return null;
195
+ }
196
+
197
+ // Compare one expected (registry) file against the on-disk copied file. Returns an array
198
+ // of human-readable mismatch strings (empty array = byte-faithful modulo alias rewrite).
199
+ function compareFile(expectedContent, actualContent, label) {
200
+ const problems = [];
201
+ // Normalize nothing — we compare raw. shadcn writes content verbatim except for alias
202
+ // rewriting, so a trailing-newline / CRLF difference IS a real difference worth flagging.
203
+ const expLines = expectedContent.split('\n');
204
+ const actLines = actualContent.split('\n');
205
+ if (expLines.length !== actLines.length) {
206
+ problems.push(
207
+ `${label}: line count differs (item has ${expLines.length}, on-disk has ${actLines.length}) — not an alias rewrite`,
208
+ );
209
+ return problems;
210
+ }
211
+ for (let i = 0; i < expLines.length; i++) {
212
+ const exp = expLines[i];
213
+ const act = actLines[i];
214
+ if (exp === act) continue;
215
+
216
+ const expImp = parseImportLine(exp);
217
+ const actImp = parseImportLine(act);
218
+ if (expImp && actImp) {
219
+ // Both are import lines. The ONLY sanctioned difference is the module specifier, and
220
+ // only when both sides are aliased specifiers. Head/tail (everything except the
221
+ // specifier) must still match byte-for-byte.
222
+ if (expImp.head === actImp.head && expImp.quote === actImp.quote && expImp.tail === actImp.tail) {
223
+ if (expImp.spec === actImp.spec) {
224
+ // identical specifier but lines differed elsewhere — impossible given head/tail
225
+ // match, but guard anyway.
226
+ problems.push(`${label}:${i + 1}: import line differs in an unexpected way`);
227
+ } else if (isSanctionedAliasRewrite(expImp.spec, actImp.spec)) {
228
+ // sanctioned alias rewrite — ONLY the alias root symbol differs; category + path
229
+ // are byte-identical. A repoint to a DIFFERENT target falls through to the error.
230
+ continue;
231
+ } else {
232
+ problems.push(
233
+ `${label}:${i + 1}: import target changed and is NOT a sanctioned alias rewrite\n` +
234
+ ` item: from ${expImp.quote}${expImp.spec}${expImp.quote}\n` +
235
+ ` on-disk: from ${actImp.quote}${actImp.spec}${actImp.quote}`,
236
+ );
237
+ }
238
+ } else {
239
+ problems.push(
240
+ `${label}:${i + 1}: import line altered beyond its module specifier (head/tail mismatch)\n` +
241
+ ` item: ${exp}\n` +
242
+ ` on-disk: ${act}`,
243
+ );
244
+ }
245
+ } else {
246
+ // At least one side is not an import line → a non-import line changed. Never allowed.
247
+ problems.push(
248
+ `${label}:${i + 1}: non-import line changed (no transform is permitted here)\n` +
249
+ ` item: ${exp}\n` +
250
+ ` on-disk: ${act}`,
251
+ );
252
+ }
253
+ }
254
+ return problems;
255
+ }
256
+
257
+ // Resolve the on-disk path shadcn wrote a file to. `target` may be a shadcn placeholder
258
+ // (`@ui/button.tsx`, `@components/…`, `@lib/…`, `@hooks/…`, `@utils/…`) that resolves to the
259
+ // CONSUMER's configured directory, an `@/…`/`~/…`/`#…` alias-prefixed path, or a legacy relative
260
+ // path. When the consumer's `components.json` aliases are provided, a placeholder resolves to the
261
+ // real configured dir (so a `src/components/ui` layout is honored — Codex R12); otherwise we fall
262
+ // back to stripping the leading segment. Falls back to `path` when `target` is absent. Rooted at
263
+ // targetDir.
264
+ function resolveTargetPath(file, targetDir, aliases = {}) {
265
+ let t = file.target ?? file.path;
266
+ // shadcn placeholder `@<name>/<rest>` (name is a components.json alias key: ui/components/lib/
267
+ // hooks/utils). Resolve to that alias's on-disk dir when known.
268
+ const ph = /^@([a-z]+)\/(.+)$/.exec(t);
269
+ if (ph && aliases[ph[1]]) {
270
+ // alias value e.g. "@/components/ui" | "~/src/components/ui" | "src/components/ui" — strip a
271
+ // leading import prefix (@/, ~/, #) to get the targetDir-relative directory.
272
+ const dir = aliases[ph[1]].replace(/^[@~#]\/?/, '');
273
+ return resolve(targetDir, dir, ph[2]);
274
+ }
275
+ // No alias map (or unknown placeholder): strip a leading alias prefix to its tail.
276
+ if (t.startsWith('@/') || t.startsWith('#') || t.startsWith('~/')) {
277
+ // e.g. `@/components/ui/button.tsx` → `components/ui/button.tsx`
278
+ t = t.replace(/^[@#~]\/?/, '');
279
+ } else if (t.startsWith('@')) {
280
+ // e.g. `@ui/button.tsx` → `ui/button.tsx`
281
+ t = t.replace(/^@/, '');
282
+ }
283
+ return resolve(targetDir, t);
284
+ }
285
+
286
+ // Read a consumer's components.json `aliases` map (for placeholder target resolution). Returns
287
+ // {} when absent/unreadable — resolveTargetPath then uses its strip-leading-segment fallback.
288
+ function readConsumerAliases(targetDir) {
289
+ try {
290
+ const cj = JSON.parse(readFileSync(resolve(targetDir, 'components.json'), 'utf8'));
291
+ return cj.aliases ?? {};
292
+ } catch {
293
+ return {};
294
+ }
295
+ }
296
+
297
+ export { canonical, itemHash, stripProvenanceHeader, compareFile, isAliasedSpecifier, aliasCanonical, isSanctionedAliasRewrite, parseImportLine, resolveTargetPath, readConsumerAliases };
298
+ // ───────────────────────────────────────────────────────────────────────────────────
299
+
300
+ const USAGE = `vegastack-design verify — fail-closed registry trust preflight + post-write check
301
+
302
+ Usage:
303
+ vegastack-design verify [--hash-only] [--save <path>] <name> (pre-write)
304
+ vegastack-design verify --post-write --item <path> --target-dir <dir>
305
+ vegastack-design verify --help
306
+
307
+ Modes:
308
+ full (default) Pre-write. Sigstore signature (cosign) + item hash. Needs the deployed
309
+ signed manifest and the cosign CLI. This is the real trust boundary.
310
+ --hash-only Pre-write. Item hash only (skips cosign). For local dev / no cosign /
311
+ before the signed manifest is deployed. Does NOT prove provenance.
312
+ --post-write OFFLINE. Compare the files shadcn copied on disk against a SAVED item.
313
+ Closes the check->copy (TOCTOU) gap. Fail-closed on any byte mismatch
314
+ except sanctioned import-alias rewriting.
315
+
316
+ Pre-write flags:
317
+ --save <path> Persist the EXACT verified item JSON to <path> for the post-write pass.
318
+ Default: a temp file (path is printed) — pass it to --post-write --item.
319
+
320
+ Post-write flags:
321
+ --item <path> The item JSON saved by a prior pre-write run (the bytes we verified).
322
+ --target-dir <d> Project root the files were copied into (resolves each file's target).
323
+
324
+ The fail-closed flow that closes the TOCTOU window:
325
+ 1) pre-write verify (full or --hash-only) WITH --save → saves the trusted item bytes
326
+ 2) shadcn add @vegastack/<name> → copies files, rewrites aliases
327
+ 3) --post-write --item <saved> --target-dir <root> → proves copies match the saved item
328
+
329
+ Env:
330
+ VEGASTACK_REGISTRY registry base URL (default https://design.vegastack.com/r)
331
+ CF_ACCESS_CLIENT_ID Cloudflare Access service-token id
332
+ CF_ACCESS_CLIENT_SECRET Cloudflare Access service-token secret
333
+ VEGASTACK_SIGNER_REPO OIDC signer repo (default VegaStack/vegastack-design) [full]
334
+ VEGASTACK_SIGNER_REF OIDC signer ref (default refs/heads/main) [full]`;
335
+
336
+ // small flag-value reader: `--flag value`
337
+ function flagValue(args, name) {
338
+ const i = args.indexOf(name);
339
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
340
+ }
341
+
342
+ // Only run the CLI when invoked directly (so tests/imports don't trigger fetches).
343
+ if (import.meta.url === `file://${process.argv[1]}`) {
344
+ const args = process.argv.slice(2);
345
+
346
+ // Deprecation notice when invoked under the legacy bin name (not via the `vegastack-design`
347
+ // dispatcher, which sets VEGASTACK_DESIGN_DISPATCH). The alias still works for one more minor.
348
+ if (
349
+ basename(process.argv[1] ?? '') === 'verify-registry-item.mjs' &&
350
+ !process.env.VEGASTACK_DESIGN_DISPATCH &&
351
+ process.stderr.isTTY // interactive humans only — don't spam CI / piped / internal-tooling runs
352
+ ) {
353
+ console.error('[deprecated] `vegastack-verify-registry-item` is now `vegastack-design verify` — please switch.');
354
+ }
355
+ if (args.includes('--help') || args.includes('-h')) {
356
+ console.log(USAGE);
357
+ process.exit(0);
358
+ }
359
+
360
+ // ── POST-WRITE mode (offline, the TOCTOU closer) ──────────────────────────────────
361
+ if (args.includes('--post-write')) {
362
+ const itemPath = flagValue(args, '--item');
363
+ const targetDir = flagValue(args, '--target-dir') ?? '.';
364
+ if (!itemPath) {
365
+ console.error('--post-write requires --item <path-to-saved-item.json>');
366
+ console.error(USAGE);
367
+ process.exit(2);
368
+ }
369
+
370
+ let item;
371
+ try {
372
+ item = JSON.parse(readFileSync(itemPath, 'utf8'));
373
+ } catch (err) {
374
+ console.error(`cannot read saved item ${itemPath}: ${err.message}`);
375
+ process.exit(2);
376
+ }
377
+
378
+ const files = Array.isArray(item.files) ? item.files : [];
379
+ if (files.length === 0) {
380
+ console.error(`saved item ${itemPath} has no files[] to verify`);
381
+ process.exit(2);
382
+ }
383
+
384
+ const allProblems = [];
385
+ let checked = 0;
386
+ const aliases = readConsumerAliases(targetDir); // resolve @ui/ etc. to the consumer's real layout
387
+ for (const file of files) {
388
+ const onDisk = resolveTargetPath(file, targetDir, aliases);
389
+ const label = file.target ?? file.path;
390
+ let actual;
391
+ try {
392
+ actual = readFileSync(onDisk, 'utf8');
393
+ } catch {
394
+ allProblems.push(`${label}: expected copied file missing at ${onDisk}`);
395
+ continue;
396
+ }
397
+ const problems = compareFile(file.content ?? '', actual, label);
398
+ if (problems.length) allProblems.push(...problems);
399
+ checked++;
400
+ }
401
+
402
+ if (allProblems.length) {
403
+ console.error(`post-write verification FAILED for ${item.name ?? itemPath}:`);
404
+ for (const p of allProblems) console.error(` ✗ ${p}`);
405
+ console.error(
406
+ '\nThe copied files do NOT match the verified item (beyond sanctioned alias rewriting).' +
407
+ '\nThis is exactly the TOCTOU signal: treat the copy-in as untrusted.',
408
+ );
409
+ process.exit(1);
410
+ }
411
+ console.log(
412
+ `post-write OK: ${checked} file(s) of ${item.name ?? itemPath} are byte-faithful to the saved item (modulo alias rewriting)`,
413
+ );
414
+ process.exit(0);
415
+ }
416
+
417
+ // ── PRE-WRITE modes (full / --hash-only) ──────────────────────────────────────────
418
+ const hashOnly = args.includes('--hash-only');
419
+ const savePath = flagValue(args, '--save');
420
+ // positional name = first non-flag that isn't a flag value we consumed
421
+ const consumed = new Set([savePath].filter(Boolean));
422
+ const name = args.filter((a) => !a.startsWith('-') && !consumed.has(a))[0];
423
+ if (!name) {
424
+ console.error(USAGE);
425
+ process.exit(2);
426
+ }
427
+
428
+ const base = process.env.VEGASTACK_REGISTRY ?? 'https://design.vegastack.com/r';
429
+ const headers = {
430
+ 'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID,
431
+ 'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET,
432
+ };
433
+
434
+ const manifestPath = join(tmpdir(), 'vega-manifest.json');
435
+
436
+ if (hashOnly) {
437
+ // hash-only: fetch the manifest over the transport, skip the signature.
438
+ const mRes = await fetch(`${base}/integrity-manifest.json`, { headers });
439
+ writeFileSync(manifestPath, await mRes.text());
440
+ console.warn('[hash-only] skipping Sigstore signature verification — provenance NOT proven');
441
+ } else {
442
+ // full: fetch manifest + signature bundle, then verify the signature against the
443
+ // EXACT release identity before trusting the manifest.
444
+ const sigPath = join(tmpdir(), 'vega-manifest.sigstore');
445
+ const [mRes, bRes] = await Promise.all([
446
+ fetch(`${base}/integrity-manifest.json`, { headers }),
447
+ fetch(`${base}/integrity-manifest.sigstore`, { headers }),
448
+ ]);
449
+ writeFileSync(manifestPath, await mRes.text());
450
+ writeFileSync(sigPath, await bRes.text());
451
+
452
+ // Pin the precise signer (the deploy workflow at the trusted ref) + repo, NOT a
453
+ // repo-prefix regexp — a broad prefix would accept a manifest signed by ANY
454
+ // workflow/ref in the repo that can obtain GitHub OIDC, defeating the registry
455
+ // trust boundary. Override the ref (e.g. a tag) via env for tagged releases.
456
+ const SIGNER_REPO = process.env.VEGASTACK_SIGNER_REPO ?? 'VegaStack/vegastack-design';
457
+ const SIGNER_REF = process.env.VEGASTACK_SIGNER_REF ?? 'refs/heads/main';
458
+ const SIGNER_IDENTITY = `https://github.com/${SIGNER_REPO}/.github/workflows/deploy.yml@${SIGNER_REF}`;
459
+ execFileSync(
460
+ 'cosign',
461
+ [
462
+ 'verify-blob',
463
+ '--bundle', sigPath,
464
+ '--certificate-identity', SIGNER_IDENTITY,
465
+ '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
466
+ '--certificate-github-workflow-repository', SIGNER_REPO,
467
+ '--certificate-github-workflow-ref', SIGNER_REF,
468
+ manifestPath,
469
+ ],
470
+ { stdio: 'inherit' },
471
+ );
472
+ }
473
+
474
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
475
+ // Capture the EXACT bytes we fetch+verify. We parse from this same text so the saved item
476
+ // is byte-for-byte what the hash was computed over — the post-write pass compares against
477
+ // THESE bytes, not a re-fetch. That is what closes the TOCTOU window.
478
+ const itemText = await fetch(`${base}/${name}.json`, { headers }).then((r) => r.text());
479
+ const item = JSON.parse(itemText);
480
+ const got = itemHash(item);
481
+ if (got !== item.meta?.integrity || got !== manifest[name]) {
482
+ console.error(`integrity mismatch for ${name}`);
483
+ process.exit(1);
484
+ }
485
+
486
+ // Persist the verified item so --post-write can compare copies against THESE exact bytes.
487
+ const outPath = savePath ?? join(tmpdir(), `vega-item-${name}.json`);
488
+ writeFileSync(outPath, itemText);
489
+
490
+ console.log(`verified ${name}${hashOnly ? ' (hash-only)' : ''}`);
491
+ console.log(`saved verified item → ${outPath}`);
492
+ console.log(
493
+ `next: run \`shadcn add @vegastack/${name}\`, then close the TOCTOU gap with:\n` +
494
+ ` vegastack-design verify --post-write --item ${outPath} --target-dir .`,
495
+ );
496
+ }
package/css/base.css ADDED
@@ -0,0 +1,2 @@
1
+ /* Re-export of @vegastack/design-tokens/base.css — see theme.css for rationale. */
2
+ @import '@vegastack/design-tokens/base.css';
package/css/theme.css ADDED
@@ -0,0 +1,4 @@
1
+ /* Re-export: token values live in @vegastack/design-tokens (a direct dependency of this package),
2
+ * so pnpm-strict consumers can import "@vegastack/design/theme.css" without declaring the
3
+ * transitive tokens package themselves. Tokens-only consumers install @vegastack/design-tokens. */
4
+ @import '@vegastack/design-tokens/theme.css';
@@ -0,0 +1,2 @@
1
+ /* Re-export of @vegastack/design-tokens/utilities.css — see theme.css for rationale. */
2
+ @import '@vegastack/design-tokens/utilities.css';
@@ -0,0 +1,52 @@
1
+ // src/index.ts
2
+ import { clsx } from "clsx";
3
+ import { extendTailwindMerge } from "tailwind-merge";
4
+ var twMerge = extendTailwindMerge({
5
+ extend: {
6
+ classGroups: {
7
+ "font-size": [
8
+ {
9
+ text: [
10
+ "h1",
11
+ "h2",
12
+ "h3",
13
+ "h4",
14
+ "label",
15
+ "label-sm",
16
+ "code",
17
+ "code-sm",
18
+ "mono-label",
19
+ "display-sm",
20
+ "display-md",
21
+ "display-lg",
22
+ "display-xl"
23
+ ]
24
+ }
25
+ ]
26
+ }
27
+ }
28
+ });
29
+ function cn(...inputs) {
30
+ return twMerge(clsx(inputs));
31
+ }
32
+ var TIMINGS = {
33
+ /** How long transient success feedback holds before reverting (CopyButton "Copied ✓"). */
34
+ feedbackRevertMs: 1500,
35
+ /** Debounce before auto-persisting a text field (AutoSaveInput). */
36
+ autoSaveDebounceMs: 800,
37
+ /** Hover delay before a rich preview (HoverCard) opens — guards accidental opens. */
38
+ hoverOpenDelayMs: 700,
39
+ /** Hover delay before a rich preview closes — lets the pointer travel into the card. */
40
+ hoverCloseDelayMs: 300
41
+ };
42
+ var FLOATING = {
43
+ sideOffsetAttached: 4,
44
+ sideOffsetDetached: 8,
45
+ collisionPadding: 8
46
+ };
47
+
48
+ export {
49
+ cn,
50
+ TIMINGS,
51
+ FLOATING
52
+ };