frakment 0.0.6 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/index.js +5 -0
- package/package.json +1 -1
- package/src/commands/add.js +232 -17
- package/src/commands/add.test.mjs +89 -0
- package/src/commands/init.js +10 -6
package/README.md
CHANGED
|
@@ -58,6 +58,9 @@ Flags:
|
|
|
58
58
|
- `--registry <url>` — override the registry URL (default: from `components.json`)
|
|
59
59
|
- `--overwrite` — overwrite existing files without prompting
|
|
60
60
|
|
|
61
|
+
- `--dry-run` — show planned file and dependency changes without changing the project
|
|
62
|
+
- `--skip-dependencies` — write component files without installing missing npm dependencies
|
|
63
|
+
|
|
61
64
|
### `frakment list`
|
|
62
65
|
|
|
63
66
|
```bash
|
package/bin/index.js
CHANGED
|
@@ -37,7 +37,12 @@ program
|
|
|
37
37
|
program
|
|
38
38
|
.command("add <component>")
|
|
39
39
|
.description("Add a component from the frakment registry")
|
|
40
|
+
.option(
|
|
41
|
+
"--dry-run",
|
|
42
|
+
"Show planned file and dependency changes without writing"
|
|
43
|
+
)
|
|
40
44
|
.option("--registry <url>", "Custom registry URL")
|
|
45
|
+
.option("--skip-dependencies", "Do not install missing npm dependencies")
|
|
41
46
|
.option("--overwrite", "Overwrite existing files", false)
|
|
42
47
|
.action(add);
|
|
43
48
|
|
package/package.json
CHANGED
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
|
+
});
|
package/src/commands/init.js
CHANGED
|
@@ -140,14 +140,18 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
140
140
|
await mkdir(path.join(cwd, "lib"), { recursive: true });
|
|
141
141
|
await writeFile(
|
|
142
142
|
path.join(cwd, "lib/animation.ts"),
|
|
143
|
-
`export const
|
|
143
|
+
`export const springSoft = { type: "spring", stiffness: 200, damping: 30 } as const
|
|
144
144
|
|
|
145
|
-
export const
|
|
146
|
-
export const
|
|
147
|
-
export const
|
|
145
|
+
export const easeInOut = [0.4, 0, 0.2, 1] as const
|
|
146
|
+
export const easeInOutQuad = [0.45, 0, 0.55, 1] as const
|
|
147
|
+
export const easeOutExpo = [0.16, 1, 0.3, 1] as const
|
|
148
|
+
export const easeSmoothOut = [0.32, 0.72, 0, 1] as const
|
|
148
149
|
|
|
149
|
-
export const
|
|
150
|
-
export const
|
|
150
|
+
export const durationFast = 0.2
|
|
151
|
+
export const durationNormal = 0.3
|
|
152
|
+
export const durationSlow = 0.45
|
|
153
|
+
|
|
154
|
+
export const delayTight = 0.02
|
|
151
155
|
`
|
|
152
156
|
);
|
|
153
157
|
s.succeed("Created 1 file:");
|