@vegastack/design 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,33 +29,47 @@
29
29
  // by a prior pre-write run (--item) and compare every file in it
30
30
  // against the file `shadcn add` actually wrote on disk (located via
31
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.
32
+ // byte mismatch, modulo the narrowly modeled transforms shadcn applies
33
+ // (leading-comment removal + configured alias rewriting — see below).
34
+ // This is the TOCTOU closer.
34
35
  //
35
36
  // 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.
37
+ // item bytes for the post-write pass. Without --save they default to a private temp file. The
38
+ // printed post-write command includes an independently retained expected integrity so a local
39
+ // replacement of the saved item cannot be authenticated by changing its self-hash too.
39
40
  //
40
41
  // Usage:
41
42
  // # 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 .
43
+ // VERIFY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vegastack-verify.XXXXXX")"
44
+ // SAVE_PATH="$VERIFY_DIR/button.json" # must not already exist
45
+ // npx --package=@vegastack/design vegastack-design verify --hash-only --save "$SAVE_PATH" button
46
+ // # 2) retain the printed expected integrity; shadcn add @vegastack/button
47
+ // # 3) run the exact integrity-pinned post-write command printed by step 1
46
48
  // vegastack-design verify --help
47
49
  //
48
50
  // Env:
49
51
  // VEGASTACK_REGISTRY registry base URL (default https://design.vegastack.com/r)
50
52
  // CF_ACCESS_CLIENT_ID Cloudflare Access service-token id (registry is service-token-only)
51
53
  // CF_ACCESS_CLIENT_SECRET Cloudflare Access service-token secret
52
- // VEGASTACK_SIGNER_REPO OIDC signer repo (default VegaStack/vegastack-design) [full mode]
54
+ // VEGASTACK_SIGNER_REPO OIDC signer repo (default vegastack/vegastack-design) [full mode]
53
55
  // 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';
56
+ import { execFileSync } from "node:child_process";
57
+ import {
58
+ closeSync,
59
+ lstatSync,
60
+ mkdtempSync,
61
+ openSync,
62
+ readFileSync,
63
+ realpathSync,
64
+ writeFileSync,
65
+ } from "node:fs";
66
+ import { tmpdir } from "node:os";
67
+ import { pathToFileURL } from "node:url";
68
+ import { join, resolve, basename, relative, isAbsolute, sep } from "node:path";
69
+ import { createHash } from "node:crypto";
70
+ import tsconfigPaths from "tsconfig-paths";
71
+
72
+ const { loadConfig: loadTsconfigPaths } = tsconfigPaths;
59
73
 
60
74
  // ── canonical item hash (INLINED from tooling/registry-hash.mjs — keep IDENTICAL) ──
61
75
  // Covers the WHOLE item (not just file content); meta.integrity is excluded so the hash
@@ -69,14 +83,18 @@ import { createHash } from 'node:crypto';
69
83
  const PROVENANCE_HEADER_RE = /^\/\/ @vegastack \S+@\S+ sha256-\S+\n(?:\n)?/;
70
84
 
71
85
  function stripProvenanceHeader(content) {
72
- if (typeof content !== 'string') return content;
73
- return content.replace(PROVENANCE_HEADER_RE, '');
86
+ if (typeof content !== "string") return content;
87
+ return content.replace(PROVENANCE_HEADER_RE, "");
74
88
  }
75
89
 
76
90
  function canonical(o) {
77
91
  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])]));
92
+ if (o && typeof o === "object") {
93
+ return Object.fromEntries(
94
+ Object.keys(o)
95
+ .sort()
96
+ .map((k) => [k, canonical(o[k])]),
97
+ );
80
98
  }
81
99
  return o;
82
100
  }
@@ -87,88 +105,279 @@ function itemHash(item) {
87
105
  delete m.integrity;
88
106
  const f = Array.isArray(files)
89
107
  ? files.map((file) =>
90
- file && typeof file === 'object' && 'content' in file
108
+ file && typeof file === "object" && "content" in file
91
109
  ? { ...file, content: stripProvenanceHeader(file.content) }
92
110
  : file,
93
111
  )
94
112
  : files;
95
113
  return (
96
- 'sha256-' +
97
- createHash('sha256')
98
- .update(JSON.stringify(canonical({ ...rest, ...(f !== undefined ? { files: f } : {}), meta: m })))
99
- .digest('base64')
114
+ "sha256-" +
115
+ createHash("sha256")
116
+ .update(
117
+ JSON.stringify(
118
+ canonical({
119
+ ...rest,
120
+ ...(f !== undefined ? { files: f } : {}),
121
+ meta: m,
122
+ }),
123
+ ),
124
+ )
125
+ .digest("base64")
100
126
  );
101
127
  }
102
128
  // ───────────────────────────────────────────────────────────────────────────────────
103
129
 
