frakment 0.0.4 → 0.0.7

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/LICENSE CHANGED
@@ -1,190 +1,21 @@
1
- Frakment License
2
- Version 1.0
3
-
4
- Copyright (c) 2026 hanshapd <https://github.com/hanshapd>
5
-
6
- This is a source-available license. It is NOT an open-source license
7
- as defined by the Open Source Initiative (OSI).
8
-
9
- In short:
10
-
11
- - Use Frakment freely for personal, educational, and non-commercial
12
- purposes. Just keep the attribution.
13
-
14
- - Use Frakment commercially only after you get one pull request
15
- merged into the main repository. That is the only path.
16
-
17
- - Always keep the attribution. Always mark modifications.
18
-
19
- ====================================================================
20
- 1. DEFINITIONS
21
- ====================================================================
22
-
23
- "Software" means the source code, components, CLI, MCP server, agent
24
- skills, documentation, and any associated files in this repository,
25
- whether used in original or modified form.
26
-
27
- "Personal Use" means use by an individual for personal projects,
28
- learning, experimentation, hobby work, or internal tools that do not
29
- generate revenue.
30
-
31
- "Educational Use" means use by students, teachers, or institutions
32
- for teaching, coursework, academic research, or non-commercial
33
- educational activities.
34
-
35
- "Commercial Use" means any use that:
36
-
37
- (a) is part of a product, service, or website that generates revenue
38
- directly or indirectly (including ads, subscriptions, paid
39
- features, consulting, or paid support);
40
- (b) is deployed in production by a for-profit entity; or
41
- (c) is used to build something that is sold, licensed, or monetized
42
- in any form.
43
-
44
- "Qualifying Contribution" means a pull request that has been
45
- reviewed and merged into the main branch of
46
- github.com/hanshapd/frakment, where all of the following are true:
47
-
48
- (a) The commit author is you (your GitHub username or email is
49
- recorded in the commit metadata).
50
- (b) The pull request makes at least one substantive change: fixes a
51
- bug, adds a feature, adds or corrects documentation, adds a
52
- test, translates content, or fixes a typo.
53
- (c) The pull request has not been reverted within 30 days of being
54
- merged.
55
-
56
- There is no minimum number of lines changed. A single-line typo fix
57
- qualifies. A documentation clarification qualifies. A test addition
58
- qualifies.
59
-
60
- A pull request that was closed without merging does NOT qualify. A
61
- pull request that was merged and later reverted within 30 days does
62
- NOT qualify.
63
-
64
- ====================================================================
65
- 2. GRANT OF RIGHTS
66
- ====================================================================
67
-
68
- Subject to the terms of this license, you are granted permission to:
69
-
70
- (a) View, read, and study the source code.
71
- (b) Fork, clone, and copy the repository.
72
- (c) Modify the source code.
73
- (d) Distribute the original or modified version.
74
-
75
- These rights apply without additional conditions for Personal Use and
76
- Educational Use, provided you satisfy the Attribution Requirements in
77
- Section 3.
78
-
79
- These rights apply for Commercial Use only after you have made a
80
- Qualifying Contribution, in addition to the Attribution Requirements.
81
-
82
- ====================================================================
83
- 3. ATTRIBUTION REQUIREMENTS (ALWAYS)
84
- ====================================================================
85
-
86
- Any use, copy, modification, or distribution of the Software, in
87
- whole or in part, for Personal, Educational, or Commercial Use, must:
88
-
89
- (a) Include the following attribution in the README, documentation,
90
- footer, or another reasonably visible user-facing location:
91
-
92
- Based on Frakment by hanshapd
93
- https://github.com/hanshapd/frakment
94
-
95
- (b) Include this LICENSE file unmodified in all copies and
96
- derivative works.
97
-
98
- (c) Clearly mark any modified versions as modified, with a brief
99
- description of the changes (e.g., in a CHANGELOG or README).
100
-
101
- ====================================================================
102
- 4. COMMERCIAL USE REQUIREMENT
103
- ====================================================================
104
-
105
- Before using the Software for any Commercial Use, you (or the legal
106
- entity you represent) must have made one Qualifying Contribution as
107
- defined in Section 1. This is the only path to Commercial Use.
108
-
109
- --------------------------------------------------------------------
110
- 4.1 SCOPE OF SATISFACTION
111
- --------------------------------------------------------------------
112
-
113
- Once a Qualifying Contribution has been made, the requirement is
114
- satisfied permanently for:
115
-
116
- - the individual who made the contribution, OR
117
- - the legal entity (company, organization, or team) on whose
118
- behalf the contribution was made,
119
-
120
- for all future Commercial Use of the Software by that individual or
121
- entity. No further contributions are required for future commercial
122
- use by the same party.
123
-
124
- --------------------------------------------------------------------
125
- 4.2 EVIDENCE ON REQUEST
126
- --------------------------------------------------------------------
127
-
128
- If hanshapd requests proof that the Commercial Use requirement has
129
- been satisfied, you must provide, within 30 days, a link to the
130
- merged pull request on GitHub.
131
-
132
- Refusal or failure to provide evidence on request voids the
133
- permission for Commercial Use until evidence is provided.
134
-
135
- --------------------------------------------------------------------
136
- 4.3 NO REQUIREMENT FOR NON-COMMERCIAL USE
137
- --------------------------------------------------------------------
138
-
139
- Personal Use and Educational Use do NOT require a Qualifying
140
- Contribution. Attribution (Section 3) is the only requirement for
141
- those uses.
142
-
143
- ====================================================================
144
- 5. RESTRICTIONS
145
- ====================================================================
146
-
147
- You may not:
148
-
149
- (a) Remove, hide, or alter copyright notices or the attribution
150
- required by Section 3.
151
- (b) Claim authorship of this Software or of any derivative work.
152
- (c) Use the names "Frakment", "frakment", or "hanshapd", or any
153
- associated logos or branding, to endorse or promote derivative
154
- works without prior written permission.
155
- (d) Sublicense this Software under different terms. Any party that
156
- receives a copy from you does so subject to this same license.
157
-
158
- ====================================================================
159
- 6. NO TRADEMARK LICENSE
160
- ====================================================================
161
-
162
- This license does not grant any rights to use the names "Frakment",
163
- "frakment", or "hanshapd", or any associated logos or marks, except
164
- as strictly necessary for the attribution described in Section 3.
165
-
166
- ====================================================================
167
- 7. WARRANTY
168
- ====================================================================
169
-
170
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
171
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
172
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
173
- NON-INFRINGEMENT.
174
-
175
- IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDER BE LIABLE FOR ANY
176
- CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
177
- TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE
178
- SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
179
-
180
- ====================================================================
181
- 8. ACCEPTANCE
182
- ====================================================================
183
-
184
- By accessing, using, copying, modifying, or distributing this project,
185
- you agree to be bound by the terms above. If you do not agree, you are
186
- not granted permission to use this project and must delete all copies
187
- in your possession.
188
-
189
- For permission requests or questions about this license, contact
190
- hanshapd on GitHub: https://github.com/hanshapd
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Frakment Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # frakment CLI
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/frakment?style=for-the-badge&color=000)](https://www.npmjs.com/package/frakment)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](./LICENSE)
5
+
3
6
  Monochrome design system and component registry CLI.
4
7
 
5
8
  ## Requirements
@@ -7,6 +10,19 @@ Monochrome design system and component registry CLI.
7
10
  - Node.js >= 22.12.0
8
11
  - Tailwind CSS v4 (`@import "tailwindcss";` in your `globals.css`)
9
12
 
13
+ ## Package managers
14
+
15
+ The commands below use `npx`. Every package manager works.
16
+
17
+ | Package manager | Run |
18
+ | :-- | :-- |
19
+ | npm | `npx frakment@latest <command>` |
20
+ | pnpm | `pnpm dlx frakment@latest <command>` |
21
+ | yarn | `yarn dlx frakment@latest <command>` |
22
+ | bun | `bunx frakment@latest <command>` |
23
+
24
+ The CLI auto-detects your package manager from the project lockfile when installing dependencies.
25
+
10
26
  ## Usage
11
27
 
12
28
  ### `frakment init`
@@ -42,6 +58,9 @@ Flags:
42
58
  - `--registry <url>` — override the registry URL (default: from `components.json`)
43
59
  - `--overwrite` — overwrite existing files without prompting
44
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
+
45
64
  ### `frakment list`
46
65
 
47
66
  ```bash
@@ -85,4 +104,4 @@ If your `globals.css` lives at `src/app/globals.css`, component files are writte
85
104
 
86
105
  ## License
87
106
 
88
- Source-available. See [LICENSE](./LICENSE). Commercial use requires at least one merged pull request to [arrafipd/frakment](https://github.com/arrafipd/frakment).
107
+ MIT licensed. See [LICENSE](./LICENSE).
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.4",
3
+ "version": "0.0.7",
4
4
  "description": "CLI and design system for the frakment component registry",
5
5
  "keywords": [
6
6
  "components",
@@ -9,8 +9,13 @@
9
9
  "registry",
10
10
  "tailwindcss"
11
11
  ],
12
- "license": "SEE LICENSE IN LICENSE",
13
- "author": "arrafipd (https://github.com/arrafipd)",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/arrafipd/frakment.git",
15
+ "directory": "packages/cli"
16
+ },
17
+ "license": "MIT",
18
+ "author": "The Frakment Authors (https://github.com/arrafipd/frakment)",
14
19
  "type": "module",
15
20
  "bin": {
16
21
  "frakment": "./bin/index.js"
@@ -18,13 +23,14 @@
18
23
  "files": [
19
24
  "bin",
20
25
  "src",
21
- "templates"
26
+ "templates",
27
+ "LICENSE"
22
28
  ],
23
29
  "dependencies": {
24
- "chalk": "^5.3.0",
30
+ "chalk": "^6.0.1",
25
31
  "commander": "^15.0.0",
26
32
  "execa": "^10.2.1",
27
- "ora": "^8.1.1",
33
+ "ora": "^10.0.0",
28
34
  "prompts": "^2.4.2"
29
35
  },
30
36
  "engines": {
@@ -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
+ });