frakment 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,9 @@ Flags:
58
58
  - `--registry <url>` — override the registry URL (default: from `components.json`)
59
59
  - `--overwrite` — overwrite existing files without prompting
60
60
 
61
+ - `--dry-run` — show planned file and dependency changes without changing the project
62
+ - `--skip-dependencies` — write component files without installing missing npm dependencies
63
+
61
64
  ### `frakment list`
62
65
 
63
66
  ```bash
package/bin/index.js CHANGED
@@ -37,7 +37,12 @@ program
37
37
  program
38
38
  .command("add <component>")
39
39
  .description("Add a component from the frakment registry")
40
+ .option(
41
+ "--dry-run",
42
+ "Show planned file and dependency changes without writing"
43
+ )
40
44
  .option("--registry <url>", "Custom registry URL")
45
+ .option("--skip-dependencies", "Do not install missing npm dependencies")
41
46
  .option("--overwrite", "Overwrite existing files", false)
42
47
  .action(add);
43
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frakment",
3
- "version": "0.0.6",
3
+ "version": "0.1.0",
4
4
  "description": "CLI and design system for the frakment component registry",
5
5
  "keywords": [
6
6
  "components",
@@ -1,5 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
 
5
6
  import chalk from "chalk";
@@ -10,22 +11,191 @@ import prompts from "prompts";
10
11
  import { detectPackageManager, findProjectRoot } from "../utils/detect.js";
11
12
  import { logger } from "../utils/logger.js";
12
13
 
14
+ const COMPONENT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
15
+ const DEPENDENCY_PATTERN =
16
+ /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[0-9A-Za-z*~^<>=|.+_-]+)?$/u;
17
+ const MAX_REGISTRY_PAYLOAD_BYTES = 10 * 1024 * 1024;
18
+
19
+ const isRecord = (value) =>
20
+ typeof value === "object" && value !== null && !Array.isArray(value);
21
+
22
+ const canonicalize = (value) => {
23
+ if (Array.isArray(value)) {
24
+ return `[${value.map(canonicalize).join(",")}]`;
25
+ }
26
+ if (isRecord(value)) {
27
+ return `{${Object.keys(value)
28
+ .sort()
29
+ .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`)
30
+ .join(",")}}`;
31
+ }
32
+ return JSON.stringify(value);
33
+ };
34
+
35
+ const createIntegrity = (payload) =>
36
+ `sha256:${createHash("sha256")
37
+ .update(canonicalize(payload), "utf8")
38
+ .digest("hex")}`;
39
+
40
+ const assertComponentName = (name) => {
41
+ if (typeof name !== "string" || !COMPONENT_NAME_PATTERN.test(name)) {
42
+ throw new Error(`Invalid component name: ${String(name)}`);
43
+ }
44
+ };
45
+
46
+ const assertDependency = (dependency) => {
47
+ if (typeof dependency !== "string" || !DEPENDENCY_PATTERN.test(dependency)) {
48
+ throw new Error(
49
+ `Invalid dependency in registry payload: ${String(dependency)}`
50
+ );
51
+ }
52
+ };
53
+
54
+ const getDependencyName = (dependency) => {
55
+ if (dependency.startsWith("@")) {
56
+ return dependency.slice(0, dependency.indexOf("@", 1));
57
+ }
58
+ return dependency.split("@")[0];
59
+ };
60
+
61
+ const hasDependencyVersion = (dependency) =>
62
+ dependency.lastIndexOf("@") > (dependency.startsWith("@") ? 0 : -1);
63
+
64
+ const isBase64 = (value) =>
65
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
66
+ value
67
+ );
68
+
69
+ export const validateRegistryPayload = (payload, expectedName) => {
70
+ if (!isRecord(payload) || payload.name !== expectedName) {
71
+ throw new Error("Registry payload has an unexpected component name");
72
+ }
73
+ if (
74
+ typeof payload.integrity !== "string" ||
75
+ !/^sha256:[a-f0-9]{64}$/u.test(payload.integrity)
76
+ ) {
77
+ throw new Error("Registry payload has invalid integrity metadata");
78
+ }
79
+ if (!Array.isArray(payload.files) || payload.files.length === 0) {
80
+ throw new Error("Registry payload must include at least one file");
81
+ }
82
+ if (payload.files.length > 100) {
83
+ throw new Error("Registry payload has too many files");
84
+ }
85
+
86
+ for (const file of payload.files) {
87
+ if (!isRecord(file) || typeof file.path !== "string") {
88
+ throw new Error("Registry payload contains an invalid file");
89
+ }
90
+ if (typeof file.content !== "string") {
91
+ throw new Error(`Registry file ${file.path} has invalid content`);
92
+ }
93
+ if (file.encoding !== undefined && file.encoding !== "base64") {
94
+ throw new Error(`Registry file ${file.path} has an unsupported encoding`);
95
+ }
96
+ if (file.encoding === "base64" && !isBase64(file.content)) {
97
+ throw new Error(`Registry file ${file.path} has invalid base64 content`);
98
+ }
99
+ }
100
+
101
+ if (
102
+ !(
103
+ Array.isArray(payload.dependencies ?? []) &&
104
+ Array.isArray(payload.registryDependencies ?? [])
105
+ )
106
+ ) {
107
+ throw new Error("Registry payload has invalid dependency lists");
108
+ }
109
+ for (const dependency of payload.dependencies ?? []) {
110
+ assertDependency(dependency);
111
+ }
112
+ for (const dependency of payload.registryDependencies ?? []) {
113
+ assertComponentName(dependency);
114
+ }
115
+
116
+ const { integrity, ...unsignedPayload } = payload;
117
+ if (integrity !== createIntegrity(unsignedPayload)) {
118
+ throw new Error("Registry payload integrity check failed");
119
+ }
120
+
121
+ return payload;
122
+ };
123
+
124
+ const getRegistryUrl = (value) => {
125
+ const url = new URL(value);
126
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
127
+ throw new Error("Registry URL must use HTTP or HTTPS");
128
+ }
129
+ if (url.username || url.password || url.search || url.hash) {
130
+ throw new Error(
131
+ "Registry URL must not include credentials, a query, or a fragment"
132
+ );
133
+ }
134
+ return url.href.replace(/\/$/u, "");
135
+ };
136
+
13
137
  async function fetchComponent(name, registryUrl, spinner) {
14
138
  try {
15
- const res = await fetch(`${registryUrl}/${name}.json`);
139
+ const res = await fetch(`${registryUrl}/${name}.json`, {
140
+ signal: AbortSignal.timeout(30_000),
141
+ });
16
142
  if (!res.ok) {
17
143
  throw new Error(`HTTP ${res.status}`);
18
144
  }
19
- const payload = await res.json();
145
+ const contentLength = Number(res.headers.get("content-length"));
146
+ if (contentLength > MAX_REGISTRY_PAYLOAD_BYTES) {
147
+ throw new Error("Registry response is too large");
148
+ }
149
+ const raw = await res.text();
150
+ if (Buffer.byteLength(raw, "utf8") > MAX_REGISTRY_PAYLOAD_BYTES) {
151
+ throw new Error("Registry response is too large");
152
+ }
153
+ const payload = validateRegistryPayload(JSON.parse(raw), name);
20
154
  spinner.text = `Fetching ${name}...`;
21
155
  return payload;
22
- } catch {
23
- spinner.fail(`Registry fetch failed for ${name}.`);
156
+ } catch (error) {
157
+ spinner.fail(
158
+ `Registry fetch failed for ${name}: ${error instanceof Error ? error.message : "unknown error"}`
159
+ );
24
160
  process.exit(1);
25
161
  }
26
162
  }
