marvelous-ui-library 1.0.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/LICENSE +21 -0
- package/README.md +20 -0
- package/bin.mjs +153 -0
- package/lib/archive.mjs +82 -0
- package/lib/installer.mjs +216 -0
- package/lib/managed.mjs +54 -0
- package/package.json +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quentin Fankrache EI
|
|
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
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Marvelous UI Library CLI
|
|
2
|
+
|
|
3
|
+
Install the Free Pack in a web project:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx marvelous-ui-library@latest init
|
|
7
|
+
npx marvelous-ui-library@latest add dialog tabs toast
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`init` detects the project and supported agent configuration. In an interactive terminal it asks whether to install the Pack in this project or in your user profile, then asks for Free or Pro. For automation, use `--scope project|user --edition free|pro`. A noninteractive invocation defaults to Free in the current project.
|
|
11
|
+
|
|
12
|
+
The Pro Pack requires a Polar license key. Enter it when prompted or pass it through the `MARVELOUS_LICENSE_KEY` environment variable. The key is not written to a project file. A project Pack remains at its installed version until you run:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx marvelous-ui-library@latest update
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`update` compares installed component hashes before replacing files. Locally customized files are reported and preserved. A Pack installed in your user profile does not change copies already installed in other projects. A current Pro license key, including a key from an active yearly Renewal, is required to fetch a newer Pro Pack. For a private Renewal link, contact hello@marvelous-ui.com.
|
|
19
|
+
|
|
20
|
+
The Free Pack is MIT licensed. The Pro Pack is subject to the commercial license shipped in its `LICENSE.md`.
|
package/bin.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { locations, readState, latestRelease, downloadRelease, installPack, addComponents, detectFramework } from "./lib/installer.mjs";
|
|
7
|
+
|
|
8
|
+
const HELP = `Marvelous UI
|
|
9
|
+
|
|
10
|
+
npx marvelous-ui-library@latest init [--scope project|user] [--edition free|pro]
|
|
11
|
+
npx marvelous-ui-library@latest add <component...> [--scope project|user] [--out path]
|
|
12
|
+
npx marvelous-ui-library@latest update [--scope project|user]
|
|
13
|
+
|
|
14
|
+
Options: --project path, --clients names. For Pro, set MARVELOUS_LICENSE_KEY or enter the Polar key when prompted.
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
function options(args) {
|
|
18
|
+
const values = new Set(["scope", "edition", "project", "out", "clients"]);
|
|
19
|
+
const parsed = { slugs: [] };
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const arg = args[i];
|
|
22
|
+
if (arg === "--help" || arg === "-h") parsed.help = true;
|
|
23
|
+
else if (arg.startsWith("--")) {
|
|
24
|
+
const name = arg.slice(2);
|
|
25
|
+
if (!values.has(name) || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`Invalid option: ${arg}`);
|
|
26
|
+
parsed[name] = args[++i];
|
|
27
|
+
} else parsed.slugs.push(arg);
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function ask(question, fallback = "") {
|
|
33
|
+
if (!process.stdin.isTTY) return fallback;
|
|
34
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
35
|
+
try { return (await rl.question(question)).trim() || fallback; }
|
|
36
|
+
finally { rl.close(); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function existingScope(project) {
|
|
40
|
+
return readState(locations("project", project).state) ? "project" : readState(locations("user", project).state) ? "user" : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function licenseKey() {
|
|
44
|
+
const key = process.env.MARVELOUS_LICENSE_KEY?.trim() || await ask("Polar license key: ");
|
|
45
|
+
if (!key) throw new Error("A Pro license key is required. Set MARVELOUS_LICENSE_KEY or run interactively.");
|
|
46
|
+
return key;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function report(result) {
|
|
50
|
+
for (const kind of ["added", "updated", "unchanged", "conflicts"]) {
|
|
51
|
+
const paths = result[kind];
|
|
52
|
+
if (paths?.length) console.log(`${kind}: ${paths.length}${kind === "conflicts" ? ` (${paths.join(", ")})` : ""}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function chosenOut(project, supplied, componentState) {
|
|
57
|
+
return supplied ?? componentState?.out ?? (existsSync(join(project, "src")) ? "src/marvelous" : "marvelous");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pluginRefresh(place) {
|
|
61
|
+
console.log("If your agent installed a copy of the plugin, register the updated Pack again:");
|
|
62
|
+
execFileSync(process.execPath, [join(place.pack, "scripts", "init-agent.mjs"), "--plugin"], { stdio: "inherit" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function init(opts, project) {
|
|
66
|
+
const scope = opts.scope ?? existingScope(project) ?? await ask("Install in this project or for this user? [project/user] ", "project");
|
|
67
|
+
const place = locations(scope, project);
|
|
68
|
+
const current = readState(place.state);
|
|
69
|
+
const edition = opts.edition ?? current?.edition ?? await ask("Free or Pro Pack? [free/pro] ", "free");
|
|
70
|
+
if (!["project", "user"].includes(scope) || !["free", "pro"].includes(edition)) throw new Error("Choose --scope project|user and --edition free|pro.");
|
|
71
|
+
console.log(`Marvelous UI: ${edition} Pack, ${scope} scope, ${detectFramework(project)} project`);
|
|
72
|
+
if (!current || current.edition !== edition) {
|
|
73
|
+
const meta = await latestRelease(edition);
|
|
74
|
+
const bytes = await downloadRelease(edition, meta, edition === "pro" ? await licenseKey() : "");
|
|
75
|
+
installPack(bytes, edition, meta, place);
|
|
76
|
+
console.log(`Pack ${meta.version} installed in ${place.pack}`);
|
|
77
|
+
} else console.log(`Pack ${current.version} is installed. Run update to fetch a newer version.`);
|
|
78
|
+
if (scope === "project") {
|
|
79
|
+
const out = chosenOut(project, opts.out, readState(place.components));
|
|
80
|
+
const args = [join(place.pack, "scripts", "init-agent.mjs"), project, "--out", out];
|
|
81
|
+
if (opts.clients) args.push("--clients", opts.clients);
|
|
82
|
+
execFileSync(process.execPath, args, { stdio: "inherit" });
|
|
83
|
+
} else {
|
|
84
|
+
console.log("Register the user Pack with your agent using these commands:");
|
|
85
|
+
execFileSync(process.execPath, [join(place.pack, "scripts", "init-agent.mjs"), "--plugin"], { stdio: "inherit" });
|
|
86
|
+
}
|
|
87
|
+
console.log("Ready. Ask your coding agent to build a UI with Marvelous UI.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function add(opts, project) {
|
|
91
|
+
const scope = opts.scope ?? existingScope(project);
|
|
92
|
+
if (!scope) throw new Error("Run marvelous-ui-library init first.");
|
|
93
|
+
const place = locations(scope, project);
|
|
94
|
+
const out = chosenOut(project, opts.out, readState(place.components));
|
|
95
|
+
const result = addComponents({ place, project, slugs: opts.slugs, out });
|
|
96
|
+
report(result);
|
|
97
|
+
console.log(result.usage);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function update(opts, project) {
|
|
101
|
+
const scope = opts.scope ?? existingScope(project);
|
|
102
|
+
if (!scope) throw new Error("Run marvelous-ui-library init first.");
|
|
103
|
+
const place = locations(scope, project);
|
|
104
|
+
const current = readState(place.state);
|
|
105
|
+
const componentState = readState(place.components);
|
|
106
|
+
const hasComponents = componentState?.slugs?.length && componentState.sourceScope === scope;
|
|
107
|
+
const pinnedOlder = hasComponents && scope === "user" &&
|
|
108
|
+
(componentState.packVersion !== current.version || componentState.packSha256 !== current.sha256 || componentState.packEdition !== current.edition);
|
|
109
|
+
const meta = await latestRelease(current.edition);
|
|
110
|
+
if (meta.version === current.version) {
|
|
111
|
+
if (meta.sha256 !== current.sha256) throw new Error(`Pack ${meta.version} changed without a version bump. Contact hello@marvelous-ui.com.`);
|
|
112
|
+
if (pinnedOlder) {
|
|
113
|
+
console.log(`Updating this project's components from Pack ${componentState.packVersion} to ${current.version}:`);
|
|
114
|
+
const preview = addComponents({ place, project, slugs: componentState.slugs, out: componentState.out, dryRun: true, useCurrentPack: true });
|
|
115
|
+
report(preview);
|
|
116
|
+
if (preview.conflicts.length) console.log("Customized files will be preserved. Merge those files manually if you want the new version.");
|
|
117
|
+
report(addComponents({ place, project, slugs: componentState.slugs, out: componentState.out, useCurrentPack: true }));
|
|
118
|
+
pluginRefresh(place);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
return console.log(`Pack ${current.version} is already current.`);
|
|
122
|
+
}
|
|
123
|
+
console.log(`Pack ${current.version} -> ${meta.version}. Installed project files will be compared before replacement.`);
|
|
124
|
+
const bytes = await downloadRelease(current.edition, meta, current.edition === "pro" ? await licenseKey() : "");
|
|
125
|
+
installPack(bytes, current.edition, meta, place, {
|
|
126
|
+
beforeCommit: (stagedPack) => {
|
|
127
|
+
if (!hasComponents) return;
|
|
128
|
+
const preview = addComponents({ place: { ...place, pack: stagedPack }, project, slugs: componentState.slugs, out: componentState.out, dryRun: true, useCurrentPack: true });
|
|
129
|
+
console.log("Project component changes:");
|
|
130
|
+
report(preview);
|
|
131
|
+
if (preview.conflicts.length) console.log("Customized files will be preserved. Merge those files manually if you want the new version.");
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
console.log(`Pack ${meta.version} installed.`);
|
|
135
|
+
if (hasComponents) report(addComponents({ place, project, slugs: componentState.slugs, out: componentState.out, useCurrentPack: true }));
|
|
136
|
+
if (scope === "user") console.log("Other projects stay at their installed versions until you run update in those projects.");
|
|
137
|
+
pluginRefresh(place);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function run(args = process.argv.slice(2)) {
|
|
141
|
+
const command = args[0];
|
|
142
|
+
if (command === "--help" || command === "-h") return console.log(HELP);
|
|
143
|
+
const opts = options(args.slice(1));
|
|
144
|
+
if (!command || opts.help || command === "help") return console.log(HELP);
|
|
145
|
+
if (!["init", "add", "update"].includes(command)) throw new Error(`Unknown command: ${command}\n${HELP}`);
|
|
146
|
+
const project = resolve(opts.project ?? process.cwd());
|
|
147
|
+
if (!existsSync(project)) throw new Error(`Project does not exist: ${project}`);
|
|
148
|
+
if (command === "init") return init(opts, project);
|
|
149
|
+
if (command === "add") return add(opts, project);
|
|
150
|
+
return update(opts, project);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
run().catch((error) => { console.error(`Marvelous UI: ${error.message}`); process.exitCode = 1; });
|
package/lib/archive.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { inflateRawSync } from "node:zlib";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const END = 0x06054b50;
|
|
6
|
+
const CENTRAL = 0x02014b50;
|
|
7
|
+
const LOCAL = 0x04034b50;
|
|
8
|
+
const MAX_ARCHIVE = 100 * 1024 * 1024;
|
|
9
|
+
const MAX_EXTRACTED = 250 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function endRecord(zip) {
|
|
12
|
+
for (let at = zip.length - 22; at >= Math.max(0, zip.length - 65557); at--) {
|
|
13
|
+
if (zip.readUInt32LE(at) === END && at + 22 + zip.readUInt16LE(at + 20) === zip.length) return at;
|
|
14
|
+
}
|
|
15
|
+
throw new Error("The Pack archive has no ZIP directory.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Extract a commercial Pack ZIP without trusting names, symlinks or compressed sizes. */
|
|
19
|
+
export function extractPack(zip, destination, root) {
|
|
20
|
+
if (!Buffer.isBuffer(zip) || zip.length > MAX_ARCHIVE) throw new Error("Pack archive exceeds the size limit.");
|
|
21
|
+
const end = endRecord(zip);
|
|
22
|
+
const count = zip.readUInt16LE(end + 10);
|
|
23
|
+
const start = zip.readUInt32LE(end + 16);
|
|
24
|
+
const centralSize = zip.readUInt32LE(end + 12);
|
|
25
|
+
if (count === 0 || count === 0xffff || start + centralSize > end) throw new Error("Invalid ZIP directory.");
|
|
26
|
+
const entries = [];
|
|
27
|
+
const names = new Set();
|
|
28
|
+
let offset = start;
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (let i = 0; i < count; i++) {
|
|
31
|
+
if (offset + 46 > end || zip.readUInt32LE(offset) !== CENTRAL) throw new Error("Invalid ZIP entry.");
|
|
32
|
+
const flags = zip.readUInt16LE(offset + 8);
|
|
33
|
+
const method = zip.readUInt16LE(offset + 10);
|
|
34
|
+
const compressed = zip.readUInt32LE(offset + 20);
|
|
35
|
+
const size = zip.readUInt32LE(offset + 24);
|
|
36
|
+
const nameLength = zip.readUInt16LE(offset + 28);
|
|
37
|
+
const extraLength = zip.readUInt16LE(offset + 30);
|
|
38
|
+
const commentLength = zip.readUInt16LE(offset + 32);
|
|
39
|
+
const local = zip.readUInt32LE(offset + 42);
|
|
40
|
+
const madeBy = zip.readUInt16LE(offset + 4) >> 8;
|
|
41
|
+
const mode = zip.readUInt32LE(offset + 38) >>> 16;
|
|
42
|
+
const nameEnd = offset + 46 + nameLength;
|
|
43
|
+
if (nameEnd > end || flags & 1 || ![0, 8].includes(method) || (madeBy === 3 && (mode & 0o170000) === 0o120000)) {
|
|
44
|
+
throw new Error("Unsupported or unsafe ZIP entry.");
|
|
45
|
+
}
|
|
46
|
+
const name = zip.subarray(offset + 46, nameEnd).toString("utf8");
|
|
47
|
+
const rel = name.startsWith(`${root}/`) ? name.slice(root.length + 1) : "";
|
|
48
|
+
const directory = rel.endsWith("/");
|
|
49
|
+
const parts = (directory ? rel.slice(0, -1) : rel).split("/");
|
|
50
|
+
if (!rel || rel.includes("\\") || parts.some((part) => !part || part === "." || part === ".." || part.includes(":"))) {
|
|
51
|
+
if (name === `${root}/`) { offset = nameEnd + extraLength + commentLength; continue; }
|
|
52
|
+
throw new Error(`Unsafe Pack path: ${name}`);
|
|
53
|
+
}
|
|
54
|
+
if (directory) {
|
|
55
|
+
if (size !== 0 || compressed !== 0) throw new Error(`Invalid ZIP directory: ${name}`);
|
|
56
|
+
offset = nameEnd + extraLength + commentLength;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const portableName = rel.toLowerCase();
|
|
60
|
+
if (names.has(portableName) || local + 30 > start || zip.readUInt32LE(local) !== LOCAL) throw new Error("Invalid or duplicate ZIP entry.");
|
|
61
|
+
names.add(portableName);
|
|
62
|
+
total += size;
|
|
63
|
+
if (total > MAX_EXTRACTED) throw new Error("Pack expands beyond the size limit.");
|
|
64
|
+
const dataStart = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
|
|
65
|
+
if (dataStart + compressed > start) throw new Error("ZIP entry exceeds its bounds.");
|
|
66
|
+
entries.push({ rel, method, compressed, size, dataStart });
|
|
67
|
+
offset = nameEnd + extraLength + commentLength;
|
|
68
|
+
}
|
|
69
|
+
if (offset !== start + centralSize) throw new Error("Invalid ZIP directory size.");
|
|
70
|
+
const files = entries.map(({ rel, method, compressed, size, dataStart }) => {
|
|
71
|
+
const data = zip.subarray(dataStart, dataStart + compressed);
|
|
72
|
+
const contents = method === 0 ? data : inflateRawSync(data, { maxOutputLength: size + 1 });
|
|
73
|
+
if (contents.length !== size) throw new Error(`Invalid ZIP entry size: ${rel}`);
|
|
74
|
+
return { rel, contents };
|
|
75
|
+
});
|
|
76
|
+
for (const { rel, contents } of files) {
|
|
77
|
+
const target = join(destination, ...rel.split("/"));
|
|
78
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
79
|
+
writeFileSync(target, contents);
|
|
80
|
+
}
|
|
81
|
+
return files.map((file) => file.rel);
|
|
82
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { extractPack } from "./archive.mjs";
|
|
6
|
+
import { copyManagedFiles, hash } from "./managed.mjs";
|
|
7
|
+
|
|
8
|
+
export const ORIGIN = "https://marvelous-ui.com";
|
|
9
|
+
const MAX_DOWNLOAD = 100 * 1024 * 1024;
|
|
10
|
+
const REQUEST_TIMEOUT = 60000;
|
|
11
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
12
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
13
|
+
|
|
14
|
+
export function locations(scope, project = process.cwd(), home = homedir()) {
|
|
15
|
+
if (!["project", "user"].includes(scope)) throw new Error("Scope must be project or user.");
|
|
16
|
+
const projectBase = join(resolve(project), ".marvelous-ui");
|
|
17
|
+
try { if (lstatSync(projectBase).isSymbolicLink()) throw new Error("The project Pack directory cannot be a symbolic link."); }
|
|
18
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
19
|
+
const base = scope === "user" ? join(home, ".marvelous-ui") : projectBase;
|
|
20
|
+
return { scope, base, pack: join(base, "pack"), state: join(base, "install.json"), components: join(projectBase, "components.json") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readState(file) {
|
|
24
|
+
if (!existsSync(file)) return null;
|
|
25
|
+
const state = JSON.parse(readFileSync(file, "utf8"));
|
|
26
|
+
if (state.schema !== 1) throw new Error(`Unsupported Marvelous UI state: ${file}`);
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveState(file, state) {
|
|
31
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
32
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
33
|
+
writeFileSync(tmp, JSON.stringify({ schema: 1, ...state }, null, 2) + "\n", { mode: 0o600 });
|
|
34
|
+
renameSync(tmp, file);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validMeta(meta) {
|
|
38
|
+
if (!meta || !VERSION.test(meta.version) || !SHA256.test(meta.sha256) || !Number.isSafeInteger(meta.bytes) || meta.bytes < 1 || meta.bytes > MAX_DOWNLOAD) {
|
|
39
|
+
throw new Error("The release manifest is invalid.");
|
|
40
|
+
}
|
|
41
|
+
return { version: meta.version, sha256: meta.sha256, bytes: meta.bytes };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function protectedProjectPack(place) {
|
|
45
|
+
if (place.scope !== "project") return;
|
|
46
|
+
const file = join(place.base, ".gitignore");
|
|
47
|
+
try { if (lstatSync(file).isSymbolicLink()) throw new Error("The Pack ignore file cannot be a symbolic link."); }
|
|
48
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
49
|
+
let rules = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
50
|
+
if (!rules.split(/\r?\n/).includes("/pack/")) {
|
|
51
|
+
rules = `${rules.trimEnd()}${rules.trim() ? "\n" : ""}/pack/\n/.stage-*/\n/.backup-*/\n`;
|
|
52
|
+
writeFileSync(file, rules);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const tracked = execFileSync("git", ["ls-files", "--cached", "--", ".marvelous-ui/pack"], {
|
|
56
|
+
cwd: dirname(place.base), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
57
|
+
});
|
|
58
|
+
if (tracked.trim()) throw new Error("The Pack is tracked by Git. Remove .marvelous-ui/pack from the Git index before continuing.");
|
|
59
|
+
} catch (error) { if (error.message?.startsWith("The Pack is tracked")) throw error; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function response(fetcher, url, init = {}) {
|
|
63
|
+
let res;
|
|
64
|
+
try { res = await fetcher(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT) }); }
|
|
65
|
+
catch { throw new Error("The Marvelous UI download service is unavailable. Try again later."); }
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const code = await res.json().then((body) => body.error).catch(() => "");
|
|
68
|
+
if (res.status === 403 && code === "invalid_key") throw new Error("This license key is invalid or expired. For a private renewal link, contact hello@marvelous-ui.com.");
|
|
69
|
+
if (res.status === 404) throw new Error("This Pack version is unavailable. Contact hello@marvelous-ui.com.");
|
|
70
|
+
throw new Error(`The Marvelous UI download service failed (${res.status}${code ? `, ${code}` : ""}). Try again later.`);
|
|
71
|
+
}
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function latestRelease(edition, { fetcher = fetch, origin = ORIGIN } = {}) {
|
|
76
|
+
if (!["free", "pro"].includes(edition)) throw new Error("Edition must be free or pro.");
|
|
77
|
+
const url = edition === "free" ? `${origin}/downloads/marvelous-ui-free.json` : `${origin}/api/pack`;
|
|
78
|
+
const res = await response(fetcher, url);
|
|
79
|
+
let metadata;
|
|
80
|
+
try { metadata = await res.json(); }
|
|
81
|
+
catch { throw new Error("The Marvelous UI release metadata is unavailable. Try again later."); }
|
|
82
|
+
return validMeta(metadata);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function downloadRelease(edition, meta, key, { fetcher = fetch, origin = ORIGIN } = {}) {
|
|
86
|
+
validMeta(meta);
|
|
87
|
+
if (edition === "pro" && !key) throw new Error("Set MARVELOUS_LICENSE_KEY or enter your Polar license key when prompted.");
|
|
88
|
+
const url = edition === "free" ? `${origin}/downloads/marvelous-ui-free.zip` : `${origin}/api/pack`;
|
|
89
|
+
const init = edition === "pro" ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key, version: meta.version }) } : {};
|
|
90
|
+
const res = await response(fetcher, url, init);
|
|
91
|
+
if (!res.body) throw new Error("The Pack download was empty.");
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let received = 0;
|
|
94
|
+
try {
|
|
95
|
+
for await (const chunk of res.body) {
|
|
96
|
+
received += chunk.length;
|
|
97
|
+
if (received > meta.bytes || received > MAX_DOWNLOAD) throw new Error("The Pack download exceeds its expected size.");
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.message === "The Pack download exceeds its expected size.") throw error;
|
|
102
|
+
throw new Error("The Pack download was interrupted. Try again later.");
|
|
103
|
+
}
|
|
104
|
+
const bytes = Buffer.concat(chunks, received);
|
|
105
|
+
if (bytes.length !== meta.bytes || hash(bytes) !== meta.sha256) throw new Error("The downloaded Pack failed its size or SHA-256 check.");
|
|
106
|
+
return bytes;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validPack(dir, edition, version) {
|
|
110
|
+
const registry = JSON.parse(readFileSync(join(dir, "registry.json"), "utf8"));
|
|
111
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
112
|
+
if (registry.version !== version || pkg.version !== version || !Array.isArray(registry.items) || !registry.items.length) throw new Error("The Pack version or registry is invalid.");
|
|
113
|
+
if (edition === "pro" && !existsSync(join(dir, "LICENSE.md"))) throw new Error("The Pro license is missing from the Pack.");
|
|
114
|
+
if (edition === "free" && !existsSync(join(dir, "LICENSE"))) throw new Error("The Free license is missing from the Pack.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Install a verified ZIP atomically so a failed download or npm install keeps the prior Pack. */
|
|
118
|
+
export function installPack(bytes, edition, meta, place, { installDependencies = true, beforeCommit } = {}) {
|
|
119
|
+
validMeta(meta);
|
|
120
|
+
if (bytes.length !== meta.bytes || hash(bytes) !== meta.sha256) throw new Error("The Pack failed its size or SHA-256 check.");
|
|
121
|
+
mkdirSync(place.base, { recursive: true });
|
|
122
|
+
protectedProjectPack(place);
|
|
123
|
+
const stage = mkdtempSync(join(place.base, ".stage-"));
|
|
124
|
+
const backup = join(place.base, `.backup-${process.pid}`);
|
|
125
|
+
try {
|
|
126
|
+
extractPack(bytes, stage, `marvelous-ui-${edition}`);
|
|
127
|
+
validPack(stage, edition, meta.version);
|
|
128
|
+
if (installDependencies) {
|
|
129
|
+
const npmArgs = ["ci", "--omit=dev", "--ignore-scripts", "--no-audit"];
|
|
130
|
+
if (process.env.npm_execpath?.endsWith(".js")) execFileSync(process.execPath, [process.env.npm_execpath, ...npmArgs], { cwd: stage, stdio: "inherit" });
|
|
131
|
+
else execFileSync("npm", npmArgs, { cwd: stage, stdio: "inherit", shell: process.platform === "win32" });
|
|
132
|
+
}
|
|
133
|
+
beforeCommit?.(stage);
|
|
134
|
+
if (existsSync(backup)) throw new Error("A previous installation is still being recovered.");
|
|
135
|
+
const prior = existsSync(place.pack);
|
|
136
|
+
const oldState = prior ? readState(place.state) : null;
|
|
137
|
+
if (prior) renameSync(place.pack, backup);
|
|
138
|
+
try { renameSync(stage, place.pack); }
|
|
139
|
+
catch (error) { if (prior) renameSync(backup, place.pack); throw error; }
|
|
140
|
+
const archive = oldState && place.scope === "user"
|
|
141
|
+
? join(place.base, "versions", `${oldState.edition}-${oldState.version}-${oldState.sha256}`) : null;
|
|
142
|
+
let archived = false;
|
|
143
|
+
try {
|
|
144
|
+
if (archive && !existsSync(archive)) {
|
|
145
|
+
mkdirSync(dirname(archive), { recursive: true });
|
|
146
|
+
renameSync(backup, archive);
|
|
147
|
+
archived = true;
|
|
148
|
+
}
|
|
149
|
+
saveState(place.state, { edition, version: meta.version, sha256: meta.sha256, scope: place.scope ?? "project" });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
rmSync(place.pack, { recursive: true, force: true });
|
|
153
|
+
if (prior) renameSync(archived ? archive : backup, place.pack);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (prior && !archived) rmSync(backup, { recursive: true, force: true });
|
|
157
|
+
} finally { if (existsSync(stage)) rmSync(stage, { recursive: true, force: true }); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function filesUnder(root, current = root) {
|
|
161
|
+
return readdirSync(current).flatMap((name) => {
|
|
162
|
+
const abs = join(current, name);
|
|
163
|
+
return statSync(abs).isDirectory() ? filesUnder(root, abs) : [relative(root, abs).split(sep).join("/")];
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function targetFolder(project, out) {
|
|
168
|
+
const target = resolve(project, out);
|
|
169
|
+
const rel = relative(resolve(project), target);
|
|
170
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error("The component output must be inside the project.");
|
|
171
|
+
let cursor = resolve(project);
|
|
172
|
+
for (const part of rel.split(sep)) {
|
|
173
|
+
cursor = join(cursor, part);
|
|
174
|
+
try { if (lstatSync(cursor).isSymbolicLink()) throw new Error("The component output cannot pass through a symbolic link."); }
|
|
175
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
176
|
+
}
|
|
177
|
+
return target;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Stage the existing Pack CLI's dependency closure and merge only managed, unedited files. */
|
|
181
|
+
export function addComponents({ place, project, slugs, out, dryRun = false, useCurrentPack = false }) {
|
|
182
|
+
if (!slugs.length || slugs.some((slug) => !/^[a-z0-9][a-z0-9-]*$/.test(slug))) throw new Error("Specify valid component slugs.");
|
|
183
|
+
const state = readState(place.state);
|
|
184
|
+
if (!state) throw new Error("Run marvelous-ui-library init first.");
|
|
185
|
+
const target = targetFolder(project, out);
|
|
186
|
+
const stage = mkdtempSync(join(tmpdir(), "mv-components-"));
|
|
187
|
+
try {
|
|
188
|
+
const prior = readState(place.components) ?? { slugs: [], out, files: {} };
|
|
189
|
+
if (prior.out !== out && prior.slugs.length) throw new Error(`Components are tracked in ${prior.out}; use --out ${prior.out}.`);
|
|
190
|
+
let selectedPack = place.pack;
|
|
191
|
+
let selectedState = state;
|
|
192
|
+
if (!useCurrentPack && place.scope === "user" && prior.slugs.length && prior.packVersion &&
|
|
193
|
+
(prior.packVersion !== state.version || prior.packSha256 !== state.sha256 || prior.packEdition !== state.edition)) {
|
|
194
|
+
if (!["free", "pro"].includes(prior.packEdition) || !VERSION.test(prior.packVersion) || !SHA256.test(prior.packSha256)) {
|
|
195
|
+
throw new Error("The project's pinned Pack state is invalid. Run update in this project.");
|
|
196
|
+
}
|
|
197
|
+
selectedPack = join(place.base, "versions", `${prior.packEdition}-${prior.packVersion}-${prior.packSha256}`);
|
|
198
|
+
if (!existsSync(selectedPack)) throw new Error("This project's pinned Pack version is unavailable. Run update in this project.");
|
|
199
|
+
selectedState = { version: prior.packVersion, sha256: prior.packSha256, edition: prior.packEdition };
|
|
200
|
+
}
|
|
201
|
+
const packCli = join(selectedPack, "scripts", "add.mjs");
|
|
202
|
+
const usage = execFileSync(process.execPath, [packCli, ...slugs, "--out", stage], { encoding: "utf8" });
|
|
203
|
+
const files = filesUnder(stage);
|
|
204
|
+
const result = copyManagedFiles(stage, target, files, prior.files, { dryRun });
|
|
205
|
+
if (!dryRun) saveState(place.components, { out, slugs: [...new Set([...prior.slugs, ...slugs])].sort(), files: result.hashes, sourceScope: state.scope, packVersion: selectedState.version, packSha256: selectedState.sha256, packEdition: selectedState.edition });
|
|
206
|
+
return { ...result, usage: usage.replaceAll(stage, target) };
|
|
207
|
+
} finally { rmSync(stage, { recursive: true, force: true }); }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function detectFramework(project) {
|
|
211
|
+
const file = join(project, "package.json");
|
|
212
|
+
if (!existsSync(file)) return "HTML";
|
|
213
|
+
const pkg = JSON.parse(readFileSync(file, "utf8"));
|
|
214
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
215
|
+
return Object.entries({ next: "Next.js", nuxt: "Nuxt", "@angular/core": "Angular", "@sveltejs/kit": "SvelteKit", astro: "Astro", vue: "Vue", svelte: "Svelte", react: "React" }).find(([key]) => deps[key])?.[1] ?? "HTML";
|
|
216
|
+
}
|
package/lib/managed.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
6
|
+
|
|
7
|
+
function safeTarget(root, rel) {
|
|
8
|
+
if (typeof rel !== "string" || !rel || isAbsolute(rel) || rel.includes("\\") || rel.split("/").some((p) => !p || p === "." || p === ".." || p.includes(":"))) {
|
|
9
|
+
throw new Error(`Unsafe file path: ${rel}`);
|
|
10
|
+
}
|
|
11
|
+
const target = resolve(root, ...rel.split("/"));
|
|
12
|
+
if (!target.startsWith(resolve(root) + sep)) throw new Error(`File escapes the project: ${rel}`);
|
|
13
|
+
let cursor = resolve(root);
|
|
14
|
+
try { if (lstatSync(cursor).isSymbolicLink()) throw new Error(`Symbolic link in install path: ${rel}`); }
|
|
15
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
16
|
+
for (const part of rel.split("/")) {
|
|
17
|
+
cursor = join(cursor, part);
|
|
18
|
+
try { if (lstatSync(cursor).isSymbolicLink()) throw new Error(`Symbolic link in install path: ${rel}`); }
|
|
19
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
20
|
+
}
|
|
21
|
+
return target;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Compare desired files with the hashes recorded when Marvelous last wrote them. */
|
|
25
|
+
export function copyManagedFiles(source, target, files, previous = {}, { dryRun = false } = {}) {
|
|
26
|
+
const result = { added: [], updated: [], unchanged: [], conflicts: [], hashes: { ...previous } };
|
|
27
|
+
const writes = [];
|
|
28
|
+
for (const rel of [...new Set(files)].sort()) {
|
|
29
|
+
const from = safeTarget(source, rel);
|
|
30
|
+
const to = safeTarget(target, rel);
|
|
31
|
+
const bytes = readFileSync(from);
|
|
32
|
+
const next = hash(bytes);
|
|
33
|
+
if (!existsSync(to)) {
|
|
34
|
+
result.added.push(rel);
|
|
35
|
+
result.hashes[rel] = next;
|
|
36
|
+
writes.push([to, bytes]);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const current = hash(readFileSync(to));
|
|
40
|
+
if (current === next) {
|
|
41
|
+
result.unchanged.push(rel);
|
|
42
|
+
result.hashes[rel] = next;
|
|
43
|
+
} else if (previous[rel] === current) {
|
|
44
|
+
result.updated.push(rel);
|
|
45
|
+
result.hashes[rel] = next;
|
|
46
|
+
writes.push([to, bytes]);
|
|
47
|
+
} else result.conflicts.push(rel);
|
|
48
|
+
}
|
|
49
|
+
if (!dryRun) for (const [to, bytes] of writes) {
|
|
50
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
51
|
+
writeFileSync(to, bytes);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "marvelous-ui-library",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Install and update the Marvelous UI component Pack for coding agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"marvelous-ui-library": "bin.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin.mjs",
|
|
12
|
+
"lib/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/baz-fkr/marvelous-ui-library.git",
|
|
24
|
+
"directory": "cli"
|
|
25
|
+
}
|
|
26
|
+
}
|