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 +21 -190
- package/README.md +20 -1
- package/bin/index.js +5 -0
- package/package.json +12 -6
- package/src/commands/add.js +232 -17
- package/src/commands/add.test.mjs +89 -0
package/LICENSE
CHANGED
|
@@ -1,190 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/frakment)
|
|
4
|
+
[](./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
|
-
|
|
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.
|
|
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
|
-
"
|
|
13
|
-
|
|
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": "^
|
|
30
|
+
"chalk": "^6.0.1",
|
|
25
31
|
"commander": "^15.0.0",
|
|
26
32
|
"execa": "^10.2.1",
|
|
27
|
-
"ora": "^
|
|
33
|
+
"ora": "^10.0.0",
|
|
28
34
|
"prompts": "^2.4.2"
|
|
29
35
|
},
|
|
30
36
|
"engines": {
|
package/src/commands/add.js
CHANGED
|
@@ -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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
+
});
|