163
+
164
+ export async function assertSafePath(cwd, relativePath) {
165
+ if (typeof relativePath !== "string" || relativePath.length === 0) {
166
+ throw new Error("Invalid file path in registry payload");
167
+ }
168
+ if (relativePath.includes("\0")) {
169
+ throw new Error(`Null byte in path: ${relativePath}`);
170
+ }
171
+ if (path.isAbsolute(relativePath) || /^[a-zA-Z]:/.test(relativePath)) {
172
+ throw new Error(`Absolute path rejected: ${relativePath}`);
173
+ }
174
+ if (relativePath.split(/[\\/]/).some((seg) => seg === "..")) {
175
+ throw new Error(`Parent traversal rejected: ${relativePath}`);
176
+ }
177
+ const root = path.resolve(cwd);
178
+ const target = path.resolve(root, relativePath);
179
+ if (target !== root && !target.startsWith(root + path.sep)) {
180
+ throw new Error(`Path escapes project root: ${relativePath}`);
181
+ }
182
+ const realRoot = await realpath(root).catch(() => root);
183
+ let existingParent = path.dirname(target);
184
+ while (!existsSync(existingParent)) {
185
+ existingParent = path.dirname(existingParent);
186
+ }
187
+ const realParent = await realpath(existingParent).catch(() => existingParent);
188
+ if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) {
189
+ throw new Error(`Symlink escape rejected: ${relativePath}`);
190
+ }
191
+ if (existsSync(target) && (await lstat(target)).isSymbolicLink()) {
192
+ throw new Error(`Symlink target rejected: ${relativePath}`);
193
+ }
194
+ return target;
195
+ }
196
+
27
197
  async function writeFileSafely(file, cwd, options) {
28
- const targetPath = path.join(cwd, file.path);
198
+ const targetPath = await assertSafePath(cwd, file.path);
29
199
  if (existsSync(targetPath) && !options.overwrite) {
30
200
  const { overwrite } = await prompts({
31
201
  initial: false,
@@ -64,14 +234,24 @@ export async function add(componentName, options) {
64
234
  process.exit(1);
65
235
  }
66
236
 
67
- const configRaw = await readFile(configPath, "utf-8");
68
- const config = JSON.parse(configRaw.replace(/^\uFEFF/u, ""));
69
- const registryUrl = (options.registry ?? config.registry).replace(/\/$/u, "");
237
+ let config;
238
+ let registryUrl;
239
+ try {
240
+ assertComponentName(componentName);
241
+ const configRaw = await readFile(configPath, "utf-8");
242
+ config = JSON.parse(configRaw.replace(/^\uFEFF/u, ""));
243
+ registryUrl = getRegistryUrl(options.registry ?? config.registry);
244
+ } catch (error) {
245
+ logger.error(
246
+ error instanceof Error ? error.message : "Invalid configuration."
247
+ );
248
+ process.exit(1);
249
+ }
70
250
 
71
251
  const registrySpinner = ora("Checking registry").start();
72
252
  const queue = [componentName];
73
253
  const fetched = new Map();
74
- const npmDeps = new Set();
254
+ const npmDeps = new Map();
75
255
 
76
256
  while (queue.length) {
77
257
  const name = queue.shift();
@@ -81,7 +261,14 @@ export async function add(componentName, options) {
81
261
  const payload = await fetchComponent(name, registryUrl, registrySpinner);
82
262
  fetched.set(name, payload);
83
263
  for (const d of payload.dependencies ?? []) {
84
- npmDeps.add(d);
264
+ const packageName = getDependencyName(d);
265
+ const existing = npmDeps.get(packageName);
266
+ if (
267
+ !existing ||
268
+ (!hasDependencyVersion(existing) && hasDependencyVersion(d))
269
+ ) {
270
+ npmDeps.set(packageName, d);
271
+ }
85
272
  }
86
273
  for (const d of payload.registryDependencies ?? []) {
87
274
  if (!fetched.has(d)) {
@@ -105,6 +292,31 @@ export async function add(componentName, options) {
105
292
  order.push(componentName);
106
293
  const written = [];
107
294
 
295
+ const getMissingDependencies = async () => {
296
+ const pkgPath = path.join(cwd, "package.json");
297
+ const pkgRaw = await readFile(pkgPath, "utf-8");
298
+ const pkg = JSON.parse(pkgRaw.replace(/^\uFEFF/u, ""));
299
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
300
+ return [...npmDeps].filter(([packageName]) => !allDeps[packageName]);
301
+ };
302
+
303
+ if (options.dryRun) {
304
+ for (const name of order) {
305
+ for (const file of fetched.get(name).files) {
306
+ const targetPath = resolveTargetPath(file.path);
307
+ await assertSafePath(cwd, targetPath);
308
+ logger.log(`Would create ${targetPath}.`);
309
+ }
310
+ }
311
+ const missing = await getMissingDependencies();
312
+ for (const [, dependency] of missing) {
313
+ logger.log(`Would install ${dependency}.`);
314
+ }
315
+ logger.success("Dry run complete. No files or dependencies were changed.");
316
+ logger.break();
317
+ return;
318
+ }
319
+
108
320
  for (const name of order) {
109
321
  for (const file of fetched.get(name).files ?? []) {
110
322
  const targetFile = { ...file, path: resolveTargetPath(file.path) };
@@ -124,17 +336,20 @@ export async function add(componentName, options) {
124
336
  }
125
337
  }
126
338
  if (npmDeps.size > 0) {
127
- const pkgPath = path.join(cwd, "package.json");
128
- const pkgRaw = await readFile(pkgPath, "utf-8");
129
- const pkg = JSON.parse(pkgRaw.replace(/^\uFEFF/u, ""));
130
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
131
- const missing = [...npmDeps].filter((d) => !allDeps[d]);
339
+ const missing = await getMissingDependencies();
132
340
  if (missing.length > 0) {
341
+ if (options.skipDependencies) {
342
+ logger.info(`Skipped ${missing.length} dependency installation(s).`);
343
+ logger.break();
344
+ return;
345
+ }
133
346
  const s = ora("Installing dependencies").start();
134
347
  const pm = detectPackageManager(cwd);
135
348
  try {
136
349
  const args =
137
- pm === "npm" ? ["install", ...missing] : ["add", ...missing];
350
+ pm === "npm"
351
+ ? ["install", ...missing.map(([, dependency]) => dependency)]
352
+ : ["add", ...missing.map(([, dependency]) => dependency)];
138
353
  await execa(pm, args, { cwd, stdio: "ignore" });
139
354
  s.succeed(`Installed ${missing.length} package(s).`);
140
355
  } catch (error) {
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { assertSafePath, validateRegistryPayload } from "./add.js";
4
+
5
+ const unsignedPayload = {
6
+ dependencies: ["@frakment/react@^0.0.3", "motion@^14.10.0"],
7
+ files: [
8
+ {
9
+ content: "export {};\n",
10
+ path: "components/ui/example.tsx",
11
+ },
12
+ ],
13
+ name: "example",
14
+ registryDependencies: ["button"],
15
+ };
16
+
17
+ const payload = {
18
+ ...unsignedPayload,
19
+ integrity:
20
+ "sha256:d75c2cab99c7b025d584adc04eb9b2eb86794f1a51df578f94c74c0eef22634d",
21
+ };
22
+
23
+ describe("registry payload validation", () => {
24
+ it("accepts a valid component payload", () => {
25
+ expect(validateRegistryPayload(payload, "example")).toEqual(payload);
26
+ });
27
+
28
+ it("rejects a component name mismatch", () => {
29
+ expect(() => validateRegistryPayload(payload, "button")).toThrow(
30
+ "unexpected component name"
31
+ );
32
+ });
33
+
34
+ it("rejects invalid dependency specifications", () => {
35
+ expect(() =>
36
+ validateRegistryPayload(
37
+ { ...payload, dependencies: ["file:../malicious-package"] },
38
+ "example"
39
+ )
40
+ ).toThrow(/invalid dependency/i);
41
+ });
42
+
43
+ it("rejects malformed files and registry dependencies", () => {
44
+ expect(() =>
45
+ validateRegistryPayload(
46
+ {
47
+ ...payload,
48
+ files: [{ content: "x", encoding: "hex", path: "file.ts" }],
49
+ },
50
+ "example"
51
+ )
52
+ ).toThrow("unsupported encoding");
53
+ expect(() =>
54
+ validateRegistryPayload(
55
+ { ...payload, registryDependencies: ["../button"] },
56
+ "example"
57
+ )
58
+ ).toThrow("Invalid component name");
59
+ });
60
+
61
+ it("rejects content that does not match the integrity hash", () => {
62
+ expect(() =>
63
+ validateRegistryPayload(
64
+ {
65
+ ...payload,
66
+ files: [{ content: "changed", path: "components/ui/example.tsx" }],
67
+ },
68
+ "example"
69
+ )
70
+ ).toThrow("integrity check failed");
71
+ });
72
+ });
73
+
74
+ describe("registry target paths", () => {
75
+ it("accepts paths within the project root", async () => {
76
+ await expect(
77
+ assertSafePath(process.cwd(), "components/ui/button.tsx")
78
+ ).resolves.toContain("components");
79
+ });
80
+
81
+ it("rejects parent traversal and absolute paths", async () => {
82
+ await expect(
83
+ assertSafePath(process.cwd(), "../../outside.txt")
84
+ ).rejects.toThrow("Parent traversal rejected");
85
+ await expect(
86
+ assertSafePath(process.cwd(), "C:\\outside.txt")
87
+ ).rejects.toThrow("Absolute path rejected");
88
+ });
89
+ });
@@ -140,14 +140,18 @@ export function cn(...inputs: ClassValue[]) {
140
140
  await mkdir(path.join(cwd, "lib"), { recursive: true });
141
141
  await writeFile(
142
142
  path.join(cwd, "lib/animation.ts"),
143
- `export const SPRING_SMOOTH = { type: "spring", stiffness: 200, damping: 30 } as const
143
+ `export const springSoft = { type: "spring", stiffness: 200, damping: 30 } as const
144
144
 
145
- export const EASE_OUT = [0.16, 1, 0.3, 1] as const
146
- export const EASE_SMOOTH = [0.32, 0.72, 0, 1] as const
147
- export const EASE_IN_OUT = [0.4, 0, 0.2, 1] as const
145
+ export const easeInOut = [0.4, 0, 0.2, 1] as const
146
+ export const easeInOutQuad = [0.45, 0, 0.55, 1] as const
147
+ export const easeOutExpo = [0.16, 1, 0.3, 1] as const
148
+ export const easeSmoothOut = [0.32, 0.72, 0, 1] as const
148
149
 
149
- export const DURATION_FAST = 0.2
150
- export const DURATION_SMOOTH = 0.45
150
+ export const durationFast = 0.2
151
+ export const durationNormal = 0.3
152
+ export const durationSlow = 0.45
153
+
154
+ export const delayTight = 0.02
151
155
  `
152
156
  );
153
157
  s.succeed("Created 1 file:");