@vinena/vectoys-cli 0.0.0-stage → 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/dist/index.js ADDED
@@ -0,0 +1,1133 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { resolve as resolve6 } from "node:path";
5
+ // package.json
6
+ var version = "0.1.0";
7
+
8
+ // src/args.ts
9
+ var VALUES = new Map([
10
+ ["--registry", "registry"],
11
+ ["--cwd", "cwd"],
12
+ ["--dir", "dir"]
13
+ ]);
14
+ function parseArgs(argv) {
15
+ const flags = {
16
+ help: false,
17
+ version: false,
18
+ yes: false,
19
+ overwrite: false,
20
+ install: true,
21
+ open: false,
22
+ dryRun: false,
23
+ registry: undefined,
24
+ cwd: undefined,
25
+ dir: undefined
26
+ };
27
+ const positional = [];
28
+ for (let i = 0;i < argv.length; i++) {
29
+ const arg = argv[i];
30
+ const [name, inline] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
31
+ const key = VALUES.get(name);
32
+ if (key) {
33
+ const value = inline ?? argv[++i];
34
+ if (!value)
35
+ throw new Error(`${name} needs a value.`);
36
+ flags[key] = value;
37
+ } else if (name === "-h" || name === "--help")
38
+ flags.help = true;
39
+ else if (name === "-v" || name === "--version")
40
+ flags.version = true;
41
+ else if (name === "-y" || name === "--yes")
42
+ flags.yes = true;
43
+ else if (name === "-o" || name === "--overwrite")
44
+ flags.overwrite = true;
45
+ else if (name === "--no-install")
46
+ flags.install = false;
47
+ else if (name === "--open")
48
+ flags.open = true;
49
+ else if (name === "--dry-run")
50
+ flags.dryRun = true;
51
+ else if (name.startsWith("-"))
52
+ throw new Error(`Unknown option ${name}. Run "vectoys --help" to see the options.`);
53
+ else
54
+ positional.push(arg);
55
+ }
56
+ return { command: positional[0], rest: positional.slice(1), flags };
57
+ }
58
+
59
+ // src/check.ts
60
+ import { spawnSync as spawnSync3 } from "node:child_process";
61
+ import { existsSync as existsSync5, lstatSync, mkdtempSync, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
62
+ import { tmpdir } from "node:os";
63
+ import { basename, extname, join as join5, relative as relative3, resolve as resolve3, sep as sep3 } from "node:path";
64
+
65
+ // ../registry-build/src/index.ts
66
+ function checkItem(value, file) {
67
+ const item = value;
68
+ for (const key of ["name", "title", "description", "license"]) {
69
+ if (typeof item[key] !== "string" || item[key] === "")
70
+ throw new Error(`${file} needs a "${key}"`);
71
+ }
72
+ if (!Array.isArray(item.files) || item.files.length === 0)
73
+ throw new Error(`${file} needs "files"`);
74
+ return item;
75
+ }
76
+
77
+ // src/commands.ts
78
+ import { spawnSync } from "node:child_process";
79
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
80
+ import { dirname, relative, resolve, sep } from "node:path";
81
+
82
+ // src/config.ts
83
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
84
+ import { join } from "node:path";
85
+ var CONFIG_FILE = "vectoys.json";
86
+ var DEFAULT_REGISTRY = "https://vectoys.com/r";
87
+ function defaults(cwd, registry = DEFAULT_REGISTRY) {
88
+ const dir = existsSync(join(cwd, "src")) ? "src/components/vectoys" : "components/vectoys";
89
+ return { dir, registry, registries: {} };
90
+ }
91
+ function readConfig(cwd) {
92
+ const path = join(cwd, CONFIG_FILE);
93
+ if (!existsSync(path))
94
+ return;
95
+ let value;
96
+ try {
97
+ value = JSON.parse(readFileSync(path, "utf8"));
98
+ } catch {
99
+ throw new Error(`${CONFIG_FILE} is not valid JSON. Fix it or delete it and run "vectoys init".`);
100
+ }
101
+ const fallback = defaults(cwd);
102
+ return {
103
+ dir: typeof value.dir === "string" ? value.dir : fallback.dir,
104
+ registry: typeof value.registry === "string" ? value.registry : fallback.registry,
105
+ registries: value.registries && typeof value.registries === "object" ? value.registries : {}
106
+ };
107
+ }
108
+ function writeConfig(cwd, config) {
109
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}
110
+ `);
111
+ }
112
+
113
+ // src/fetch.ts
114
+ async function fetchJson(url) {
115
+ let response;
116
+ try {
117
+ response = await fetch(url);
118
+ } catch (error) {
119
+ const cause = error.cause?.message ?? error.message;
120
+ throw new Error(`Could not reach ${url}: ${cause}`);
121
+ }
122
+ if (!response.ok)
123
+ throw new Error(`${url} answered ${response.status}${response.status === 404 ? ", no such item" : ""}.`);
124
+ try {
125
+ return await response.json();
126
+ } catch {
127
+ throw new Error(`${url} did not return JSON.`);
128
+ }
129
+ }
130
+
131
+ // src/files.ts
132
+ import { posix } from "node:path";
133
+ function place(given) {
134
+ const files = given.map((file) => ({ ...file, path: posix.normalize(file.path.replace(/\\/g, "/")) }));
135
+ const root = shared(files.map((file) => posix.dirname(file.path)));
136
+ return files.map((file) => {
137
+ const to = posix.relative(root, file.path);
138
+ if (to.startsWith("../") || posix.isAbsolute(to))
139
+ throw new Error(`"${file.path}" would land outside the item's folder.`);
140
+ return { from: file.path, to, content: file.content };
141
+ });
142
+ }
143
+ function shared(dirs) {
144
+ const parts = dirs.map((dir) => dir === "." ? [] : dir.split("/"));
145
+ const first = parts[0] ?? [];
146
+ let length = 0;
147
+ while (length < first.length && parts.every((part) => part[length] === first[length]))
148
+ length++;
149
+ return first.slice(0, length).join("/") || ".";
150
+ }
151
+
152
+ // src/pm.ts
153
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
154
+ import { join as join2 } from "node:path";
155
+ var LOCKFILES = [
156
+ ["bun.lock", "bun"],
157
+ ["bun.lockb", "bun"],
158
+ ["pnpm-lock.yaml", "pnpm"],
159
+ ["yarn.lock", "yarn"],
160
+ ["package-lock.json", "npm"]
161
+ ];
162
+ var SPEC = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.^~*+-]+)?$/i;
163
+ function isPackageSpec(value) {
164
+ return typeof value === "string" && SPEC.test(value);
165
+ }
166
+ function detect(cwd) {
167
+ return LOCKFILES.find(([file]) => existsSync2(join2(cwd, file)))?.[1] ?? "npm";
168
+ }
169
+ function installCommand(pm, packages) {
170
+ return [pm, [pm === "npm" ? "install" : "add", ...packages]];
171
+ }
172
+ function missing(cwd, wanted) {
173
+ const path = join2(cwd, "package.json");
174
+ if (!existsSync2(path))
175
+ return wanted;
176
+ const pkg = JSON.parse(readFileSync2(path, "utf8"));
177
+ const have = new Set(["dependencies", "devDependencies", "peerDependencies"].flatMap((field) => Object.keys(pkg[field] ?? {})));
178
+ return wanted.filter((spec) => !have.has(packageName(spec)));
179
+ }
180
+ function packageName(spec) {
181
+ const at = spec.indexOf("@", 1);
182
+ return at === -1 ? spec : spec.slice(0, at);
183
+ }
184
+
185
+ // src/prompt.ts
186
+ import { createInterface } from "node:readline";
187
+ function confirm(question) {
188
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
189
+ return new Promise((resolve) => {
190
+ rl.question(`${question} (y/N) `, (answer) => {
191
+ rl.close();
192
+ resolve(/^y(es)?$/i.test(answer.trim()));
193
+ });
194
+ });
195
+ }
196
+
197
+ // src/resolve.ts
198
+ function itemUrl(spec, config, registry = config.registry) {
199
+ if (/^https?:\/\//.test(spec))
200
+ return spec;
201
+ const scoped = /^(@[^/]+)\/(.+)$/.exec(spec);
202
+ if (scoped)
203
+ return join3(registryFor(scoped[1], config), scoped[2]);
204
+ if (!/^[\w.-]+$/.test(spec))
205
+ throw new Error(`"${spec}" is not an item name, an @scope/name or a URL.`);
206
+ return join3(registry, spec);
207
+ }
208
+ function indexUrl(scope, config, registry = config.registry) {
209
+ return join3(scope ? registryFor(scope, config) : registry, "index");
210
+ }
211
+ function registryFor(scope, config) {
212
+ const registry = config.registries[scope];
213
+ if (!registry) {
214
+ throw new Error(`No registry is set for ${scope}. Add it to "registries" in vectoys.json, as { "${scope}": "https://example.com/r" }.`);
215
+ }
216
+ return registry;
217
+ }
218
+ function join3(registry, name) {
219
+ if (registry.includes("{name}"))
220
+ return registry.replace("{name}", name);
221
+ return `${registry.replace(/\/+$/, "")}/${name}.json`;
222
+ }
223
+
224
+ // src/commands.ts
225
+ var ITEM_NAME = /^(?!\.{1,2}$)[\w.-]+$/;
226
+ function init(context) {
227
+ const found = readConfig(context.cwd);
228
+ if (found) {
229
+ context.log(`${CONFIG_FILE} is already here. Items go to ${found.dir}.`);
230
+ return found;
231
+ }
232
+ const config = defaults(context.cwd, context.registryFlag ?? DEFAULT_REGISTRY);
233
+ writeConfig(context.cwd, config);
234
+ context.log(`Wrote ${CONFIG_FILE}. Items go to ${config.dir}, from ${config.registry}.`);
235
+ context.log(`Packages install with ${detect(context.cwd)}.`);
236
+ return config;
237
+ }
238
+ async function add(context, specs) {
239
+ if (specs.length === 0)
240
+ throw new Error('Name an item to add, as "vectoys add phone". Run "vectoys list" to see them.');
241
+ const found = readConfig(context.cwd);
242
+ const config = found ?? defaults(context.cwd, context.registryFlag ?? DEFAULT_REGISTRY);
243
+ const items = await Promise.all(specs.map(async (spec) => {
244
+ const url = itemUrl(spec, config, context.registry);
245
+ return { url, item: check(await fetchJson(url), url) };
246
+ }));
247
+ const root = resolve(context.cwd, config.dir);
248
+ const writes = items.flatMap(({ url, item }) => {
249
+ const folder = resolve(root, item.name);
250
+ return place(item.files).map((file) => {
251
+ const path = resolve(folder, file.to);
252
+ if (!folder.startsWith(root + sep) || !path.startsWith(folder + sep))
253
+ throw new Error(`${url} has a file that would land outside ${config.dir}.`);
254
+ return { path, content: file.content };
255
+ });
256
+ });
257
+ if (!found)
258
+ init(context);
259
+ for (const { path, content } of writes)
260
+ await write(context, path, content);
261
+ for (const { item } of items)
262
+ if (item.docs)
263
+ context.log(item.docs);
264
+ installMissing(context, [...new Set(items.flatMap(({ item }) => item.dependencies ?? []))]);
265
+ }
266
+ async function list(context, scope) {
267
+ const config = readConfig(context.cwd) ?? defaults(context.cwd);
268
+ const url = scope && /^https?:\/\//.test(scope) ? indexUrl(undefined, config, scope) : indexUrl(scope, config, scope ? undefined : context.registry);
269
+ const index = await fetchJson(url);
270
+ if (!Array.isArray(index.items) || index.items.length === 0) {
271
+ context.log(`${url} lists no items.`);
272
+ return;
273
+ }
274
+ const width = Math.max(...index.items.map((item) => item.name.length));
275
+ const prefix = scope?.startsWith("@") ? `${scope}/` : "";
276
+ for (const item of index.items) {
277
+ const license = item.license ? ` (license: ${item.license})` : "";
278
+ context.log(`${(prefix + item.name).padEnd(width + prefix.length)} ${item.title ?? ""}${license}`);
279
+ }
280
+ }
281
+ function check(value, url) {
282
+ const item = value;
283
+ const fine = typeof item.name === "string" && ITEM_NAME.test(item.name) && Array.isArray(item.files) && item.files.every((file) => typeof file?.path === "string" && typeof file.content === "string");
284
+ if (!fine)
285
+ throw new Error(`${url} is not a registry item with a name and files that have content.`);
286
+ if (item.dependencies !== undefined && !(Array.isArray(item.dependencies) && item.dependencies.every(isPackageSpec))) {
287
+ throw new Error(`${url} lists dependencies that are not plain npm package names.`);
288
+ }
289
+ return item;
290
+ }
291
+ async function write(context, path, content) {
292
+ const shown = relative(context.cwd, path);
293
+ if (existsSync3(path)) {
294
+ if (readFileSync3(path, "utf8") === content) {
295
+ context.log(`Same ${shown}`);
296
+ return;
297
+ }
298
+ const replace = context.overwrite || context.yes || process.stdin.isTTY && await confirm(`${shown} is already there. Replace it?`);
299
+ if (!replace) {
300
+ context.log(`Kept ${shown} (pass --overwrite to replace it)`);
301
+ return;
302
+ }
303
+ }
304
+ mkdirSync(dirname(path), { recursive: true });
305
+ writeFileSync2(path, content);
306
+ context.log(`Wrote ${shown}`);
307
+ }
308
+ function installMissing(context, dependencies) {
309
+ const needed = missing(context.cwd, dependencies);
310
+ if (needed.length === 0)
311
+ return;
312
+ const [command, args] = installCommand(detect(context.cwd), needed);
313
+ const line = [command, ...args].join(" ");
314
+ if (!context.install) {
315
+ context.log(`Install what the items need with: ${line}`);
316
+ return;
317
+ }
318
+ context.log(`Running ${line}`);
319
+ const windows = process.platform === "win32";
320
+ const result = spawnSync(command, windows ? args.map((arg) => `"${arg}"`) : args, { cwd: context.cwd, stdio: "inherit", shell: windows });
321
+ if (result.status !== 0)
322
+ throw new Error(`"${line}" failed. Run it yourself to see why.`);
323
+ }
324
+
325
+ // src/create.ts
326
+ import { spawnSync as spawnSync2 } from "node:child_process";
327
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
328
+ import { dirname as dirname2, join as join4, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
329
+ // ../../package.json
330
+ var devDependencies = {
331
+ "@happy-dom/global-registrator": "20.14.5",
332
+ "@types/bun": "1.3.14",
333
+ typescript: "7.0.2"
334
+ };
335
+
336
+ // ../../tsconfig.base.json
337
+ var compilerOptions = {
338
+ target: "ES2022",
339
+ lib: ["ES2022", "DOM"],
340
+ module: "ESNext",
341
+ moduleResolution: "bundler",
342
+ strict: true,
343
+ noUncheckedIndexedAccess: true,
344
+ exactOptionalPropertyTypes: true,
345
+ verbatimModuleSyntax: true,
346
+ isolatedModules: true,
347
+ rewriteRelativeImportExtensions: true,
348
+ skipLibCheck: true,
349
+ noEmit: true,
350
+ types: ["bun"],
351
+ paths: {
352
+ "@vinena/vectoys": ["./packages/engine/src/index.ts"],
353
+ "@vinena/vectoys-react": ["./packages/react/src/index.ts"]
354
+ }
355
+ };
356
+
357
+ // src/create.ts
358
+ var BASE_TSCONFIG = "../../tsconfig.base.json";
359
+ function itemTsconfig(inRepo) {
360
+ const include = ["src", "test"];
361
+ if (inRepo)
362
+ return { extends: BASE_TSCONFIG, compilerOptions: { jsx: "react-jsx" }, include };
363
+ const { paths, rewriteRelativeImportExtensions, ...own } = compilerOptions;
364
+ return { compilerOptions: { ...own, jsx: "react-jsx" }, include };
365
+ }
366
+ function inVectoysRepo(folder) {
367
+ for (let dir = dirname2(folder);; dir = dirname2(dir)) {
368
+ const pkg = join4(dir, "package.json");
369
+ if (existsSync4(pkg) && existsSync4(join4(dir, "registry"))) {
370
+ const value = JSON.parse(readFileSync4(pkg, "utf8"));
371
+ const named = typeof value === "object" && value !== null && "name" in value && value.name === "vectoys-workspace";
372
+ if (named)
373
+ return folder.startsWith(join4(dir, "registry") + sep2);
374
+ }
375
+ if (dirname2(dir) === dir)
376
+ return false;
377
+ }
378
+ }
379
+ function componentName(name) {
380
+ const joined = name.split(/[^a-zA-Z0-9]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
381
+ return /^[A-Za-z]/.test(joined) ? joined : `Item${joined}`;
382
+ }
383
+ function titleOf(name) {
384
+ const words = name.replace(/[-_.]+/g, " ").trim() || "item";
385
+ return words.charAt(0).toUpperCase() + words.slice(1);
386
+ }
387
+ function gitAuthor(cwd) {
388
+ const result = spawnSync2("git", ["config", "user.name"], { cwd, encoding: "utf8" });
389
+ return result.status === 0 ? result.stdout.trim() || undefined : undefined;
390
+ }
391
+ function scaffold(name, author, inRepo = false) {
392
+ const component = componentName(name);
393
+ const title = titleOf(name);
394
+ const item = {
395
+ name,
396
+ title,
397
+ description: `${title}: a tilted slab drawn as SVG paths. Say what it is and what it does.`,
398
+ files: ["src/geometry.ts", `src/${name}.tsx`],
399
+ dependencies: ["@vinena/vectoys", "@vinena/vectoys-react"],
400
+ license: "MIT",
401
+ ...author ? { author } : {}
402
+ };
403
+ const vectoys = inRepo ? "workspace:*" : `^${version}`;
404
+ const pkg = {
405
+ name: `@vectoys/${name}`,
406
+ version: "0.0.0",
407
+ private: true,
408
+ description: `${title} drawn with the vectoys engine.`,
409
+ type: "module",
410
+ sideEffects: false,
411
+ exports: { ".": "./src/index.ts" },
412
+ scripts: { typecheck: "tsc --noEmit", test: "bun test" },
413
+ dependencies: { "@vinena/vectoys": vectoys, "@vinena/vectoys-react": vectoys },
414
+ peerDependencies: { react: ">=19" },
415
+ devDependencies: {
416
+ ...inRepo ? {} : { "@types/bun": devDependencies["@types/bun"] },
417
+ "@types/react": "19.0.0",
418
+ "@types/react-dom": "19.0.0",
419
+ react: "19.0.0",
420
+ "react-dom": "19.0.0"
421
+ }
422
+ };
423
+ const tsconfig = itemTsconfig(inRepo);
424
+ const json = (value) => `${JSON.stringify(value, null, 2)}
425
+ `;
426
+ return {
427
+ "item.json": json(item),
428
+ ".gitignore": `preview.html
429
+ `,
430
+ "package.json": json(pkg),
431
+ "tsconfig.json": json(tsconfig),
432
+ "src/geometry.ts": `import { camera, draw, plate, rotateX, slab, type Part, type Pose } from "@vinena/vectoys";
433
+
434
+ // A slab 60 by 40 scene units and 4 deep, with a plate on its face. In the
435
+ // slab's own plane x runs across from its middle, y down from its middle and
436
+ // z out of its face. Change the parts to make the item yours.
437
+ export const WIDTH = 60;
438
+ export const HEIGHT = 40;
439
+ export const THICKNESS = 4;
440
+ const INSET = 6;
441
+
442
+ /** How the slab turns towards the eye at rest, in degrees. */
443
+ export const TILT = { yaw: -24, pitch: -16 } as const;
444
+
445
+ function parts(pose: Pose): Part[] {
446
+ return [
447
+ slab({
448
+ name: "body",
449
+ box: [-WIDTH / 2, -HEIGHT / 2, WIDTH / 2, HEIGHT / 2],
450
+ radii: [4, 4],
451
+ thickness: THICKNESS,
452
+ edge: 1,
453
+ pose,
454
+ children: [plate({ name: "face", box: [-WIDTH / 2 + INSET, -HEIGHT / 2 + INSET, WIDTH / 2 - INSET, HEIGHT / 2 - INSET], radii: [2, 2], role: "face" })],
455
+ }),
456
+ ];
457
+ }
458
+
459
+ /** The slab as the eye sees it, turned by \`yaw\` and \`pitch\`: the camera and the parts drawn. */
460
+ export function seen(yaw: number = TILT.yaw, pitch: number = TILT.pitch) {
461
+ const eye = camera({ width: 100, height: 80, perspective: 280, yaw, pitch });
462
+ // Upright, the slab's face looks at the eye.
463
+ const pose: Pose = { position: [0, THICKNESS / 2, 0], rotation: rotateX(-90) };
464
+ return { camera: eye, drawn: draw(parts(pose), eye) };
465
+ }
466
+
467
+ export type View = ReturnType<typeof seen>;
468
+ `,
469
+ [`src/${name}.tsx`]: `import { Faces, Stage, type Look, type StyleWithVariables } from "@vinena/vectoys-react";
470
+ import { useMemo, type CSSProperties } from "react";
471
+ import { TILT, seen } from "./geometry";
472
+
473
+ // The page's colours, as CSS variables, each with a tone so the item shows as
474
+ // meant before they are set.
475
+ const HAIRLINE = "var(--vectoys-hairline, #8a8a8a)";
476
+ const SURFACE = "var(--vectoys-surface, #f5f5f5)";
477
+ const FINISH = "var(--vectoys-finish, #5b7cfa)";
478
+
479
+ const fill = (color: string): Look => ({ style: { fill: color } });
480
+ const STYLES: Record<string, Look> = {
481
+ top: fill(FINISH),
482
+ bottom: fill(FINISH),
483
+ side: fill(FINISH),
484
+ face: fill(SURFACE),
485
+ };
486
+
487
+ const OUTLINE: Look = {
488
+ vectorEffect: "non-scaling-stroke",
489
+ strokeWidth: 1,
490
+ strokeLinejoin: "round",
491
+ style: { stroke: HAIRLINE, fill: "none" },
492
+ };
493
+
494
+ export type ${component}Props = {
495
+ /** What the item shows, for assistive technology. */
496
+ label?: string;
497
+ /** The slab's colour; by default the page's \`--vectoys-finish\`. */
498
+ finish?: string;
499
+ /** How far it turns about its up axis, in degrees. */
500
+ yaw?: number;
501
+ /** How far it tips towards or away from the eye, in degrees. */
502
+ pitch?: number;
503
+ className?: string;
504
+ style?: CSSProperties;
505
+ };
506
+
507
+ /** ${title}: a slab tilted towards the eye, drawn the same on the server and the client. */
508
+ export function ${component}({ label = ${JSON.stringify(title)}, finish, yaw = TILT.yaw, pitch = TILT.pitch, className, style }: ${component}Props) {
509
+ const view = useMemo(() => seen(yaw, pitch), [yaw, pitch]);
510
+ const body = view.drawn.hulls.find((hull) => hull.part === "body");
511
+ const vars: StyleWithVariables = finish ? { "--vectoys-finish": finish } : {};
512
+ return (
513
+ <div role="img" aria-label={label} className={className} style={style}>
514
+ <Stage camera={view.camera} style={vars}>
515
+ <Faces faces={view.drawn.faces} hulls={view.drawn.hulls} styles={STYLES} shading />
516
+ {body && <path d={body.d} {...OUTLINE} />}
517
+ </Stage>
518
+ </div>
519
+ );
520
+ }
521
+ `,
522
+ "src/index.ts": `export { HEIGHT, THICKNESS, TILT, WIDTH, seen, type View } from "./geometry";
523
+ export { ${component}, type ${component}Props } from "./${name}";
524
+ `,
525
+ [`test/${name}.test.tsx`]: `import { expect, test } from "bun:test";
526
+ import { renderToStaticMarkup } from "react-dom/server";
527
+ import { ${component} } from "../src";
528
+
529
+ test("renders on the server as a labelled image with no NaN", () => {
530
+ const html = renderToStaticMarkup(<${component} label="A blue slab" finish="#3b82f6" />);
531
+ expect(html).toStartWith('<div role="img" aria-label="A blue slab"');
532
+ expect(html).toContain("--vectoys-finish:#3b82f6");
533
+ expect(html).not.toContain("NaN");
534
+ });
535
+ `
536
+ };
537
+ }
538
+ function create(context, name, under) {
539
+ if (!name)
540
+ throw new Error('Name the new item, as "vectoys create spinner".');
541
+ if (!ITEM_NAME.test(name))
542
+ throw new Error(`"${name}" is not an item name. Use letters, digits, dots, dashes and underscores.`);
543
+ const folder = resolve2(context.cwd, under ?? ".", name);
544
+ const shown = relative2(context.cwd, folder) || ".";
545
+ if (existsSync4(folder) && !context.overwrite)
546
+ throw new Error(`${shown} is already there. Pass --overwrite to write over it.`);
547
+ for (const [path, content] of Object.entries(scaffold(name, gitAuthor(context.cwd), inVectoysRepo(folder)))) {
548
+ const to = join4(folder, path);
549
+ mkdirSync2(dirname2(to), { recursive: true });
550
+ writeFileSync3(to, content);
551
+ context.log(`Wrote ${relative2(context.cwd, to)}`);
552
+ }
553
+ context.log(`Draw it in ${shown}/src, then run "vectoys check ${shown}" before you submit it.`);
554
+ }
555
+
556
+ // src/check.ts
557
+ var BRANDS = [
558
+ "apple watch",
559
+ "galaxy tab",
560
+ "pixel phone",
561
+ "surface pro",
562
+ "iphone",
563
+ "ipad",
564
+ "macbook",
565
+ "imac",
566
+ "airpods",
567
+ "apple",
568
+ "google",
569
+ "microsoft",
570
+ "samsung",
571
+ "playstation",
572
+ "xbox",
573
+ "nintendo"
574
+ ];
575
+ var BRAND = new RegExp(`\\b(${BRANDS.map((brand) => brand.replace(" ", "\\s+")).join("|")})\\b`, "gi");
576
+ var MAX_SOURCE = 64 * 1024;
577
+ function brands(text) {
578
+ return [...new Set([...text.matchAll(BRAND)].map((match) => match[0].toLowerCase().replace(/\s+/g, " ")))];
579
+ }
580
+ var MARK = "@@vectoys-check@@";
581
+ function bunScript(body) {
582
+ return `
583
+ const [entry, dir, wanted] = process.argv.slice(2);
584
+ const done = (value) => process.stdout.write("\\n${MARK}" + JSON.stringify(value));
585
+ try {
586
+ const { createElement } = await import(Bun.resolveSync("react", dir));
587
+ const { renderToStaticMarkup } = await import(Bun.resolveSync("react-dom/server", dir));
588
+ const mod = await import(entry);
589
+ const isComponent = (value) => typeof value === "function" || (typeof value === "object" && value !== null && "$$typeof" in value);
590
+ const name = isComponent(mod[wanted]) ? wanted : Object.keys(mod).find((key) => /^[A-Z]/.test(key) && isComponent(mod[key]));
591
+ if (!name) throw new Error("it exports no component");
592
+ ${body}
593
+ } catch (error) {
594
+ done({ error: error instanceof Error ? error.message : String(error) });
595
+ }
596
+ `;
597
+ }
598
+ var RUNNER = bunScript(`const render = () => renderToStaticMarkup(createElement(mod[name]));
599
+ done({ name, html: [render(), render()] });`);
600
+ function isRendered(value) {
601
+ if (typeof value !== "object" || value === null)
602
+ return false;
603
+ if ("error" in value)
604
+ return typeof value.error === "string";
605
+ return "name" in value && typeof value.name === "string" && "html" in value && Array.isArray(value.html) && value.html.length === 2 && value.html.every((html) => typeof html === "string");
606
+ }
607
+ function runInBun(script, entry, dir, wanted) {
608
+ const work = mkdtempSync(join5(tmpdir(), "vectoys-check-"));
609
+ try {
610
+ const runner = join5(work, "render.mjs");
611
+ writeFileSync4(runner, script);
612
+ const result = spawnSync3("bun", [runner, entry, dir, wanted], { cwd: dir, encoding: "utf8", timeout: 60000, maxBuffer: 64 * 1024 * 1024 });
613
+ if (result.error) {
614
+ const missing2 = "code" in result.error && result.error.code === "ENOENT";
615
+ return { error: missing2 ? "it needs bun to load the item's TSX: https://bun.sh" : result.error.message };
616
+ }
617
+ const at = result.stdout.lastIndexOf(MARK);
618
+ if (at === -1)
619
+ return { error: result.stderr.trim() || "bun stopped without rendering it" };
620
+ const value = JSON.parse(result.stdout.slice(at + MARK.length));
621
+ if (typeof value === "object" && value !== null && "error" in value && typeof value.error === "string")
622
+ return { error: value.error };
623
+ return { value };
624
+ } finally {
625
+ rmSync(work, { recursive: true, force: true });
626
+ }
627
+ }
628
+ function render(entry, dir, wanted) {
629
+ const ran = runInBun(RUNNER, entry, dir, wanted);
630
+ if ("error" in ran)
631
+ return ran;
632
+ return isRendered(ran.value) ? ran.value : { error: "bun gave back something other than markup" };
633
+ }
634
+ function textOf(html) {
635
+ const labels = [...html.matchAll(/\s(?:aria-label|title|alt)="([^"]*)"/g)].map((match) => match[1]);
636
+ const text = html.replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]*>/g, " ");
637
+ return [text, ...labels].join(" ");
638
+ }
639
+ var labelled = (attributes) => /\saria-label="[^"]*\S[^"]*"/.test(attributes) || /\saria-labelledby="[^"]+"/.test(attributes);
640
+ function accessibleName(html) {
641
+ const root = /^<[a-z][\w-]*([^>]*)>/i.exec(html);
642
+ const role = /\srole="(img|group)"/.exec(root?.[1] ?? "")?.[1];
643
+ if (role && labelled(root?.[1] ?? ""))
644
+ return `the root is role="${role}" with an aria-label`;
645
+ const controls = [...html.matchAll(/<(button|a|input|select|textarea)\b([^>]*)>/gi)].filter(([, tag, attributes]) => tag?.toLowerCase() !== "a" || /\shref=/.test(attributes ?? ""));
646
+ if (controls.length === 0)
647
+ return;
648
+ const named = controls.every((control) => {
649
+ const [, tag = "", attributes = ""] = control;
650
+ if (labelled(attributes))
651
+ return true;
652
+ const kind = tag.toLowerCase();
653
+ if (kind !== "button" && kind !== "a")
654
+ return false;
655
+ const after = html.slice((control.index ?? 0) + control[0].length);
656
+ const end = after.search(new RegExp(`</${kind}>`, "i"));
657
+ return after.slice(0, end === -1 ? 0 : end).replace(/<[^>]*>/g, "").trim() !== "";
658
+ });
659
+ return named ? `its ${controls.length} control${controls.length === 1 ? " is" : "s are"} labelled` : undefined;
660
+ }
661
+ function itemProblem(item, dir) {
662
+ if (!ITEM_NAME.test(item.name))
663
+ return `"${item.name}" is not an item name`;
664
+ const file = item.files.find((path) => typeof path !== "string" || !resolve3(dir, path).startsWith(dir + sep3) || !existsSync5(resolve3(dir, path)));
665
+ if (file !== undefined)
666
+ return `files lists ${JSON.stringify(file)}, which is not a file in the folder`;
667
+ const other = item.files.find((path) => !lstatSync(resolve3(dir, path)).isFile());
668
+ if (other !== undefined) {
669
+ const stat = lstatSync(resolve3(dir, other));
670
+ const kind = stat.isSymbolicLink() ? "a symlink, " : stat.isDirectory() ? "a folder, " : "";
671
+ return `files lists ${JSON.stringify(other)}, which is ${kind}not a regular file`;
672
+ }
673
+ if (item.dependencies !== undefined && !(Array.isArray(item.dependencies) && item.dependencies.every(isPackageSpec))) {
674
+ return "dependencies are not all plain npm package names";
675
+ }
676
+ return;
677
+ }
678
+ function readItem(dir) {
679
+ const path = join5(dir, "item.json");
680
+ if (!existsSync5(path))
681
+ return "there is no item.json";
682
+ let value;
683
+ try {
684
+ value = JSON.parse(readFileSync5(path, "utf8"));
685
+ } catch (error) {
686
+ return `item.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`;
687
+ }
688
+ if (typeof value !== "object" || value === null || Array.isArray(value))
689
+ return "item.json is not an object";
690
+ try {
691
+ const item = checkItem(value, "item.json");
692
+ return itemProblem(item, dir) ?? item;
693
+ } catch (error) {
694
+ return error instanceof Error ? error.message : String(error);
695
+ }
696
+ }
697
+ function componentFile(item) {
698
+ return item.files.find((file) => basename(file, ".tsx") === item.name && extname(file) === ".tsx") ?? item.files.find((file) => extname(file) === ".tsx");
699
+ }
700
+ function checks(dir) {
701
+ const item = readItem(dir);
702
+ if (typeof item === "string")
703
+ return [{ pass: false, line: item }];
704
+ const results = [{ pass: true, line: "item.json has what the registry needs" }];
705
+ const size = item.files.reduce((sum, file) => sum + lstatSync(resolve3(dir, file)).size, 0);
706
+ const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
707
+ results.push({ pass: size < MAX_SOURCE, line: `the files are ${kb(size)}, ${size < MAX_SOURCE ? "under" : "over"} ${kb(MAX_SOURCE)}` });
708
+ const named = brands([item.name, item.title, item.description].join(" "));
709
+ results.push({ pass: named.length === 0, line: named.length ? `item.json names brands: ${named.join(", ")}` : "item.json names no brands" });
710
+ const component = componentFile(item);
711
+ if (!component)
712
+ return [...results, { pass: false, line: "files lists no .tsx component to render" }];
713
+ const rendered = render(resolve3(dir, component), dir, componentName(item.name));
714
+ if ("error" in rendered)
715
+ return [...results, { pass: false, line: `${component} does not render on the server: ${rendered.error}` }];
716
+ const [html, again] = rendered.html;
717
+ results.push({ pass: true, line: `${rendered.name} renders on the server` });
718
+ const broken = ["NaN", "Infinity"].filter((word) => html.includes(word));
719
+ results.push({ pass: broken.length === 0, line: broken.length ? `the markup has ${broken.join(" and ")} in it` : "the markup has no NaN or Infinity" });
720
+ results.push({ pass: html === again, line: html === again ? "two renders give the same markup" : "two renders give different markup" });
721
+ const shown = brands(textOf(html));
722
+ results.push({ pass: shown.length === 0, line: shown.length ? `the rendered text names brands: ${shown.join(", ")}` : "the rendered text names no brands" });
723
+ const name = accessibleName(html);
724
+ results.push({ pass: name !== undefined, line: name ?? 'the root is not role="img" or "group" with an aria-label, and it has no labelled controls' });
725
+ return results;
726
+ }
727
+ function check2(context, given) {
728
+ const dir = resolve3(context.cwd, given ?? ".");
729
+ context.log(`Checking ${relative3(context.cwd, dir) || "."}`);
730
+ const results = checks(dir);
731
+ for (const result of results)
732
+ context.log(` ${result.pass ? "pass" : "FAIL"} ${result.line}`);
733
+ const failed = results.filter((result) => !result.pass).length;
734
+ if (failed > 0)
735
+ throw new Error(`${failed} of ${results.length} checks fail.`);
736
+ context.log(`All ${results.length} checks pass.`);
737
+ }
738
+
739
+ // src/preview.ts
740
+ import { spawn } from "node:child_process";
741
+ import { writeFileSync as writeFileSync5 } from "node:fs";
742
+ import { join as join6, relative as relative4, resolve as resolve4 } from "node:path";
743
+ var YAWS = [-45, -20, 0, 20, 45];
744
+ var RUNNER2 = bunScript(`const render = (props) => renderToStaticMarkup(createElement(mod[name], props));
745
+ const yaws = ${JSON.stringify(YAWS)}.map((yaw) => ({ yaw, html: render({ yaw }) }));
746
+ const turns = yaws[0].html !== yaws[yaws.length - 1].html;
747
+ done({ name, html: render(), yaws: turns ? yaws : [] });`);
748
+ function isPreview(value) {
749
+ if (typeof value !== "object" || value === null)
750
+ return false;
751
+ if (!(("name" in value) && typeof value.name === "string" && ("html" in value) && typeof value.html === "string"))
752
+ return false;
753
+ if (!(("yaws" in value) && Array.isArray(value.yaws)))
754
+ return false;
755
+ return value.yaws.every((turn) => typeof turn === "object" && turn !== null && ("yaw" in turn) && typeof turn.yaw === "number" && ("html" in turn) && typeof turn.html === "string");
756
+ }
757
+ var THEMES = {
758
+ light: `--page: #f3f4f6; --ink: #15171c; --muted: #5f6571; --vectoys-bg: #f3f4f6; --vectoys-surface: #f5f6f8; --vectoys-hairline: #8a8e96; --vectoys-border: #d2d5db; --vectoys-shadow: rgba(10, 14, 22, 0.1); --vectoys-focus: #5f6571; color-scheme: light;`,
759
+ dark: `--page: #0f1114; --ink: #eceef2; --muted: #959baa; --vectoys-bg: #0f1114; --vectoys-surface: #3a3e46; --vectoys-hairline: #6c717c; --vectoys-border: #4b505a; --vectoys-shadow: rgba(0, 0, 0, 0.55); --vectoys-focus: #959baa; color-scheme: dark;`
760
+ };
761
+ var escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
762
+ function scoped(html, scope) {
763
+ return html.replace(/(\s(?:id|for|aria-labelledby|aria-describedby|aria-controls)=")([^"]+)"/g, (_, attribute, ids) => `${attribute}${ids.split(/\s+/).map((id) => scope + id).join(" ")}"`).replace(/url\(#/g, `url(#${scope}`).replace(/href="#/g, `href="#${scope}`);
764
+ }
765
+ function previewPage(title, preview) {
766
+ const panel = (theme) => {
767
+ const turns = preview.yaws.map(({ yaw, html }, at) => `<figure><div class="item">${scoped(html, `${theme}-${at}-`)}</div><figcaption>yaw ${yaw}</figcaption></figure>`).join("");
768
+ return `<section class="${theme}">
769
+ <h2>${theme}</h2>
770
+ <figure class="hero"><div class="item">${scoped(preview.html, `${theme}-`)}</div><figcaption>as it comes</figcaption></figure>
771
+ ${turns ? `<div class="turns">${turns}</div>` : ""}
772
+ </section>`;
773
+ };
774
+ return `<!doctype html>
775
+ <html lang="en">
776
+ <head>
777
+ <meta charset="utf-8">
778
+ <meta name="viewport" content="width=device-width, initial-scale=1">
779
+ <title>${escape(title)} preview</title>
780
+ <style>
781
+ * { box-sizing: border-box; }
782
+ body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; background: #f3f4f6; }
783
+ header { padding: 16px 24px; color: #15171c; }
784
+ h1 { margin: 0; font-size: 18px; font-weight: 500; }
785
+ header p { margin: 0; color: #5f6571; }
786
+ main { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); }
787
+ section { padding: 24px; background: var(--page); color: var(--ink); }
788
+ section.light { ${THEMES.light} }
789
+ section.dark { ${THEMES.dark} }
790
+ h2 { margin: 0 0 16px; font-size: 13px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
791
+ figure { margin: 0; display: grid; gap: 8px; justify-items: center; }
792
+ figcaption { font-size: 12px; color: var(--muted); }
793
+ .hero .item { width: min(100%, 360px); }
794
+ .turns { margin-top: 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; }
795
+ .turns .item { width: 100%; }
796
+ </style>
797
+ </head>
798
+ <body>
799
+ <header><h1>${escape(title)}</h1><p>${escape(preview.name)}, rendered on the server${preview.yaws.length ? ` and turned to yaw ${preview.yaws.map(({ yaw }) => yaw).join(", ")}` : ""}</p></header>
800
+ <main>
801
+ ${panel("light")}
802
+ ${panel("dark")}
803
+ </main>
804
+ </body>
805
+ </html>
806
+ `;
807
+ }
808
+ function open(path) {
809
+ const [command, args] = process.platform === "darwin" ? ["open", [path]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", path]] : ["xdg-open", [path]];
810
+ spawn(command, args, { detached: true, stdio: "ignore" }).unref();
811
+ }
812
+ function preview(context, given, opening = false) {
813
+ const dir = resolve4(context.cwd, given ?? ".");
814
+ const item = readItem(dir);
815
+ if (typeof item === "string")
816
+ throw new Error(`${relative4(context.cwd, dir) || "."} is not an item to preview: ${item}.`);
817
+ const component = componentFile(item);
818
+ if (!component)
819
+ throw new Error("item.json lists no .tsx component to render.");
820
+ const ran = runInBun(RUNNER2, resolve4(dir, component), dir, componentName(item.name));
821
+ if ("error" in ran)
822
+ throw new Error(`${component} does not render on the server: ${ran.error}`);
823
+ if (!isPreview(ran.value))
824
+ throw new Error("bun gave back something other than markup.");
825
+ const path = join6(dir, "preview.html");
826
+ writeFileSync5(path, previewPage(item.title, ran.value));
827
+ context.log(path);
828
+ if (opening)
829
+ open(path);
830
+ return path;
831
+ }
832
+
833
+ // src/skill.ts
834
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
835
+ import { fileURLToPath } from "node:url";
836
+ var SKILL = fileURLToPath(new URL("../SKILL.md", import.meta.url));
837
+ function skill(context, path = SKILL) {
838
+ if (!existsSync6(path))
839
+ throw new Error(`${path} is missing. Reinstall vectoys.`);
840
+ context.log(readFileSync6(path, "utf8").trimEnd());
841
+ }
842
+
843
+ // src/submit.ts
844
+ import { spawnSync as spawnSync4 } from "node:child_process";
845
+ import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync as readFileSync7, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "node:fs";
846
+ import { tmpdir as tmpdir2 } from "node:os";
847
+ import { dirname as dirname3, join as join7, relative as relative5, resolve as resolve5 } from "node:path";
848
+ var SUBMISSIONS_OPEN = false;
849
+ var UPSTREAM = "vinenastudio/vectoys";
850
+ var CLOSED = "Submissions open when vectoys launches. Nothing was sent.";
851
+ var run = (command, args, cwd) => {
852
+ const result = spawnSync4(command, args, { cwd, encoding: "utf8" });
853
+ if (result.error) {
854
+ const missing2 = "code" in result.error && result.error.code === "ENOENT";
855
+ throw new Error(missing2 ? `submit needs ${command}${command === "gh" ? ", the GitHub CLI: https://cli.github.com" : ""}.` : result.error.message);
856
+ }
857
+ return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
858
+ };
859
+ function must(runner, command, args, cwd) {
860
+ const result = runner(command, args, cwd);
861
+ if (result.status !== 0)
862
+ throw new Error(`${command} ${args.join(" ")} failed: ${(result.stderr || result.stdout).trim()}`);
863
+ return result.stdout.trim();
864
+ }
865
+ function openPullRequestFrom(head, runner, cwd) {
866
+ const found = JSON.parse(must(runner, "gh", ["pr", "list", "--repo", UPSTREAM, "--head", head, "--state", "open", "--json", "url"], cwd) || "[]");
867
+ const first = Array.isArray(found) ? found[0] : undefined;
868
+ return typeof first === "object" && first !== null && "url" in first && typeof first.url === "string" ? first.url : undefined;
869
+ }
870
+ function openPullRequest(change, runner, cwd) {
871
+ const login = must(runner, "gh", ["api", "user", "--jq", ".login"], cwd);
872
+ must(runner, "gh", ["repo", "fork", UPSTREAM, "--clone=false"], cwd);
873
+ must(runner, "gh", ["repo", "sync", `${login}/vectoys`, "--branch", "main"], cwd);
874
+ const work = mkdtempSync2(join7(tmpdir2(), "vectoys-submit-"));
875
+ try {
876
+ const clone = join7(work, "vectoys");
877
+ must(runner, "gh", ["repo", "clone", `${login}/vectoys`, clone, "--", "--depth", "1"], work);
878
+ must(runner, "git", ["checkout", "-b", change.branch], clone);
879
+ change.write(clone);
880
+ must(runner, "git", ["add", "--all"], clone);
881
+ must(runner, "git", ["commit", "-m", change.title], clone);
882
+ const ref = `refs/heads/${change.branch}`;
883
+ const pushed = must(runner, "git", ["ls-remote", "origin", ref], clone).split("\t")[0] ?? "";
884
+ must(runner, "git", ["push", `--force-with-lease=${ref}:${pushed}`, "--set-upstream", "origin", change.branch], clone);
885
+ const head = `${login}:${change.branch}`;
886
+ const open2 = openPullRequestFrom(head, runner, clone);
887
+ if (open2)
888
+ return { url: open2, opened: false };
889
+ return { url: must(runner, "gh", ["pr", "create", "--repo", UPSTREAM, "--base", "main", "--head", head, "--title", change.title, "--body", change.body], clone), opened: true };
890
+ } finally {
891
+ rmSync2(work, { recursive: true, force: true });
892
+ }
893
+ }
894
+ function steps(change, own) {
895
+ return [
896
+ `Fork ${UPSTREAM} to your GitHub account with gh and bring its main up to date`,
897
+ "Clone the fork into a temporary folder",
898
+ `Make the branch ${change.branch}`,
899
+ ...own,
900
+ `Commit "${change.title}" and push the branch to the fork`,
901
+ `Open a pull request to ${UPSTREAM} main, or update the one already open from the branch`
902
+ ];
903
+ }
904
+ function itemFiles(dir, listed) {
905
+ const extra = ["package.json", "tsconfig.json", "test"].filter((path) => existsSync7(join7(dir, path)));
906
+ return [...new Set(["item.json", ...listed, ...extra])];
907
+ }
908
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
909
+ function inRepoForm(folder) {
910
+ const pkgPath = join7(folder, "package.json");
911
+ if (existsSync7(pkgPath)) {
912
+ const pkg = JSON.parse(readFileSync7(pkgPath, "utf8"));
913
+ if (!isRecord(pkg))
914
+ throw new Error("The item's package.json is not an object.");
915
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
916
+ const deps = pkg[field];
917
+ if (!isRecord(deps))
918
+ continue;
919
+ for (const name of Object.keys(deps))
920
+ if (name.startsWith("@vectoys/") || name === "@vinena/vectoys" || name.startsWith("@vinena/vectoys-"))
921
+ deps[name] = "workspace:*";
922
+ if (field === "devDependencies")
923
+ delete deps["@types/bun"];
924
+ }
925
+ writeFileSync6(pkgPath, `${JSON.stringify(pkg, null, 2)}
926
+ `);
927
+ }
928
+ const tsPath = join7(folder, "tsconfig.json");
929
+ if (existsSync7(tsPath)) {
930
+ const tsconfig = JSON.parse(readFileSync7(tsPath, "utf8"));
931
+ if (!isRecord(tsconfig))
932
+ throw new Error("The item's tsconfig.json is not an object.");
933
+ const own = isRecord(tsconfig.compilerOptions) ? tsconfig.compilerOptions : {};
934
+ const kept = Object.fromEntries(Object.entries(own).filter(([key]) => !(key in compilerOptions)));
935
+ writeFileSync6(tsPath, `${JSON.stringify({ extends: BASE_TSCONFIG, ...tsconfig, compilerOptions: kept }, null, 2)}
936
+ `);
937
+ }
938
+ }
939
+ function itemChange(context, dir) {
940
+ const shown = relative5(context.cwd, dir) || ".";
941
+ context.log(`Checking ${shown}`);
942
+ const results = checks(dir);
943
+ for (const result of results)
944
+ context.log(` ${result.pass ? "pass" : "FAIL"} ${result.line}`);
945
+ const failed = results.filter((result) => !result.pass).length;
946
+ if (failed > 0)
947
+ throw new Error(`${failed} of ${results.length} checks fail. Fix them, then submit again.`);
948
+ const item = readItem(dir);
949
+ if (typeof item === "string")
950
+ throw new Error(item);
951
+ const files = itemFiles(dir, item.files);
952
+ const branch = `item/${item.name}`;
953
+ const title = `Add the ${item.name} item`;
954
+ const body = [
955
+ `Adds \`registry/${item.name}\`: ${item.description}`,
956
+ "",
957
+ "## vectoys check",
958
+ "",
959
+ ...results.map((result) => `- ${result.pass ? "pass" : "FAIL"}: ${result.line}`),
960
+ "",
961
+ "## Author",
962
+ "",
963
+ "- [ ] I made this item myself, and it draws no maker's product.",
964
+ "- [ ] I agree to license it under the MIT license."
965
+ ].join(`
966
+ `);
967
+ return {
968
+ branch,
969
+ title,
970
+ body,
971
+ steps: steps({ branch, title }, [`Copy ${files.join(", ")} into registry/${item.name}/`]),
972
+ write: (clone) => {
973
+ for (const file of files) {
974
+ const to = join7(clone, "registry", item.name, file);
975
+ mkdirSync3(dirname3(to), { recursive: true });
976
+ cpSync(join7(dir, file), to, { recursive: true });
977
+ }
978
+ inRepoForm(join7(clone, "registry", item.name));
979
+ }
980
+ };
981
+ }
982
+ function registryUrls(given) {
983
+ const trimmed = given.replace(/\/+$/, "");
984
+ if (trimmed.endsWith(".json"))
985
+ return { index: trimmed, base: trimmed.slice(0, trimmed.lastIndexOf("/")) };
986
+ return { index: `${trimmed}/index.json`, base: trimmed };
987
+ }
988
+ function readIndex(value) {
989
+ if (typeof value !== "object" || value === null || Array.isArray(value))
990
+ return "it is not a JSON object";
991
+ if (!("items" in value) || !Array.isArray(value.items))
992
+ return 'it has no "items" list';
993
+ if (value.items.length === 0)
994
+ return "it lists no items";
995
+ const bad = value.items.find((item) => !(typeof item === "object" && item !== null && ("name" in item) && typeof item.name === "string" && ITEM_NAME.test(item.name)));
996
+ if (bad !== undefined)
997
+ return `it lists ${JSON.stringify(bad)}, which has no item name`;
998
+ const name = "name" in value && typeof value.name === "string" && ITEM_NAME.test(value.name) ? value.name : undefined;
999
+ return { name, items: value.items.length };
1000
+ }
1001
+ function listed(registries, listing) {
1002
+ return [...registries.filter((entry) => entry.name !== listing.name), listing].sort((a, b) => a.name.localeCompare(b.name));
1003
+ }
1004
+ function isListings(value) {
1005
+ return Array.isArray(value) && value.every((entry) => typeof entry === "object" && entry !== null && ("name" in entry) && typeof entry.name === "string");
1006
+ }
1007
+ async function listingChange(context, given, runner) {
1008
+ const { index, base } = registryUrls(given);
1009
+ context.log(`Reading ${index}`);
1010
+ const read = readIndex(await fetchJson(index));
1011
+ if (typeof read === "string")
1012
+ throw new Error(`${index} is not a vectoys registry index: ${read}.`);
1013
+ const { items } = read;
1014
+ const name = read.name ?? new URL(base).hostname;
1015
+ context.log(` pass it lists ${items} item${items === 1 ? "" : "s"}`);
1016
+ const author = runner("git", ["config", "user.name"], context.cwd).stdout.trim();
1017
+ if (!author)
1018
+ throw new Error('Set your name for git first, as git config --global user.name "Your Name".');
1019
+ const listing = { name, url: base, author };
1020
+ const branch = `registry/${name}`;
1021
+ const title = `List the ${name} registry`;
1022
+ const body = [
1023
+ `Lists the ${name} registry, at ${base}, by ${author}. Its index lists ${items} item${items === 1 ? "" : "s"}.`,
1024
+ "",
1025
+ "- [ ] I run this registry, and its items draw no maker's product."
1026
+ ].join(`
1027
+ `);
1028
+ return {
1029
+ branch,
1030
+ title,
1031
+ body,
1032
+ steps: steps({ branch, title }, [`Add ${JSON.stringify(listing)} to registries.json, sorted by name`]),
1033
+ write: (clone) => {
1034
+ const path = join7(clone, "registries.json");
1035
+ const found = existsSync7(path) ? JSON.parse(readFileSync7(path, "utf8")) : [];
1036
+ if (!isListings(found))
1037
+ throw new Error("registries.json in the fork is not a list of registries.");
1038
+ writeFileSync6(path, `${JSON.stringify(listed(found, listing), null, 2)}
1039
+ `);
1040
+ }
1041
+ };
1042
+ }
1043
+ async function submit(context, given, options) {
1044
+ const runner = options.run ?? run;
1045
+ const change = options.registry ? await listingChange(context, options.registry, runner) : itemChange(context, resolve5(context.cwd, given ?? "."));
1046
+ if (!(options.submissionsOpen ?? SUBMISSIONS_OPEN) || options.dryRun) {
1047
+ context.log("");
1048
+ context.log("submit would:");
1049
+ change.steps.forEach((step, at) => context.log(` ${at + 1}. ${step}`));
1050
+ context.log("");
1051
+ context.log(`Pull request title: ${change.title}`);
1052
+ context.log("Pull request body:");
1053
+ for (const line of change.body.split(`
1054
+ `))
1055
+ context.log(line ? ` ${line}` : "");
1056
+ context.log("");
1057
+ context.log(CLOSED);
1058
+ return;
1059
+ }
1060
+ const { url, opened } = openPullRequest(change, runner, context.cwd);
1061
+ context.log(`${opened ? "Opened" : "Updated"} ${url}`);
1062
+ return url;
1063
+ }
1064
+
1065
+ // src/index.ts
1066
+ var HELP = `vectoys ${version}: copy vectoys devices and toys into your project.
1067
+
1068
+ Usage
1069
+ vectoys init Write vectoys.json with where items go
1070
+ vectoys add <item...> Copy items in and install what they need
1071
+ vectoys list [@registry] Show the items a registry has
1072
+ vectoys create <name> Start a new item in ./<name> to draw and submit
1073
+ vectoys check [dir] Check an item folder before you submit it (needs bun)
1074
+ vectoys preview [dir] Render an item to preview.html in light and dark (needs bun)
1075
+ vectoys submit [dir] Check an item, then open a pull request to add it (needs gh)
1076
+ vectoys submit --registry <url>
1077
+ Open a pull request to list your registry on vectoys
1078
+ vectoys skill Print how a coding agent makes a new item
1079
+
1080
+ Items
1081
+ phone From the default registry
1082
+ @acme/thing From the registry "@acme" maps to in vectoys.json
1083
+ https://acme.dev/r/x.json From that URL
1084
+
1085
+ Options
1086
+ --registry <url> The registry for plain names (or set VECTOYS_REGISTRY), or the one submit lists
1087
+ --cwd <dir> Run in another folder
1088
+ --dir <dir> Where create puts the new item's folder
1089
+ -y, --yes Answer yes to every question
1090
+ -o, --overwrite Replace files or a new item's folder that are already there
1091
+ --no-install Do not install packages, only say which ones
1092
+ --open Open the preview once it is written
1093
+ --dry-run Say what submit would send, and send nothing
1094
+ -h, --help Show this help
1095
+ -v, --version Show the version
1096
+ `;
1097
+ async function main(argv) {
1098
+ const { command, rest, flags } = parseArgs(argv);
1099
+ if (flags.version)
1100
+ return console.log(version);
1101
+ if (flags.help || !command)
1102
+ return console.log(HELP);
1103
+ const context = {
1104
+ cwd: resolve6(flags.cwd ?? process.cwd()),
1105
+ registry: flags.registry ?? (process.env.VECTOYS_REGISTRY || undefined),
1106
+ registryFlag: flags.registry,
1107
+ yes: flags.yes,
1108
+ overwrite: flags.overwrite,
1109
+ install: flags.install,
1110
+ log: (line) => console.log(line)
1111
+ };
1112
+ if (command === "init")
1113
+ return void init(context);
1114
+ if (command === "add")
1115
+ return add(context, rest);
1116
+ if (command === "list")
1117
+ return list(context, rest[0]);
1118
+ if (command === "create")
1119
+ return create(context, rest[0], flags.dir);
1120
+ if (command === "check")
1121
+ return check2(context, rest[0]);
1122
+ if (command === "preview")
1123
+ return void preview(context, rest[0], flags.open);
1124
+ if (command === "submit")
1125
+ return void await submit(context, rest[0], { registry: flags.registry, dryRun: flags.dryRun });
1126
+ if (command === "skill")
1127
+ return skill(context);
1128
+ throw new Error(`Unknown command "${command}". Run "vectoys --help" to see the commands.`);
1129
+ }
1130
+ main(process.argv.slice(2)).catch((error) => {
1131
+ console.error(`vectoys: ${error.message}`);
1132
+ process.exitCode = 1;
1133
+ });