@opys/cli 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +82 -0
  2. package/dist/opys.mjs +446 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # opys CLI
2
+
3
+ Command-line interface for building and launching Minecraft client installations from declarative manifests.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g @opys/cli
9
+ ```
10
+
11
+ Or run directly without installing:
12
+
13
+ ```sh
14
+ npx @opys/cli <command>
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ ### `opys build`
20
+
21
+ Reads a JS config file, fetches Mojang metadata, and writes a `opys.json` manifest.
22
+
23
+ ```sh
24
+ opys build [--input opys.config.mjs] [--output opys.json]
25
+ ```
26
+
27
+ | Flag | Short | Default | Description |
28
+ | ---------- | ----- | ---------------------- | --------------------------------- |
29
+ | `--input` | `-i` | `opys.config.mjs` | Path to the JS config file |
30
+ | `--output` | `-o` | value from config file | Output path for the manifest JSON |
31
+
32
+ If `--output` is omitted and the config has no `output` field, the manifest is written to stdout.
33
+
34
+ ### `opys launch`
35
+
36
+ Installs missing artifacts and spawns the JVM.
37
+
38
+ ```sh
39
+ opys launch [manifest] [--var key=value ...]
40
+ ```
41
+
42
+ Common vars to pass at launch: `username`, `uuid`, `token`.
43
+
44
+ ## Config file (`opys.config.mjs`)
45
+
46
+ ```js
47
+ import {
48
+ defineConfig,
49
+ resolveMinecraft,
50
+ artifactScanner,
51
+ } from '@opys/minecraft';
52
+
53
+ export default defineConfig(async () => {
54
+ const mc = await resolveMinecraft({ version: '1.20.1' });
55
+
56
+ return {
57
+ output: 'opys.json',
58
+ manifest: {
59
+ artifacts: [
60
+ mc.artifacts,
61
+ artifactScanner({
62
+ directory: 'mods',
63
+ url: 'https://cdn.example.com/mods/${path}',
64
+ path: '${root}/mods/${path}',
65
+ }),
66
+ ],
67
+ vars: mc.vars,
68
+ launch: mc.launch,
69
+ },
70
+ };
71
+ });
72
+ ```
73
+
74
+ ## Exit codes
75
+
76
+ | Code | Meaning |
77
+ | ---- | ------------------------------- |
78
+ | 0 | Success |
79
+ | 1 | Usage error (bad args / config) |
80
+ | 2 | Network error |
81
+ | 3 | Integrity check failed |
82
+ | 4 | Extraction failure |
package/dist/opys.mjs ADDED
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env node
2
+ import { writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { encodeManifest } from "@opys/core";
5
+ import { buildManifest, resolveConfig } from "@opys/dev";
6
+ import { parseArgs } from "node:util";
7
+ import { pathToFileURL } from "node:url";
8
+ import { ExtractionError, IntegrityError, NetworkError, install, launch } from "@opys/runtime";
9
+ import { VersionFetchError } from "@opys/mojang";
10
+
11
+ //#region lib/errors.ts
12
+ var UsageError = class extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "UsageError";
16
+ }
17
+ };
18
+
19
+ //#endregion
20
+ //#region lib/args.ts
21
+ function parseArgs$1(argv, specs) {
22
+ const options = {};
23
+ for (const s of specs) options[s.long] = {
24
+ type: s.type,
25
+ ...s.short ? { short: s.short } : {}
26
+ };
27
+ let parsed;
28
+ try {
29
+ parsed = parseArgs({
30
+ args: argv,
31
+ options,
32
+ allowPositionals: true,
33
+ strict: true
34
+ });
35
+ } catch (e) {
36
+ throw new UsageError(e.message);
37
+ }
38
+ return { getString: (flag) => parsed.values[flag] };
39
+ }
40
+
41
+ //#endregion
42
+ //#region lib/load-config.ts
43
+ /** Import a config file, resolve it for the given mode, and report its directory. */
44
+ async function loadConfig(inputFile, mode) {
45
+ const absConfig = resolve(inputFile);
46
+ const configDir = dirname(absConfig);
47
+ const mod = await import(pathToFileURL(absConfig).href);
48
+ if (!mod.default) throw new UsageError(`${inputFile}: no default export`);
49
+ return {
50
+ config: await resolveConfig(mod.default, { mode }),
51
+ configDir
52
+ };
53
+ }
54
+
55
+ //#endregion
56
+ //#region lib/commands/build.ts
57
+ async function cmdBuild(argv, logger, command) {
58
+ const args = parseArgs$1(argv, [
59
+ {
60
+ long: "input",
61
+ short: "i",
62
+ type: "string"
63
+ },
64
+ {
65
+ long: "output",
66
+ short: "o",
67
+ type: "string"
68
+ },
69
+ {
70
+ long: "mode",
71
+ type: "string"
72
+ }
73
+ ]);
74
+ const inputFile = args.getString("input") ?? "opys.config.mjs";
75
+ const outputFile = args.getString("output");
76
+ const mode = args.getString("mode") ?? command;
77
+ const { config, configDir } = await loadConfig(inputFile, mode);
78
+ const manifest = await buildManifest(config, {
79
+ log: (scope, msg) => logger.info(`[${scope}] ${msg}`),
80
+ configDir,
81
+ mode
82
+ });
83
+ const json = JSON.stringify(encodeManifest(manifest), null, 2) + "\n";
84
+ const out = outputFile ?? config.output;
85
+ if (out) {
86
+ await writeFile(resolve(configDir, out), json);
87
+ logger.info(`Written to ${out}`);
88
+ } else process.stdout.write(json);
89
+ }
90
+
91
+ //#endregion
92
+ //#region lib/progress.ts
93
+ const BAR_WIDTH = 24;
94
+ const NON_TTY_INTERVAL_MS = 3e3;
95
+ const FILLED = "█";
96
+ const EMPTY = "░";
97
+ function progressBar(pct) {
98
+ const filled = Math.round(Math.min(pct, 1) * BAR_WIDTH);
99
+ return FILLED.repeat(filled) + EMPTY.repeat(BAR_WIDTH - filled);
100
+ }
101
+ function formatSpeed(filesPerSec) {
102
+ if (filesPerSec >= 1e3) return `${(filesPerSec / 1e3).toFixed(1)}k/s`;
103
+ return `${filesPerSec.toFixed(0)}/s`;
104
+ }
105
+ function formatDuration(ms) {
106
+ const s = ms / 1e3;
107
+ if (s < 60) return `${s.toFixed(1)}s`;
108
+ return `${Math.floor(s / 60)}m ${(s % 60).toFixed(0)}s`;
109
+ }
110
+ function formatEta(remaining, rate) {
111
+ if (rate <= 0) return "";
112
+ const secs = remaining / rate;
113
+ if (secs < 1) return "";
114
+ if (secs < 60) return ` eta ${secs.toFixed(0)}s`;
115
+ return ` eta ${(secs / 60).toFixed(1)}m`;
116
+ }
117
+ function elapsed(t0) {
118
+ return formatDuration(Date.now() - t0);
119
+ }
120
+ function formatBytes(bytes) {
121
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
122
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
123
+ return `${bytes} B`;
124
+ }
125
+ function basename(path) {
126
+ return path.replace(/\\/g, "/").split("/").at(-1) ?? path;
127
+ }
128
+ function initialProgress(total, t0) {
129
+ return {
130
+ fetched: 0,
131
+ total,
132
+ t0,
133
+ active: []
134
+ };
135
+ }
136
+ function renderProgress(state) {
137
+ const { fetched, total, t0, active } = state;
138
+ const pct = total === 0 ? 1 : fetched / total;
139
+ const rate = fetched / (Math.max(Date.now() - t0, 1) / 1e3);
140
+ const overall = ` [${progressBar(pct)}] ${`${Math.round(pct * 100).toString().padStart(3)}%`} ${`${fetched}/${total}`}${rate > .1 ? ` @ ${formatSpeed(rate)}` : ""}${fetched < total ? formatEta(total - fetched, rate) : ""}`;
141
+ const cols = process.stderr.columns ?? 80;
142
+ return [overall, ...active.map((f, i) => {
143
+ const indent = i === active.length - 1 ? " └─ " : " ├─ ";
144
+ const name = basename(f.name);
145
+ const pctF = f.total > 0 ? f.bytes / f.total : 0;
146
+ const barF = progressBar(pctF);
147
+ const pctFStr = `${Math.round(pctF * 100).toString().padStart(3)}%`;
148
+ const totalFmt = f.total > 0 ? formatBytes(f.total) : "";
149
+ const fixed = ` [${barF}] ${pctFStr}${f.total > 0 ? ` ${formatBytes(f.bytes).padStart(totalFmt.length)}/${totalFmt}` : f.bytes > 0 ? ` ${formatBytes(f.bytes)}` : ""}`;
150
+ const nameBudget = cols - 1 - indent.length - fixed.length;
151
+ return `${indent}${nameBudget > 4 ? name.slice(0, nameBudget).padEnd(nameBudget) : ""}${fixed}`;
152
+ })];
153
+ }
154
+ var ProgressWriter = class {
155
+ lastLines = [];
156
+ lastNonTty = 0;
157
+ constructor(isTTY, nonTtyInterval = NON_TTY_INTERVAL_MS) {
158
+ this.isTTY = isTTY;
159
+ this.nonTtyInterval = nonTtyInterval;
160
+ }
161
+ clearLines() {
162
+ if (!this.isTTY || this.lastLines.length === 0) return;
163
+ process.stderr.write("\x1B[2K\r");
164
+ for (let i = 1; i < this.lastLines.length; i++) process.stderr.write("\x1B[1A\x1B[2K\r");
165
+ }
166
+ update(lines) {
167
+ if (this.isTTY) {
168
+ this.clearLines();
169
+ process.stderr.write(lines.join("\n"));
170
+ this.lastLines = lines;
171
+ } else {
172
+ const now = Date.now();
173
+ if (now - this.lastNonTty >= this.nonTtyInterval) {
174
+ if (lines.length > 0) process.stderr.write(`${lines[0]}\n`);
175
+ this.lastNonTty = now;
176
+ }
177
+ }
178
+ }
179
+ clear() {
180
+ if (this.isTTY && this.lastLines.length > 0) {
181
+ this.clearLines();
182
+ this.lastLines = [];
183
+ }
184
+ }
185
+ redraw() {
186
+ if (this.isTTY && this.lastLines.length > 0) process.stderr.write(this.lastLines.join("\n"));
187
+ }
188
+ log(line) {
189
+ this.clear();
190
+ process.stderr.write(`${line}\n`);
191
+ }
192
+ finish(lines) {
193
+ if (this.isTTY) {
194
+ this.clearLines();
195
+ const out = lines ?? this.lastLines;
196
+ if (out.length > 0) process.stderr.write(`${out.join("\n")}\n`);
197
+ this.lastLines = [];
198
+ } else if (lines && lines.length > 0) process.stderr.write(`${lines[0]}\n`);
199
+ }
200
+ };
201
+
202
+ //#endregion
203
+ //#region lib/commands/launch.ts
204
+ /** Minimum gap between progress redraws, in milliseconds. */
205
+ const RENDER_THROTTLE_MS = 80;
206
+ async function cmdLaunch(argv, logger, command) {
207
+ const args = parseArgs$1(argv, [{
208
+ long: "input",
209
+ short: "i",
210
+ type: "string"
211
+ }, {
212
+ long: "mode",
213
+ type: "string"
214
+ }]);
215
+ const inputFile = args.getString("input") ?? "opys.config.mjs";
216
+ const mode = args.getString("mode") ?? command;
217
+ const { config, configDir } = await loadConfig(inputFile, mode);
218
+ const baseManifest = await buildManifest(config, {
219
+ log: (scope, msg) => logger.info(`[${scope}] ${msg}`),
220
+ configDir,
221
+ mode
222
+ });
223
+ const manifest = config.runClient ? {
224
+ ...baseManifest,
225
+ ...config.runClient(baseManifest)
226
+ } : baseManifest;
227
+ const t0 = Date.now();
228
+ const pw = new ProgressWriter(process.stderr.isTTY ?? false);
229
+ logger.setProgressWriter(pw);
230
+ logger.info("Installing...");
231
+ const active = /* @__PURE__ */ new Map();
232
+ const state = initialProgress(0, t0);
233
+ let lastRender = 0;
234
+ const render = (force = false) => {
235
+ const now = Date.now();
236
+ if (!force && now - lastRender < RENDER_THROTTLE_MS) return;
237
+ lastRender = now;
238
+ state.active = [...active.values()];
239
+ pw.update(renderProgress(state));
240
+ };
241
+ await install(manifest, { onProgress(p) {
242
+ switch (p.phase) {
243
+ case "download":
244
+ state.total = p.total;
245
+ state.fetched = p.fetched;
246
+ render(true);
247
+ break;
248
+ case "download:start":
249
+ active.set(p.path, {
250
+ name: p.path,
251
+ bytes: 0,
252
+ total: p.total
253
+ });
254
+ render();
255
+ break;
256
+ case "download:bytes": {
257
+ const entry = active.get(p.path);
258
+ if (entry) {
259
+ entry.bytes = p.bytes;
260
+ render();
261
+ }
262
+ break;
263
+ }
264
+ case "download:done":
265
+ active.delete(p.path);
266
+ pw.log(` ✓ ${basename(p.path)}`);
267
+ break;
268
+ case "verify":
269
+ pw.finish();
270
+ pw.log(" Verifying...");
271
+ break;
272
+ case "extract":
273
+ pw.log(` Extracting ${p.count} archive${p.count === 1 ? "" : "s"}...`);
274
+ break;
275
+ case "sweep":
276
+ pw.log(` Swept ${p.removed} stale file${p.removed === 1 ? "" : "s"}`);
277
+ break;
278
+ }
279
+ } });
280
+ pw.finish();
281
+ logger.info(` Ready in ${elapsed(t0)}`);
282
+ logger.info("Launching...");
283
+ const child = await launch(manifest, { install: false });
284
+ logger.info(` PID ${child.pid}`);
285
+ await new Promise((res, rej) => {
286
+ child.on("exit", (code) => code === 0 || code === null ? res() : rej(/* @__PURE__ */ new Error(`exit ${code}`)));
287
+ child.on("error", rej);
288
+ });
289
+ }
290
+
291
+ //#endregion
292
+ //#region lib/logger.ts
293
+ const RANK = {
294
+ silent: 0,
295
+ error: 1,
296
+ warn: 2,
297
+ info: 3,
298
+ debug: 4
299
+ };
300
+ const LEVEL_PREFIX = {
301
+ error: "[error]",
302
+ warn: "[warn] ",
303
+ debug: "[debug]"
304
+ };
305
+ /**
306
+ * Level-aware logger that writes to stderr.
307
+ * Coordinates with a {@link ProgressWriter} to clear the progress bar before
308
+ * writing debug/warn lines so they don't mangle in-flight progress output.
309
+ */
310
+ var Logger = class {
311
+ t0 = Date.now();
312
+ pw;
313
+ constructor(level) {
314
+ this.level = level;
315
+ }
316
+ /** Attach a ProgressWriter so log lines clear the bar before printing. */
317
+ setProgressWriter(pw) {
318
+ this.pw = pw;
319
+ }
320
+ error(msg) {
321
+ this.emit("error", msg);
322
+ }
323
+ warn(msg) {
324
+ this.emit("warn", msg);
325
+ }
326
+ info(msg) {
327
+ this.emit("info", msg);
328
+ }
329
+ debug(msg) {
330
+ this.emit("debug", msg);
331
+ }
332
+ /** Returns true when `level` is at or below this logger's threshold. */
333
+ enables(level) {
334
+ return RANK[level] <= RANK[this.level];
335
+ }
336
+ /** Returns an `InstallOptions`-compatible log callback. */
337
+ installerLog() {
338
+ return (level, msg) => this.emit(level, msg);
339
+ }
340
+ emit(level, msg) {
341
+ if (!this.enables(level)) return;
342
+ this.pw?.clear();
343
+ const prefix = LEVEL_PREFIX[level];
344
+ const line = prefix ? `${prefix} +${Date.now() - this.t0}ms ${msg}\n` : `${msg}\n`;
345
+ process.stderr.write(line);
346
+ this.pw?.redraw();
347
+ }
348
+ };
349
+ /** Parse a log level from a string, defaulting to 'info' on unknown values. */
350
+ function parseLogLevel(raw) {
351
+ if (!raw) return "info";
352
+ if (Object.prototype.hasOwnProperty.call(RANK, raw)) return raw;
353
+ process.stderr.write(`[warn] Unknown log level '${raw}', defaulting to 'info'\n`);
354
+ return "info";
355
+ }
356
+
357
+ //#endregion
358
+ //#region bin/opys.ts
359
+ const USAGE = `\
360
+ opys — declarative manifest toolkit
361
+
362
+ USAGE
363
+ opys build [-i <opys.config.mjs>] [-o <out>] [--mode <m>] Build manifest
364
+ opys launch [-i <opys.config.mjs>] [--mode <m>] Build, install, launch
365
+
366
+ OPTIONS
367
+ -i, --input Config file (default: opys.config.mjs)
368
+ -o, --output Output file (default: stdout for build)
369
+ --mode <value> Mode passed to config function (default: command name)
370
+ --log-level <level> Log verbosity: silent|error|warn|info|debug (default: info)
371
+ -v Shorthand for --log-level debug
372
+
373
+ EXIT CODES
374
+ 0 Success
375
+ 1 Usage or config error
376
+ 2 Network error
377
+ 3 Integrity check failure
378
+ 4 Extraction failure
379
+ `;
380
+ const COMMANDS = {
381
+ build: cmdBuild,
382
+ launch: cmdLaunch
383
+ };
384
+ /** Strip global flags from argv, returning the cleaned args and extracted values. */
385
+ function extractGlobals(argv) {
386
+ const args = [];
387
+ let logLevel = "info";
388
+ let i = 0;
389
+ while (i < argv.length) {
390
+ const token = argv[i++];
391
+ if (token === "--log-level") logLevel = parseLogLevel(argv[i++]);
392
+ else if (token === "-v") logLevel = "debug";
393
+ else args.push(token);
394
+ }
395
+ return {
396
+ args,
397
+ logLevel
398
+ };
399
+ }
400
+ async function main() {
401
+ const [, , ...allArgs] = process.argv;
402
+ const { args, logLevel } = extractGlobals(allArgs ?? []);
403
+ const [command, ...rest] = args;
404
+ const logger = new Logger(logLevel);
405
+ if (!command || command === "--help" || command === "-h") {
406
+ process.stdout.write(USAGE);
407
+ return;
408
+ }
409
+ const handler = COMMANDS[command];
410
+ if (!handler) {
411
+ process.stderr.write(`Unknown command '${command}'\n\n${USAGE}`);
412
+ process.exit(1);
413
+ }
414
+ await handler(rest, logger, command);
415
+ }
416
+ main().catch((err) => {
417
+ if (err instanceof UsageError) {
418
+ process.stderr.write(`Error: ${err.message}\n`);
419
+ process.exit(1);
420
+ }
421
+ if (err instanceof NetworkError || err instanceof VersionFetchError) {
422
+ process.stderr.write(`Network error: ${err.message}\n`);
423
+ process.exit(2);
424
+ }
425
+ if (err instanceof IntegrityError) {
426
+ process.stderr.write(`Integrity check failed:\n`);
427
+ for (const p of err.paths) process.stderr.write(` ${p}\n`);
428
+ process.exit(3);
429
+ }
430
+ if (err instanceof ExtractionError) {
431
+ process.stderr.write(`Extraction failed: ${err.message}\n`);
432
+ if (err.cause instanceof Error) process.stderr.write(` caused by: ${err.cause.message}\n`);
433
+ process.exit(4);
434
+ }
435
+ const msg = err instanceof Error ? err.message : String(err);
436
+ process.stderr.write(`Unexpected error: ${msg}\n`);
437
+ if (err instanceof Error && err.cause instanceof Error) {
438
+ process.stderr.write(` caused by: ${err.cause.message}\n`);
439
+ if (err.cause.stack && !process.env.OPYS_QUIET) process.stderr.write(`${err.cause.stack}\n`);
440
+ }
441
+ if (err instanceof Error && err.stack && !process.env.OPYS_QUIET) process.stderr.write(`${err.stack}\n`);
442
+ process.exit(1);
443
+ });
444
+
445
+ //#endregion
446
+ export { };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@opys/cli",
3
+ "version": "0.1.2",
4
+ "bin": {
5
+ "opys": "dist/opys.mjs"
6
+ },
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsdown --config tsdown.config.ts",
12
+ "typecheck": "tsc --noEmit -p tsconfig.json",
13
+ "test": "vitest run tests/unit --passWithNoTests",
14
+ "test:int": "vitest run tests/integration --testTimeout=180000"
15
+ },
16
+ "type": "module",
17
+ "dependencies": {
18
+ "@opys/core": "^0.1.2",
19
+ "@opys/dev": "^0.1.2",
20
+ "@opys/runtime": "^0.1.2",
21
+ "@opys/minecraft": "^0.1.2",
22
+ "@opys/mojang": "^0.1.2"
23
+ },
24
+ "peerDependencies": {
25
+ "zod": "^4.0.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/harmoniya-net/opys.git",
33
+ "directory": "packages/cli"
34
+ }
35
+ }