@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
|
@@ -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
|
|
33
|
-
// (
|
|
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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
|
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
|
|
55
|
-
import {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 !==
|
|
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 ===
|
|
79
|
-
return Object.fromEntries(
|
|
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 ===
|
|
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
|
-
|
|
97
|
-
createHash(
|
|
98
|
-
.update(
|
|
99
|
-
|
|
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
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
function
|
|
168
|
-
return (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 =
|
|
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
|
|
199
|
-
function compareFile(
|
|
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 =
|
|
204
|
-
const actLines = actualContent.split(
|
|
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 (
|
|
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(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
|
273
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
function readConsumerAliases(targetDir) {
|
|
622
|
+
function readConsumerConfiguration(targetDir) {
|
|
623
|
+
let aliases = {};
|
|
289
624
|
try {
|
|
290
|
-
const cj = JSON.parse(
|
|
291
|
-
|
|
625
|
+
const cj = JSON.parse(
|
|
626
|
+
readFileSync(resolve(targetDir, "components.json"), "utf8"),
|
|
627
|
+
);
|
|
628
|
+
aliases = cj.aliases ?? {};
|
|
292
629
|
} catch {
|
|
293
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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] ??
|
|
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(
|
|
818
|
+
console.error(
|
|
819
|
+
"[deprecated] `vegastack-verify-registry-item` is now `vegastack-design verify` — please switch.",
|
|
820
|
+
);
|
|
354
821
|
}
|
|
355
|
-
if (args.includes(
|
|
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 (
|
|
362
|
-
const itemPath =
|
|
363
|
-
const
|
|
364
|
-
|
|
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,
|
|
844
|
+
item = JSON.parse(readFileSync(itemPath, "utf8"));
|
|
373
845
|
} catch (err) {
|
|
374
|
-
console.error(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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(
|
|
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
|
-
|
|
407
|
-
|
|
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 =
|
|
419
|
-
const savePath =
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
944
|
+
try {
|
|
945
|
+
const base = (process.env.VEGASTACK_REGISTRY ?? DEFAULT_REGISTRY).replace(
|
|
946
|
+
/\/$/,
|
|
947
|
+
"",
|
|
948
|
+
);
|
|
949
|
+
const headers = requestHeaders();
|
|
433
950
|
|
|
434
|
-
|
|
951
|
+
const tempDir = mkdtempSync(join(tmpdir(), "vegastack-verify-"));
|
|
952
|
+
const manifestPath = join(tempDir, "manifest.json");
|
|
435
953
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
}
|