@runek/cli 0.6.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nullorder
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,60 @@
1
+ # runek
2
+
3
+ **Procedural 3D components for React Three Fiber — pull the source, own the world.**
4
+
5
+ [Runek](https://runek.nullorder.org) is a source registry of procedural,
6
+ seeded R3F components ("shadcn for 3D worlds"). Every component — a
7
+ bookshelf, a lake, a whole house — generates its geometry from props and a
8
+ `seed`. No models, no textures, no CDN. This CLI copies editable component
9
+ **source** into your project; you own the files.
10
+
11
+ ## Usage
12
+
13
+ ```sh
14
+ npx @runek/cli init # write runek.config.json + the install dir
15
+ npx @runek/cli add player terrain bookshelf # pull source + deps into your project
16
+ npx @runek/cli list # browse the catalog
17
+ ```
18
+
19
+ `add` resolves registry dependencies recursively (every component pulls
20
+ `core`), rewrites the `@runek/core` import to your local copy, and installs
21
+ the npm packages the components need via your detected package manager.
22
+
23
+ Then compose a world:
24
+
25
+ ```tsx
26
+ import { World } from './runek/core'
27
+ import { Bookshelf } from './runek/Bookshelf'
28
+ import { Player } from './runek/Player'
29
+ import { Terrain } from './runek/Terrain'
30
+
31
+ export function FirstWorld() {
32
+ return (
33
+ <World>
34
+ <Terrain size={[40, 40]} />
35
+ <Bookshelf position={[0, 1, 0]} seed={42} fill={0.8} />
36
+ <Player />
37
+ </World>
38
+ )
39
+ }
40
+ ```
41
+
42
+ Same `seed` → same world, every time.
43
+
44
+ ## Options
45
+
46
+ ```
47
+ --registry <url|path> Registry base (default: https://runek.nullorder.org/r)
48
+ --dir <path> Install directory (default: src/runek)
49
+ --overwrite Overwrite files that already exist
50
+ --no-install Print the dependency install command instead of running it
51
+ ```
52
+
53
+ ## Links
54
+
55
+ [Docs](https://runek.nullorder.org/docs) ·
56
+ [Component gallery](https://runek.nullorder.org/gallery) ·
57
+ [Walk the library](https://runek.nullorder.org/library) ·
58
+ [GitHub](https://github.com/nullorder/runek)
59
+
60
+ MIT © nullorder
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { collectDependencies, configExists, DEFAULT_CONFIG, detectPackageManager, fetchIndex, installCommand, installDependencies, readConfig, resolveItems, writeConfig, writeFiles, } from "./lib.js";
6
+ const color = (code) => (s) => `\x1b[${code}m${s}\x1b[0m`;
7
+ const bold = color('1');
8
+ const dim = color('2');
9
+ const green = color('32');
10
+ const yellow = color('33');
11
+ const red = color('31');
12
+ const cyan = color('36');
13
+ const CONFIG_HINT = cyan('runek.config.json');
14
+ const HELP = `${bold('runek')} — pull procedural 3D component source into your project
15
+
16
+ ${bold('Usage')}
17
+ runek init [options] Create ${CONFIG_HINT} and the component directory
18
+ runek add <name...> [options] Add components (and their dependencies)
19
+ runek list [options] List everything in the registry
20
+
21
+ ${bold('Options')}
22
+ --registry <url|path> Registry base (default: ${DEFAULT_CONFIG.registry})
23
+ --dir <path> Install directory (default: ${DEFAULT_CONFIG.dir})
24
+ --overwrite Overwrite files that already exist
25
+ --no-install Print the dependency install command instead of running it
26
+ --force (init) overwrite an existing config
27
+ -h, --help Show this help
28
+
29
+ ${bold('Examples')}
30
+ runek init
31
+ runek add player terrain bookshelf
32
+ runek list --registry ./registry
33
+ `;
34
+ async function main() {
35
+ const { values, positionals } = parseArgs({
36
+ allowPositionals: true,
37
+ options: {
38
+ registry: { type: 'string' },
39
+ dir: { type: 'string' },
40
+ 'no-install': { type: 'boolean' },
41
+ overwrite: { type: 'boolean' },
42
+ force: { type: 'boolean' },
43
+ help: { type: 'boolean', short: 'h' },
44
+ },
45
+ });
46
+ const [command, ...names] = positionals;
47
+ const opts = values;
48
+ if (opts.help || command === 'help' || !command) {
49
+ process.stdout.write(HELP);
50
+ return;
51
+ }
52
+ switch (command) {
53
+ case 'init':
54
+ return init(opts);
55
+ case 'add':
56
+ return add(names, opts);
57
+ case 'list':
58
+ case 'ls':
59
+ return list(opts);
60
+ default:
61
+ throw new Error(`unknown command "${command}" — run "runek --help"`);
62
+ }
63
+ }
64
+ function init(opts) {
65
+ const cwd = process.cwd();
66
+ if (configExists(cwd) && !opts.force) {
67
+ throw new Error(`${CONFIG_HINT} already exists — pass --force to overwrite`);
68
+ }
69
+ const config = { ...DEFAULT_CONFIG };
70
+ if (opts.registry)
71
+ config.registry = opts.registry;
72
+ if (opts.dir)
73
+ config.dir = opts.dir;
74
+ writeConfig(cwd, config);
75
+ mkdirSync(join(cwd, config.dir), { recursive: true });
76
+ console.log(`${green('✓')} wrote ${CONFIG_HINT}`);
77
+ console.log(`${green('✓')} created ${cyan(config.dir)}`);
78
+ console.log(`\n${bold('Next:')} runek add player terrain bookshelf`);
79
+ }
80
+ async function add(names, opts) {
81
+ if (names.length === 0)
82
+ throw new Error('specify at least one component, e.g. "runek add bookshelf"');
83
+ const cwd = process.cwd();
84
+ const config = readConfig(cwd);
85
+ if (opts.registry)
86
+ config.registry = opts.registry;
87
+ if (opts.dir)
88
+ config.dir = opts.dir;
89
+ const manifests = await resolveItems(config.registry, names);
90
+ const requested = new Set(names);
91
+ for (const m of manifests) {
92
+ const tag = requested.has(m.name) ? '' : dim(' (dependency)');
93
+ console.log(`${cyan('•')} ${m.name}${tag}`);
94
+ }
95
+ const files = manifests.flatMap((m) => m.files);
96
+ const { written, skipped } = writeFiles(cwd, config.dir, files, !!opts.overwrite);
97
+ for (const path of written)
98
+ console.log(` ${green('+')} ${config.dir}/${path}`);
99
+ for (const path of skipped)
100
+ console.log(` ${yellow('•')} ${config.dir}/${path} ${dim('(exists, skipped)')}`);
101
+ if (skipped.length > 0)
102
+ console.log(dim(' pass --overwrite to replace skipped files'));
103
+ const deps = collectDependencies(manifests);
104
+ if (deps.length === 0) {
105
+ console.log(`\n${green('✓')} done`);
106
+ return;
107
+ }
108
+ const pm = detectPackageManager(cwd);
109
+ if (opts['no-install']) {
110
+ console.log(`\n${bold('Install dependencies:')}\n ${installCommand(pm, deps)}`);
111
+ return;
112
+ }
113
+ console.log(`\n${bold('Installing')} ${deps.join(', ')} ${dim(`(${pm})`)}`);
114
+ installDependencies(cwd, pm, deps);
115
+ console.log(`\n${green('✓')} done`);
116
+ }
117
+ async function list(opts) {
118
+ const base = opts.registry ?? readConfig(process.cwd()).registry;
119
+ const index = await fetchIndex(base);
120
+ const byCategory = new Map();
121
+ for (const item of index.items) {
122
+ const key = item.category ?? 'other';
123
+ const group = byCategory.get(key) ?? [];
124
+ group.push(item);
125
+ byCategory.set(key, group);
126
+ }
127
+ for (const [category, items] of [...byCategory].sort()) {
128
+ console.log(`\n${bold(category)}`);
129
+ for (const item of items) {
130
+ console.log(` ${cyan(item.name.padEnd(12))} ${dim(item.description ?? '')}`);
131
+ }
132
+ }
133
+ }
134
+ main().catch((err) => {
135
+ console.error(red(`✖ ${err instanceof Error ? err.message : String(err)}`));
136
+ process.exit(1);
137
+ });
package/dist/lib.js ADDED
@@ -0,0 +1,107 @@
1
+ // Core logic for the Runek CLI, kept free of argument parsing so it can be unit
2
+ // tested and reused. The registry is "just data": an index plus one manifest per
3
+ // item, served over HTTP or read from a local directory.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
7
+ export const CONFIG_FILE = 'runek.config.json';
8
+ export const DEFAULT_CONFIG = {
9
+ registry: 'https://runek.nullorder.org/r',
10
+ dir: 'src/runek',
11
+ };
12
+ // --- config ----------------------------------------------------------------
13
+ export function configExists(cwd) {
14
+ return existsSync(join(cwd, CONFIG_FILE));
15
+ }
16
+ export function readConfig(cwd) {
17
+ const path = join(cwd, CONFIG_FILE);
18
+ if (!existsSync(path))
19
+ return { ...DEFAULT_CONFIG };
20
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(path, 'utf8')) };
21
+ }
22
+ export function writeConfig(cwd, config) {
23
+ const body = { $schema: 'https://runek.nullorder.org/registry/config-schema.json', ...config };
24
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(body, null, 2)}\n`);
25
+ }
26
+ // --- registry client -------------------------------------------------------
27
+ function isHttp(base) {
28
+ return /^https?:\/\//.test(base);
29
+ }
30
+ async function readJson(base, ...segments) {
31
+ if (isHttp(base)) {
32
+ const url = [base.replace(/\/+$/, ''), ...segments].join('/');
33
+ const res = await fetch(url);
34
+ if (!res.ok)
35
+ throw new Error(`registry request failed (${res.status}) for ${url}`);
36
+ return (await res.json());
37
+ }
38
+ const path = join(isAbsolute(base) ? base : resolve(base), ...segments);
39
+ if (!existsSync(path))
40
+ throw new Error(`registry file not found: ${path}`);
41
+ return JSON.parse(readFileSync(path, 'utf8'));
42
+ }
43
+ export function fetchIndex(base) {
44
+ return readJson(base, 'registry.json');
45
+ }
46
+ export function fetchManifest(base, name) {
47
+ return readJson(base, 'components', `${name}.json`);
48
+ }
49
+ /**
50
+ * Resolve the given item names plus their registry dependencies, returning
51
+ * manifests with every dependency ordered before the item that needs it.
52
+ */
53
+ export async function resolveItems(base, names) {
54
+ const seen = new Set();
55
+ const ordered = [];
56
+ async function visit(name) {
57
+ if (seen.has(name))
58
+ return;
59
+ seen.add(name);
60
+ const manifest = await fetchManifest(base, name);
61
+ for (const dep of manifest.registryDependencies ?? [])
62
+ await visit(dep);
63
+ ordered.push(manifest);
64
+ }
65
+ for (const name of names)
66
+ await visit(name);
67
+ return ordered;
68
+ }
69
+ /**
70
+ * Write component source verbatim. Components import `@runek/core` from npm
71
+ * (installed as a dependency), so there's no import to rewrite.
72
+ */
73
+ export function writeFiles(cwd, dir, files, overwrite) {
74
+ const result = { written: [], skipped: [] };
75
+ for (const file of files) {
76
+ const dest = join(cwd, dir, file.path);
77
+ if (existsSync(dest) && !overwrite) {
78
+ result.skipped.push(file.path);
79
+ continue;
80
+ }
81
+ mkdirSync(dirname(dest), { recursive: true });
82
+ writeFileSync(dest, file.content);
83
+ result.written.push(file.path);
84
+ }
85
+ return result;
86
+ }
87
+ /** Union of npm dependencies across resolved manifests, sorted and deduped. */
88
+ export function collectDependencies(manifests) {
89
+ return [...new Set(manifests.flatMap((m) => m.dependencies ?? []))].sort();
90
+ }
91
+ export function detectPackageManager(cwd) {
92
+ if (existsSync(join(cwd, 'pnpm-lock.yaml')))
93
+ return 'pnpm';
94
+ if (existsSync(join(cwd, 'yarn.lock')))
95
+ return 'yarn';
96
+ if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock')))
97
+ return 'bun';
98
+ return 'npm';
99
+ }
100
+ export function installCommand(pm, deps) {
101
+ const verb = pm === 'npm' ? 'install' : 'add';
102
+ return `${pm} ${verb} ${deps.join(' ')}`;
103
+ }
104
+ export function installDependencies(cwd, pm, deps) {
105
+ const verb = pm === 'npm' ? 'install' : 'add';
106
+ execFileSync(pm, [verb, ...deps], { cwd, stdio: 'inherit' });
107
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@runek/cli",
3
+ "version": "0.6.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Runek CLI — pull procedural 3D component source into your project (source registry).",
8
+ "license": "MIT",
9
+ "type": "module",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/nullorder/runek.git",
13
+ "directory": "packages/cli"
14
+ },
15
+ "homepage": "https://runek.nullorder.org",
16
+ "bugs": "https://github.com/nullorder/runek/issues",
17
+ "keywords": [
18
+ "react-three-fiber",
19
+ "threejs",
20
+ "3d",
21
+ "procedural-generation",
22
+ "components",
23
+ "registry",
24
+ "cli"
25
+ ],
26
+ "bin": {
27
+ "runek": "./dist/index.js"
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^24.0.0",
37
+ "typescript": "^6.0.3",
38
+ "vitest": "^4.1.8"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run"
44
+ }
45
+ }