130
+ const DEFAULT_REGISTRY = "https://design.vegastack.com/r";
131
+ const DEFAULT_TRUSTED_REGISTRY_ORIGIN = new URL(DEFAULT_REGISTRY).origin;
132
+ const REQUEST_TIMEOUT_MS = 15_000;
133
+ const ITEM_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
134
+ const INTEGRITY_RE = /^sha256-[A-Za-z0-9+/]{43}=$/;
135
+
136
+ function assertItemName(name) {
137
+ if (!ITEM_NAME_RE.test(name)) {
138
+ throw new Error(`invalid registry item name: ${name}`);
139
+ }
140
+ return name;
141
+ }
142
+
143
+ function requestHeaders() {
144
+ return Object.fromEntries(
145
+ Object.entries({
146
+ "CF-Access-Client-Id": process.env.CF_ACCESS_CLIENT_ID,
147
+ "CF-Access-Client-Secret": process.env.CF_ACCESS_CLIENT_SECRET,
148
+ }).filter(([, value]) => value != null && value !== ""),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Credential material that must never appear in a request URL or in printed output. Kept in step
154
+ * with the same defense in check-updates.mjs: the trusted-origin check below is scoped to requests
155
+ * that carry credential HEADERS, so a URL carrying the token in a query string bypassed it entirely
156
+ * and was fetched — and then echoed into logs. Credentials belong in headers; a URL is recorded in
157
+ * server access logs, proxy logs and CDN caches even when the origin is fully trusted.
158
+ *
159
+ * This CLI takes its base URL from VEGASTACK_REGISTRY (process env only, never components.json),
160
+ * so the bar is higher here than in check-updates — but the two published entrypoints must not
161
+ * diverge on a security boundary.
162
+ */
163
+ const MIN_SECRET_LENGTH = 8;
164
+ function secretValues() {
165
+ const out = new Map();
166
+ for (const name of ["CF_ACCESS_CLIENT_SECRET", "CF_ACCESS_CLIENT_ID"]) {
167
+ const v = process.env[name];
168
+ if (typeof v === "string" && v.length >= MIN_SECRET_LENGTH)
169
+ out.set(v, `\${${name}}`);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ function redact(text) {
175
+ let out = String(text);
176
+ for (const [secret, placeholder] of secretValues()) {
177
+ out = out.split(secret).join(placeholder);
178
+ const encoded = encodeURIComponent(secret);
179
+ if (encoded !== secret) out = out.split(encoded).join(placeholder);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ function assertNoSecretInUrl(url) {
185
+ const text = String(url);
186
+ for (const [secret, placeholder] of secretValues()) {
187
+ const encoded = encodeURIComponent(secret);
188
+ if (
189
+ text.includes(secret) ||
190
+ (encoded !== secret && text.includes(encoded))
191
+ ) {
192
+ throw new Error(
193
+ `refusing to put ${placeholder} in a registry URL — credentials must be sent as headers, ` +
194
+ `never in a URL (URLs are recorded in access, proxy, and CDN logs). ` +
195
+ `Remove it from VEGASTACK_REGISTRY.`,
196
+ );
197
+ }
198
+ }
199
+ }
200
+
201
+ function assertRegistryRequest(url, headers) {
202
+ let parsed;
203
+ try {
204
+ parsed = new URL(url);
205
+ } catch {
206
+ throw new Error(`registry URL is invalid: ${redact(url)}`);
207
+ }
208
+ if (parsed.username || parsed.password) {
209
+ throw new Error("registry URL must not contain embedded credentials");
210
+ }
211
+ // Unconditional, and BEFORE the header-scoped origin check: a URL-borne secret leaks even to a
212
+ // fully trusted origin, and an uncredentialed request skips everything below.
213
+ assertNoSecretInUrl(url);
214
+ if (Object.keys(headers).length === 0) return parsed;
215
+
216
+ const trustedValue =
217
+ process.env.VEGASTACK_TRUSTED_REGISTRY_ORIGIN ??
218
+ DEFAULT_TRUSTED_REGISTRY_ORIGIN;
219
+ let trusted;
220
+ try {
221
+ trusted = new URL(trustedValue);
222
+ } catch {
223
+ throw new Error(
224
+ "VEGASTACK_TRUSTED_REGISTRY_ORIGIN must be an absolute HTTPS origin",
225
+ );
226
+ }
227
+ if (
228
+ trusted.protocol !== "https:" ||
229
+ trusted.username ||
230
+ trusted.password ||
231
+ trusted.pathname !== "/" ||
232
+ trusted.search ||
233
+ trusted.hash
234
+ ) {
235
+ throw new Error(
236
+ "VEGASTACK_TRUSTED_REGISTRY_ORIGIN must be an HTTPS origin with no path, credentials, query, or hash",
237
+ );
238
+ }
239
+ if (parsed.protocol !== "https:" || parsed.origin !== trusted.origin) {
240
+ throw new Error(
241
+ `refusing to send registry credentials to ${parsed.origin}; trusted origin is ${trusted.origin}`,
242
+ );
243
+ }
244
+ return parsed;
245
+ }
246
+
247
+ async function fetchRegistryText(url, headers, label) {
248
+ assertRegistryRequest(url, headers);
249
+ let response;
250
+ try {
251
+ response = await fetch(url, {
252
+ headers,
253
+ redirect: "error",
254
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
255
+ });
256
+ } catch (error) {
257
+ throw new RegistryAccessError(`${label} request failed: ${error.message}`);
258
+ }
259
+ if (!response.ok) {
260
+ throw new RegistryAccessError(
261
+ `${label} fetch failed: HTTP ${response.status} ${response.statusText}`,
262
+ );
263
+ }
264
+ return response.text();
265
+ }
266
+
267
+ class RegistryAccessError extends Error {}
268
+
269
+ function assertPathInside(root, candidate, label = "registry target") {
270
+ const rootPath = resolve(root);
271
+ const candidatePath = resolve(candidate);
272
+ const rel = relative(rootPath, candidatePath);
273
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
274
+ throw new Error(`${label} escapes the consumer root: ${candidate}`);
275
+ }
276
+ return candidatePath;
277
+ }
278
+
279
+ function assertExistingPathInside(root, candidate, label = "registry target") {
280
+ const lexicalRoot = resolve(root);
281
+ const lexicalCandidate = assertPathInside(lexicalRoot, candidate, label);
282
+ const rel = relative(lexicalRoot, lexicalCandidate);
283
+ let cursor = lexicalRoot;
284
+ for (const segment of rel.split(sep)) {
285
+ cursor = resolve(cursor, segment);
286
+ if (lstatSync(cursor).isSymbolicLink()) {
287
+ throw new Error(`${label} traverses a symlink: ${cursor}`);
288
+ }
289
+ }
290
+ return assertPathInside(
291
+ realpathSync(lexicalRoot),
292
+ realpathSync(lexicalCandidate),
293
+ label,
294
+ );
295
+ }
296
+
297
+ function writeNewPrivateFile(filePath, contents) {
298
+ // Exclusive creation refuses an existing file or final-component symlink. A normal write to a
299
+ // predictable path in a shared temp directory could otherwise follow an attacker-created link.
300
+ let descriptor;
301
+ try {
302
+ descriptor = openSync(filePath, "wx", 0o600);
303
+ writeFileSync(descriptor, contents);
304
+ } finally {
305
+ if (descriptor !== undefined) closeSync(descriptor);
306
+ }
307
+ }
308
+
309
+ // Single chokepoint for printed output: control-character scrub AND secret redaction, so no
310
+ // future call site can reintroduce a credential leak by forgetting to wrap its argument.
311
+ function terminalText(value) {
312
+ return redact(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, "\uFFFD");
313
+ }
314
+
315
+ function shellArg(value) {
316
+ return `'${terminalText(value).replaceAll("'", "'\\''")}'`;
317
+ }
318
+
104
319
  // ── post-write file comparison (alias-rewrite-aware, fail-closed) ────────────────────
105
320
  //
106
321
  // GUARANTEE: comparePostWrite proves the file `shadcn add` wrote on disk is byte-identical
107
322
  // 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',
323
+ // the two transforms shadcn is allowed to apply on JS/TS copy-in: removing the contiguous leading
324
+ // comment/whitespace prologue for transformed file types and rewriting canonical imports to the
325
+ // exact aliases in the consumer's components.json. Every remaining non-import line must match
326
+ // byte-for-byte. An import (or
327
+ // `export … from`) line may differ only in its module specifier and only when the result equals
328
+ // the components.json-derived rewrite. Anything else — an arbitrary alternate alias root, a
329
+ // repointed import, changed bindings, a changed bare/relative specifier, any altered non-import
330
+ // line, a missing file, or a line-count change after the allowed prologue — is a mismatch and fails
331
+ // closed. Raw registry:file entries do not receive the comment transform.
332
+
333
+ // shadcn rewrites the registry's canonical aliases to the EXACT aliases declared by the consumer.
334
+ // Longest prefixes win (`@/components/ui` before `@/components`, `@/lib/utils` before `@/lib`).
335
+ // Accepting an arbitrary `@/` → `~/` symbol swap is unsafe: those symbols may resolve to different
336
+ // trees in the consumer's tsconfig. The local components.json is therefore the transformation spec.
337
+ const REGISTRY_ALIAS_RULES = [
338
+ ["@/components/ui", "ui"],
339
+ ["@/lib/utils", "utils"],
340
+ ["@/components", "components"],
341
+ ["@/registry", "registry"],
342
+ ["@/hooks", "hooks"],
343
+ ["@/utils", "utils"],
344
+ ["@/lib", "lib"],
345
+ ["@/ui", "ui"],
147
346
  ];
148
347
 
149
- function isAliasedSpecifier(spec) {
150
- return ALIAS_ROOTS.some((root) => spec === root || spec.startsWith(root + '/'));
348
+ function rewriteRegistrySpecifier(specifier, aliases = {}) {
349
+ for (const [canonicalPrefix, aliasName] of REGISTRY_ALIAS_RULES) {
350
+ if (
351
+ specifier !== canonicalPrefix &&
352
+ !specifier.startsWith(`${canonicalPrefix}/`)
353
+ )
354
+ continue;
355
+ const configured = aliases[aliasName];
356
+ if (typeof configured !== "string" || configured.length === 0)
357
+ return specifier;
358
+ return (
359
+ configured.replace(/\/$/, "") + specifier.slice(canonicalPrefix.length)
360
+ );
361
+ }
362
+ return specifier;
151
363
  }
152
364
 
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;
365
+ function isSanctionedAliasRewrite(expSpec, actSpec, aliases = {}) {
366
+ return (
367
+ expSpec !== actSpec &&
368
+ rewriteRegistrySpecifier(expSpec, aliases) === actSpec
369
+ );
163
370
  }
164
371
 
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)
372
+ const MODULE_SPECIFIER_RE =
373
+ /((?:\bfrom\s*|^\s*import\s*|^\s*}\s*from\s*|import\(\s*|require\(\s*))(['"])(@\/[^'"]*)\2/gm;
374
+ function rewriteRegistryAliases(content, aliases = {}) {
375
+ return content.replace(
376
+ MODULE_SPECIFIER_RE,
377
+ (match, head, quote, specifier) => {
378
+ const rewritten = rewriteRegistrySpecifier(specifier, aliases);
379
+ return `${head}${quote}${rewritten}${quote}`;
380
+ },
172
381
  );
173
382
  }
174
383
 
@@ -176,7 +385,8 @@ function isSanctionedAliasRewrite(expSpec, actSpec) {
176
385
  // `import '<spec>'`, or an `export … from '<spec>'` line. Returns null if the line is not
177
386
  // such a statement. We match the LAST quoted string after a `from`/bare-import keyword so
178
387
  // 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*)$/;
388
+ const IMPORT_FROM_RE =
389
+ /^(\s*(?:import|export)\b[\s\S]*?\bfrom\s*)(['"])([^'"]*)\2(\s*;?\s*)$/;
180
390
  const IMPORT_BARE_RE = /^(\s*import\s*)(['"])([^'"]*)\2(\s*;?\s*)$/;
181
391
  // MULTILINE import/export: the CLOSING line `} from '<spec>';` (the named bindings span the prior
182
392
  // lines — which shadcn never rewrites and so compare literally — while ONLY this closing line carries
@@ -194,17 +404,77 @@ function parseImportLine(line) {
194
404
  return null;
195
405
  }
196
406
 
407
+ const JAVASCRIPT_LIKE_RE = /\.[cm]?[jt]sx?$/i;
408
+ const SHADCN_TRANSFORMED_FILE_TYPES = new Set([
409
+ "registry:ui",
410
+ "registry:hook",
411
+ "registry:page",
412
+ "registry:component",
413
+ ]);
414
+
415
+ /**
416
+ * Model the real shadcn transform narrowly: for JavaScript/TypeScript-family files only, it removes
417
+ * the complete leading trivia prologue when that prologue contains at least one line or block
418
+ * comment. Stop at the first non-trivia byte. An unterminated block comment is not sanctioned.
419
+ */
420
+ function stripShadcnLeadingCommentPrologue(
421
+ content,
422
+ label = "",
423
+ fileType = "registry:ui",
424
+ ) {
425
+ if (
426
+ !JAVASCRIPT_LIKE_RE.test(label) ||
427
+ !SHADCN_TRANSFORMED_FILE_TYPES.has(fileType)
428
+ )
429
+ return content;
430
+ let cursor = 0;
431
+ let sawComment = false;
432
+ while (cursor < content.length) {
433
+ while (/\s/.test(content[cursor] ?? "")) cursor++;
434
+ if (content.startsWith("//", cursor)) {
435
+ sawComment = true;
436
+ const newline = content.indexOf("\n", cursor + 2);
437
+ cursor = newline === -1 ? content.length : newline + 1;
438
+ continue;
439
+ }
440
+ if (content.startsWith("/*", cursor)) {
441
+ const close = content.indexOf("*/", cursor + 2);
442
+ if (close === -1) return content;
443
+ sawComment = true;
444
+ cursor = close + 2;
445
+ continue;
446
+ }
447
+ break;
448
+ }
449
+ return sawComment ? content.slice(cursor) : content;
450
+ }
451
+
197
452
  // 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) {
453
+ // of human-readable mismatch strings (empty array = byte-faithful modulo modeled shadcn transforms).
454
+ function compareFile(
455
+ expectedContent,
456
+ actualContent,
457
+ label,
458
+ aliases = {},
459
+ fileType = "registry:ui",
460
+ ) {
200
461
  const problems = [];
462
+ // Prefer the exact source. If it differs, permit only the real shadcn leading-comment prologue
463
+ // removal for JS/TS-family files; internal/trailing comments remain part of the exact comparison.
464
+ const strippedExpected = stripShadcnLeadingCommentPrologue(
465
+ expectedContent,
466
+ label,
467
+ fileType,
468
+ );
469
+ const comparableExpected =
470
+ expectedContent === actualContent ? expectedContent : strippedExpected;
201
471
  // Normalize nothing — we compare raw. shadcn writes content verbatim except for alias
202
472
  // 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');
473
+ const expLines = comparableExpected.split("\n");
474
+ const actLines = actualContent.split("\n");
205
475
  if (expLines.length !== actLines.length) {
206
476
  problems.push(
207
- `${label}: line count differs (item has ${expLines.length}, on-disk has ${actLines.length}) — not an alias rewrite`,
477
+ `${label}: line count differs (item has ${expLines.length}, on-disk has ${actLines.length}) after allowed leading-comment removal — not an alias rewrite`,
208
478
  );
209
479
  return problems;
210
480
  }
@@ -219,14 +489,22 @@ function compareFile(expectedContent, actualContent, label) {
219
489
  // Both are import lines. The ONLY sanctioned difference is the module specifier, and
220
490
  // only when both sides are aliased specifiers. Head/tail (everything except the
221
491
  // specifier) must still match byte-for-byte.
222
- if (expImp.head === actImp.head && expImp.quote === actImp.quote && expImp.tail === actImp.tail) {
492
+ if (
493
+ expImp.head === actImp.head &&
494
+ expImp.quote === actImp.quote &&
495
+ expImp.tail === actImp.tail
496
+ ) {
223
497
  if (expImp.spec === actImp.spec) {
224
498
  // identical specifier but lines differed elsewhere — impossible given head/tail
225
499
  // 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.
500
+ problems.push(
501
+ `${label}:${i + 1}: import line differs in an unexpected way`,
502
+ );
503
+ } else if (
504
+ isSanctionedAliasRewrite(expImp.spec, actImp.spec, aliases)
505
+ ) {
506
+ // Exact components.json-derived rewrite. Any unconfigured alternate root or repointed
507
+ // path falls through to the error.
230
508
  continue;
231
509
  } else {
232
510
  problems.push(
@@ -261,47 +539,145 @@ function compareFile(expectedContent, actualContent, label) {
261
539
  // real configured dir (so a `src/components/ui` layout is honored — Codex R12); otherwise we fall
262
540
  // back to stripping the leading segment. Falls back to `path` when `target` is absent. Rooted at
263
541
  // targetDir.
264
- function resolveTargetPath(file, targetDir, aliases = {}) {
542
+ function resolveModulePath(specifier, paths = {}) {
543
+ for (const [pattern, targets] of Object.entries(paths)) {
544
+ const candidates = Array.isArray(targets) ? targets : [];
545
+ if (pattern.includes("*")) {
546
+ const [prefix, suffix] = pattern.split("*");
547
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix))
548
+ continue;
549
+ const captured = specifier.slice(
550
+ prefix.length,
551
+ specifier.length - suffix.length,
552
+ );
553
+ const target = candidates.find((value) => typeof value === "string");
554
+ if (target) return target.replace("*", captured).replace(/^\.\//, "");
555
+ } else if (specifier === pattern) {
556
+ const target = candidates.find((value) => typeof value === "string");
557
+ if (target) return target.replace(/^\.\//, "");
558
+ }
559
+ }
560
+ return undefined;
561
+ }
562
+
563
+ function aliasDirectory(alias, paths) {
564
+ return (resolveModulePath(alias, paths) ?? alias)
565
+ .replace(/^[@~#]\/?/, "")
566
+ .replace(/^\.\//, "");
567
+ }
568
+
569
+ function resolveTargetPath(file, targetDir, aliases = {}, paths = {}) {
265
570
  let t = file.target ?? file.path;
571
+ if (typeof t !== "string" || t.length === 0) {
572
+ throw new Error("registry file is missing a non-empty target/path");
573
+ }
574
+ const directTarget = file.target != null;
266
575
  // shadcn placeholder `@<name>/<rest>` (name is a components.json alias key: ui/components/lib/
267
576
  // hooks/utils). Resolve to that alias's on-disk dir when known.
268
577
  const ph = /^@([a-z]+)\/(.+)$/.exec(t);
269
578
  if (ph && aliases[ph[1]]) {
270
579
  // alias value e.g. "@/components/ui" | "~/src/components/ui" | "src/components/ui" — strip a
271
580
  // leading import prefix (@/, ~/, #) to get the targetDir-relative directory.
272
- const dir = aliases[ph[1]].replace(/^[@~#]\/?/, '');
273
- return resolve(targetDir, dir, ph[2]);
581
+ const alias = aliases[ph[1]];
582
+ if (typeof alias !== "string") {
583
+ throw new Error(`consumer alias ${ph[1]} must be a string`);
584
+ }
585
+ const dir = aliasDirectory(alias, paths);
586
+ return assertPathInside(
587
+ targetDir,
588
+ resolve(targetDir, dir, ph[2]),
589
+ `registry target ${t}`,
590
+ );
274
591
  }
275
592
  // No alias map (or unknown placeholder): strip a leading alias prefix to its tail.
276
- if (t.startsWith('@/') || t.startsWith('#') || t.startsWith('~/')) {
593
+ if (t.startsWith("@/") || t.startsWith("#") || t.startsWith("~/")) {
277
594
  // e.g. `@/components/ui/button.tsx` → `components/ui/button.tsx`
278
- t = t.replace(/^[@#~]\/?/, '');
279
- } else if (t.startsWith('@')) {
595
+ t = t.replace(/^[@#~]\/?/, "");
596
+ } else if (t.startsWith("@")) {
280
597
  // e.g. `@ui/button.tsx` → `ui/button.tsx`
281
- t = t.replace(/^@/, '');
598
+ t = t.replace(/^@/, "");
282
599
  }
283
- return resolve(targetDir, t);
600
+ // For a recognized Next src layout, shadcn prefixes direct non-placeholder registry:block targets
601
+ // with `src/` (for example app/dashboard becomes src/app/dashboard). Explicit alias targets are
602
+ // already rooted and must not be prefixed again.
603
+ const usesSrcLayout = Object.values(aliases).some((value) => {
604
+ if (typeof value !== "string") return false;
605
+ return aliasDirectory(value, paths).startsWith("src/");
606
+ });
607
+ if (
608
+ directTarget &&
609
+ !file.target?.startsWith("~/") &&
610
+ usesSrcLayout &&
611
+ !t.startsWith("src/")
612
+ ) {
613
+ t = `src/${t}`;
614
+ }
615
+ return assertPathInside(
616
+ targetDir,
617
+ resolve(targetDir, t),
618
+ `registry target ${file.target ?? file.path}`,
619
+ );
284
620
  }
285
621
 
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) {
622
+ function readConsumerConfiguration(targetDir) {
623
+ let aliases = {};
289
624
  try {
290
- const cj = JSON.parse(readFileSync(resolve(targetDir, 'components.json'), 'utf8'));
291
- return cj.aliases ?? {};
625
+ const cj = JSON.parse(
626
+ readFileSync(resolve(targetDir, "components.json"), "utf8"),
627
+ );
628
+ aliases = cj.aliases ?? {};
292
629
  } catch {
293
- return {};
630
+ // Post-write will still fail missing/misresolved files rather than trusting a guessed alias.
631
+ }
632
+ let paths = {};
633
+ const loaded = loadTsconfigPaths(targetDir);
634
+ if (loaded.resultType === "success") {
635
+ paths = Object.fromEntries(
636
+ Object.entries(loaded.paths).map(([pattern, targets]) => [
637
+ pattern,
638
+ targets.map((target) =>
639
+ relative(targetDir, resolve(loaded.absoluteBaseUrl, target))
640
+ .split(sep)
641
+ .join("/"),
642
+ ),
643
+ ]),
644
+ );
294
645
  }
646
+ return { aliases, paths };
647
+ }
648
+
649
+ function readConsumerAliases(targetDir) {
650
+ return readConsumerConfiguration(targetDir).aliases;
295
651
  }
296
652
 
297
- export { canonical, itemHash, stripProvenanceHeader, compareFile, isAliasedSpecifier, aliasCanonical, isSanctionedAliasRewrite, parseImportLine, resolveTargetPath, readConsumerAliases };
653
+ export {
654
+ canonical,
655
+ itemHash,
656
+ stripProvenanceHeader,
657
+ stripShadcnLeadingCommentPrologue,
658
+ compareFile,
659
+ rewriteRegistrySpecifier,
660
+ rewriteRegistryAliases,
661
+ isSanctionedAliasRewrite,
662
+ parseImportLine,
663
+ resolveTargetPath,
664
+ readConsumerAliases,
665
+ readConsumerConfiguration,
666
+ assertRegistryRequest,
667
+ assertItemName,
668
+ assertPathInside,
669
+ assertExistingPathInside,
670
+ writeNewPrivateFile,
671
+ terminalText,
672
+ parseCliArgs,
673
+ };
298
674
  // ───────────────────────────────────────────────────────────────────────────────────
299
675
 
300
676
  const USAGE = `vegastack-design verify — fail-closed registry trust preflight + post-write check
301
677
 
302
678
  Usage:
303
679
  vegastack-design verify [--hash-only] [--save <path>] <name> (pre-write)
304
- vegastack-design verify --post-write --item <path> --target-dir <dir>
680
+ vegastack-design verify --post-write --item <path> --expected-integrity <sha256-base64> --target-dir <dir>
305
681
  vegastack-design verify --help
306
682
 
307
683
  Modes:
@@ -309,188 +685,373 @@ Modes:
309
685
  signed manifest and the cosign CLI. This is the real trust boundary.
310
686
  --hash-only Pre-write. Item hash only (skips cosign). For local dev / no cosign /
311
687
  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.
688
+ --post-write OFFLINE. Authenticate a SAVED item against the independently retained
689
+ --expected-integrity, then compare the files shadcn copied on disk.
313
690
  Closes the check->copy (TOCTOU) gap. Fail-closed on any byte mismatch
314
- except sanctioned import-alias rewriting.
691
+ except shadcn's leading-comment removal and sanctioned alias rewriting.
315
692
 
316
693
  Pre-write flags:
317
694
  --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.
695
+ The path must not already exist. Default: a private unique temp file (path is
696
+ printed) — pass it to --post-write --item.
319
697
 
320
698
  Post-write flags:
321
699
  --item <path> The item JSON saved by a prior pre-write run (the bytes we verified).
700
+ --expected-integrity <sha256-base64>
701
+ The independently retained digest printed by the pre-write run. Required.
322
702
  --target-dir <d> Project root the files were copied into (resolves each file's target).
323
703
 
324
704
  The fail-closed flow that closes the TOCTOU window:
325
705
  1) pre-write verify (full or --hash-only) WITH --save → saves the trusted item bytes
326
706
  2) shadcn add @vegastack/<name> → copies files, rewrites aliases
327
- 3) --post-write --item <saved> --target-dir <root> → proves copies match the saved item
707
+ 3) Run the exact --post-write command printed by step 1 → authenticates the saved item and
708
+ proves the copied files match it
328
709
 
329
710
  Env:
330
711
  VEGASTACK_REGISTRY registry base URL (default https://design.vegastack.com/r)
712
+ VEGASTACK_TRUSTED_REGISTRY_ORIGIN
713
+ exact HTTPS origin allowed to receive credentials (defaults to
714
+ https://design.vegastack.com; set explicitly for a custom registry)
331
715
  CF_ACCESS_CLIENT_ID Cloudflare Access service-token id
332
716
  CF_ACCESS_CLIENT_SECRET Cloudflare Access service-token secret
333
- VEGASTACK_SIGNER_REPO OIDC signer repo (default VegaStack/vegastack-design) [full]
717
+ VEGASTACK_SIGNER_REPO OIDC signer repo (default vegastack/vegastack-design) [full]
334
718
  VEGASTACK_SIGNER_REF OIDC signer ref (default refs/heads/main) [full]`;
335
719
 
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;
720
+ function parseCliArgs(args) {
721
+ const parsed = {
722
+ hashOnly: false,
723
+ postWrite: false,
724
+ savePath: undefined,
725
+ itemPath: undefined,
726
+ expectedIntegrity: undefined,
727
+ targetDir: ".",
728
+ positionals: [],
729
+ };
730
+ const seen = new Set();
731
+ const setOnce = (flag) => {
732
+ if (seen.has(flag)) throw new Error(`duplicate option: ${flag}`);
733
+ seen.add(flag);
734
+ };
735
+ const valueAfter = (index, flag) => {
736
+ const value = args[index + 1];
737
+ if (!value || value.startsWith("-"))
738
+ throw new Error(`${flag} requires a value`);
739
+ return value;
740
+ };
741
+ for (let index = 0; index < args.length; index++) {
742
+ const argument = args[index];
743
+ if (argument === "--hash-only") {
744
+ setOnce(argument);
745
+ parsed.hashOnly = true;
746
+ } else if (argument === "--post-write") {
747
+ setOnce(argument);
748
+ parsed.postWrite = true;
749
+ } else if (
750
+ argument === "--save" ||
751
+ argument === "--item" ||
752
+ argument === "--expected-integrity" ||
753
+ argument === "--target-dir"
754
+ ) {
755
+ setOnce(argument);
756
+ const value = valueAfter(index, argument);
757
+ index++;
758
+ if (argument === "--save") parsed.savePath = value;
759
+ else if (argument === "--item") parsed.itemPath = value;
760
+ else if (argument === "--expected-integrity")
761
+ parsed.expectedIntegrity = value;
762
+ else parsed.targetDir = value;
763
+ } else if (argument.startsWith("-")) {
764
+ throw new Error(`unknown option: ${argument}`);
765
+ } else {
766
+ parsed.positionals.push(argument);
767
+ }
768
+ }
769
+ if (parsed.postWrite) {
770
+ if (parsed.hashOnly || parsed.savePath) {
771
+ throw new Error(
772
+ "--post-write cannot be combined with --hash-only or --save",
773
+ );
774
+ }
775
+ if (!parsed.itemPath)
776
+ throw new Error("--post-write requires --item <path-to-saved-item.json>");
777
+ if (!INTEGRITY_RE.test(parsed.expectedIntegrity ?? "")) {
778
+ throw new Error(
779
+ "--post-write requires --expected-integrity <sha256-base64> from the pre-write result",
780
+ );
781
+ }
782
+ if (parsed.positionals.length > 0)
783
+ throw new Error("--post-write does not accept an item name");
784
+ } else {
785
+ if (
786
+ parsed.itemPath ||
787
+ parsed.expectedIntegrity ||
788
+ seen.has("--target-dir")
789
+ ) {
790
+ throw new Error(
791
+ "--item, --expected-integrity, and --target-dir require --post-write",
792
+ );
793
+ }
794
+ if (parsed.positionals.length !== 1) {
795
+ throw new Error(
796
+ "pre-write verification requires exactly one registry item name",
797
+ );
798
+ }
799
+ }
800
+ return { ...parsed, name: parsed.positionals[0] };
340
801
  }
341
802
 
342
803
  // Only run the CLI when invoked directly (so tests/imports don't trigger fetches).
343
- if (import.meta.url === `file://${process.argv[1]}`) {
804
+ // Naive `file://${argv[1]}` string-building breaks on any path needing percent-encoding (a space,
805
+ // '#', non-ASCII) — the comparison silently failed and the CLI exited 0 having verified NOTHING,
806
+ // i.e. the fail-closed preflight failed OPEN. pathToFileURL encodes identically to import.meta.url.
807
+ // (check-updates.mjs already uses this form; keep the two in step.)
808
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
344
809
  const args = process.argv.slice(2);
345
810
 
346
811
  // Deprecation notice when invoked under the legacy bin name (not via the `vegastack-design`
347
812
  // dispatcher, which sets VEGASTACK_DESIGN_DISPATCH). The alias still works for one more minor.
348
813
  if (
349
- basename(process.argv[1] ?? '') === 'verify-registry-item.mjs' &&
814
+ basename(process.argv[1] ?? "") === "verify-registry-item.mjs" &&
350
815
  !process.env.VEGASTACK_DESIGN_DISPATCH &&
351
816
  process.stderr.isTTY // interactive humans only — don't spam CI / piped / internal-tooling runs
352
817
  ) {
353
- console.error('[deprecated] `vegastack-verify-registry-item` is now `vegastack-design verify` — please switch.');
818
+ console.error(
819
+ "[deprecated] `vegastack-verify-registry-item` is now `vegastack-design verify` — please switch.",
820
+ );
354
821
  }
355
- if (args.includes('--help') || args.includes('-h')) {
822
+ if (args.includes("--help") || args.includes("-h")) {
356
823
  console.log(USAGE);
357
824
  process.exit(0);
358
825
  }
359
826
 
827
+ let parsedArgs;
828
+ try {
829
+ parsedArgs = parseCliArgs(args);
830
+ } catch (err) {
831
+ console.error(terminalText(err.message));
832
+ console.error(USAGE);
833
+ process.exit(2);
834
+ }
835
+
360
836
  // ── 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
- }
837
+ if (parsedArgs.postWrite) {
838
+ const itemPath = parsedArgs.itemPath;
839
+ const expectedIntegrity = parsedArgs.expectedIntegrity;
840
+ const targetDir = parsedArgs.targetDir;
369
841
 
370
842
  let item;
371
843
  try {
372
- item = JSON.parse(readFileSync(itemPath, 'utf8'));
844
+ item = JSON.parse(readFileSync(itemPath, "utf8"));
373
845
  } catch (err) {
374
- console.error(`cannot read saved item ${itemPath}: ${err.message}`);
846
+ console.error(
847
+ `cannot read saved item ${terminalText(itemPath)}: ${terminalText(err.message)}`,
848
+ );
375
849
  process.exit(2);
376
850
  }
377
851
 
378
852
  const files = Array.isArray(item.files) ? item.files : [];
379
853
  if (files.length === 0) {
380
- console.error(`saved item ${itemPath} has no files[] to verify`);
854
+ console.error(
855
+ `saved item ${terminalText(itemPath)} has no files[] to verify`,
856
+ );
857
+ process.exit(2);
858
+ }
859
+ const savedIntegrity = itemHash(item);
860
+ if (
861
+ savedIntegrity !== expectedIntegrity ||
862
+ item.meta?.integrity !== expectedIntegrity
863
+ ) {
864
+ console.error(
865
+ `saved item ${terminalText(itemPath)} does not match the independently retained pre-write integrity`,
866
+ );
381
867
  process.exit(2);
382
868
  }
383
869
 
384
870
  const allProblems = [];
385
871
  let checked = 0;
386
- const aliases = readConsumerAliases(targetDir); // resolve @ui/ etc. to the consumer's real layout
872
+ let realTargetDir;
873
+ try {
874
+ realTargetDir = realpathSync(targetDir);
875
+ } catch (err) {
876
+ console.error(
877
+ `cannot resolve --target-dir ${terminalText(targetDir)}: ${terminalText(err.message)}`,
878
+ );
879
+ process.exit(2);
880
+ }
881
+ const configuration = readConsumerConfiguration(realTargetDir);
882
+ const aliases = configuration.aliases;
387
883
  for (const file of files) {
388
- const onDisk = resolveTargetPath(file, targetDir, aliases);
389
884
  const label = file.target ?? file.path;
885
+ let onDisk;
390
886
  let actual;
391
887
  try {
392
- actual = readFileSync(onDisk, 'utf8');
393
- } catch {
394
- allProblems.push(`${label}: expected copied file missing at ${onDisk}`);
888
+ onDisk = resolveTargetPath(
889
+ file,
890
+ realTargetDir,
891
+ aliases,
892
+ configuration.paths,
893
+ );
894
+ const realOnDisk = assertExistingPathInside(
895
+ realTargetDir,
896
+ onDisk,
897
+ `registry target ${label}`,
898
+ );
899
+ actual = readFileSync(realOnDisk, "utf8");
900
+ } catch (err) {
901
+ allProblems.push(`${label}: ${err.message}`);
395
902
  continue;
396
903
  }
397
- const problems = compareFile(file.content ?? '', actual, label);
904
+ const problems = compareFile(
905
+ file.content ?? "",
906
+ actual,
907
+ label,
908
+ aliases,
909
+ file.type,
910
+ );
398
911
  if (problems.length) allProblems.push(...problems);
399
912
  checked++;
400
913
  }
401
914
 
402
915
  if (allProblems.length) {
403
- console.error(`post-write verification FAILED for ${item.name ?? itemPath}:`);
404
- for (const p of allProblems) console.error(` ✗ ${p}`);
405
916
  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.',
917
+ `post-write verification FAILED for ${terminalText(item.name ?? itemPath)}:`,
918
+ );
919
+ for (const p of allProblems) console.error(` ✗ ${terminalText(p)}`);
920
+ console.error(
921
+ "\nThe copied files do NOT match the verified item (beyond shadcn leading-comment removal and sanctioned alias rewriting)." +
922
+ "\nThis is exactly the TOCTOU signal: treat the copy-in as untrusted.",
408
923
  );
409
924
  process.exit(1);
410
925
  }
411
926
  console.log(
412
- `post-write OK: ${checked} file(s) of ${item.name ?? itemPath} are byte-faithful to the saved item (modulo alias rewriting)`,
927
+ `post-write OK: ${checked} file(s) of ${terminalText(item.name ?? itemPath)} are byte-faithful to the saved item (modulo shadcn leading-comment removal and alias rewriting)`,
413
928
  );
414
929
  process.exit(0);
415
930
  }
416
931
 
417
932
  // ── 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);
933
+ const hashOnly = parsedArgs.hashOnly;
934
+ const savePath = parsedArgs.savePath;
935
+ const name = parsedArgs.name;
936
+
937
+ try {
938
+ assertItemName(name);
939
+ } catch (err) {
940
+ console.error(terminalText(err.message));
425
941
  process.exit(2);
426
942
  }
427
943
 
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
- };
944
+ try {
945
+ const base = (process.env.VEGASTACK_REGISTRY ?? DEFAULT_REGISTRY).replace(
946
+ /\/$/,
947
+ "",
948
+ );
949
+ const headers = requestHeaders();
433
950
 
434
- const manifestPath = join(tmpdir(), 'vega-manifest.json');
951
+ const tempDir = mkdtempSync(join(tmpdir(), "vegastack-verify-"));
952
+ const manifestPath = join(tempDir, "manifest.json");
435
953
 
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
- }
954
+ if (hashOnly) {
955
+ // hash-only: fetch the manifest over the transport, skip the signature.
956
+ const manifestText = await fetchRegistryText(
957
+ `${base}/integrity-manifest.json`,
958
+ headers,
959
+ "integrity manifest",
960
+ );
961
+ writeFileSync(manifestPath, manifestText, { mode: 0o600 });
962
+ console.warn(
963
+ "[hash-only] skipping Sigstore signature verification — provenance NOT proven",
964
+ );
965
+ } else {
966
+ // full: fetch manifest + signature bundle, then verify the signature against the
967
+ // EXACT release identity before trusting the manifest.
968
+ const sigPath = join(tempDir, "manifest.sigstore");
969
+ const [manifestText, bundleText] = await Promise.all([
970
+ fetchRegistryText(
971
+ `${base}/integrity-manifest.json`,
972
+ headers,
973
+ "integrity manifest",
974
+ ),
975
+ fetchRegistryText(
976
+ `${base}/integrity-manifest.sigstore`,
977
+ headers,
978
+ "signature bundle",
979
+ ),
980
+ ]);
981
+ writeFileSync(manifestPath, manifestText, { mode: 0o600 });
982
+ writeFileSync(sigPath, bundleText, { mode: 0o600 });
473
983
 
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
- }
984
+ // Pin the precise signer (the deploy workflow at the trusted ref) + repo, NOT a
985
+ // repo-prefix regexp — a broad prefix would accept a manifest signed by ANY
986
+ // workflow/ref in the repo that can obtain GitHub OIDC, defeating the registry
987
+ // trust boundary. Override the ref (e.g. a tag) via env for tagged releases.
988
+ const SIGNER_REPO =
989
+ process.env.VEGASTACK_SIGNER_REPO ?? "vegastack/vegastack-design";
990
+ const SIGNER_REF = process.env.VEGASTACK_SIGNER_REF ?? "refs/heads/main";
991
+ const SIGNER_IDENTITY = `https://github.com/${SIGNER_REPO}/.github/workflows/deploy.yml@${SIGNER_REF}`;
992
+ try {
993
+ execFileSync(
994
+ "cosign",
995
+ [
996
+ "verify-blob",
997
+ "--bundle",
998
+ sigPath,
999
+ "--certificate-identity",
1000
+ SIGNER_IDENTITY,
1001
+ "--certificate-oidc-issuer",
1002
+ "https://token.actions.githubusercontent.com",
1003
+ "--certificate-github-workflow-repository",
1004
+ SIGNER_REPO,
1005
+ "--certificate-github-workflow-ref",
1006
+ SIGNER_REF,
1007
+ manifestPath,
1008
+ ],
1009
+ { stdio: "inherit" },
1010
+ );
1011
+ } catch (error) {
1012
+ if (error.code === "ENOENT") {
1013
+ throw new RegistryAccessError(
1014
+ "cosign is not installed or is not available on PATH; install it before full verification",
1015
+ );
1016
+ }
1017
+ throw new Error("Sigstore signature verification failed");
1018
+ }
1019
+ }
1020
+
1021
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1022
+ // Capture the EXACT bytes we fetch+verify. We parse from this same text so the saved item
1023
+ // is byte-for-byte what the hash was computed over — the post-write pass compares against
1024
+ // THESE bytes, not a re-fetch. That is what closes the TOCTOU window.
1025
+ const itemText = await fetchRegistryText(
1026
+ `${base}/${name}.json`,
1027
+ headers,
1028
+ `registry item ${name}`,
1029
+ );
1030
+ const item = JSON.parse(itemText);
1031
+ if (
1032
+ item.name !== name ||
1033
+ !Array.isArray(item.files) ||
1034
+ item.files.length === 0
1035
+ ) {
1036
+ throw new Error("registry item identity/files contract mismatch");
1037
+ }
1038
+ const got = itemHash(item);
1039
+ if (got !== item.meta?.integrity || got !== manifest[name]) {
1040
+ throw new Error(`integrity mismatch for ${name}`);
1041
+ }
485
1042
 
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);
1043
+ // Persist the verified item so --post-write can compare copies against THESE exact bytes.
1044
+ const outPath = savePath ?? join(tempDir, `item-${name}.json`);
1045
+ writeNewPrivateFile(outPath, itemText);
489
1046
 
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
- );
1047
+ console.log(`verified ${name}${hashOnly ? " (hash-only)" : ""}`);
1048
+ console.log(`saved verified item → ${terminalText(outPath)}`);
1049
+ console.log(
1050
+ `next: run \`shadcn add @vegastack/${name}\`, then close the TOCTOU gap with:\n` +
1051
+ ` vegastack-design verify --post-write --item ${shellArg(outPath)} --expected-integrity ${shellArg(got)} --target-dir .`,
1052
+ );
1053
+ } catch (error) {
1054
+ console.error(`verification failed: ${terminalText(error.message)}`);
1055
+ process.exit(error instanceof RegistryAccessError ? 2 : 1);
1056
+ }
496
1057
  }