@coderook/cli 0.34.11 → 0.35.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/.claude-plugin/plugin.json +1 -1
- package/README.md +40 -0
- package/dist/cbx/src/core/worktree.js +29 -2
- package/dist/cli/src/cli.js +67 -0
- package/dist/cli/src/mods_command.js +518 -0
- package/dist/cli/src/zip_writer.js +188 -0
- package/package.json +1 -1
- package/skills/coderook/SKILL.md +26 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "coderook",
|
|
3
3
|
"displayName": "CodeRook",
|
|
4
4
|
"description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, with cbx or plain git, from Claude Code.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.35.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/README.md
CHANGED
|
@@ -462,6 +462,46 @@ https://api.coderook.com/v1/public/projects/OWNER/SLUG/releases
|
|
|
462
462
|
https://api.coderook.com/v1/public/projects/OWNER/SLUG/updates electron-updater's generic provider
|
|
463
463
|
```
|
|
464
464
|
|
|
465
|
+
## Mod repositories for games
|
|
466
|
+
|
|
467
|
+
A mod repository is where a game's players get mods from. The game's maker
|
|
468
|
+
sets one up and its rules; modders publish with their own accounts; nothing is
|
|
469
|
+
listed until it passes those rules and a malware scan.
|
|
470
|
+
|
|
471
|
+
```
|
|
472
|
+
cbx mods create my-game --name "My Game" --allow .png,.ogg,.json --nested .pak
|
|
473
|
+
cbx mods builds my-game --new a build secret for the game, shown once
|
|
474
|
+
cbx mods builds my-game --register secret.bin or your own, never printed
|
|
475
|
+
cbx mods info my-game the address and public key to pin
|
|
476
|
+
cbx mods settings my-game --uploads owner change the rules
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Rules: `--allow` and `--block` for file types (programs and scripts such as
|
|
480
|
+
`.exe`, `.dll`, `.ps1` and `.js` are refused in every repository, and so is a
|
|
481
|
+
file that is really a program whatever it is called), `--nested` for archive
|
|
482
|
+
types that are opened and checked too, `--manifest` and `--fields` for the
|
|
483
|
+
manifest each part carries (`mod.json` by default) and which of its fields must
|
|
484
|
+
match, `--max-part`, `--max-files`, `--max-parts` and `--max-ratio` for size,
|
|
485
|
+
and `--uploads anyone|owner`.
|
|
486
|
+
|
|
487
|
+
```
|
|
488
|
+
cbx mods publish ./BetterTrees --repo my-game zip, upload, wait for the checks
|
|
489
|
+
cbx mods publish ./HD-Art --repo my-game --part animals one part of a bigger mod
|
|
490
|
+
cbx mods list my-game your mods and how their checks went
|
|
491
|
+
cbx mods remove better-trees --repo my-game
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
`publish` reads the folder's manifest for the mod's id, name, folder and
|
|
495
|
+
version (`--id`, `--name`, `--folder` and `--version` override it), writes the
|
|
496
|
+
manifest the repository expects into the zip, uploads in pieces that are
|
|
497
|
+
retried, and waits up to 30 minutes for the result; `--no-wait` returns
|
|
498
|
+
straight away. A rejected part says what was wrong, file by file.
|
|
499
|
+
|
|
500
|
+
The owner also has `cbx mods reports`, `cbx mods block <username> --take-down`,
|
|
501
|
+
`cbx mods list --all`, `cbx mods remove --as-owner` and `cbx mods delete`.
|
|
502
|
+
How the game signs its requests and checks the catalogue is at
|
|
503
|
+
<https://coderook.com/docs#mod-repositories>.
|
|
504
|
+
|
|
465
505
|
## Building for every platform
|
|
466
506
|
|
|
467
507
|
```
|
|
@@ -1614,10 +1614,29 @@ const SECRET_SUFFIXES = [".pem", ".key", ".p12", ".pfx", ".keystore", ".jks"];
|
|
|
1614
1614
|
the answer be a rule — one line in .gitignore covers the lot, where a list
|
|
1615
1615
|
of the files inside it would go stale the moment the program ran again.
|
|
1616
1616
|
*/
|
|
1617
|
+
/** Chromium's disk cache: an index beside data_N blocks, Cache_Data, or the js/wasm code cache. */
|
|
1618
|
+
async function looksLikeBrowserCache(directory) {
|
|
1619
|
+
const names = new Set((await (0, promises_1.readdir)(directory).catch(() => [])).map((name) => name.toLowerCase()));
|
|
1620
|
+
if (names.has("cache_data") || names.has("index-dir"))
|
|
1621
|
+
return true;
|
|
1622
|
+
if (names.has("index") && names.has("data_0"))
|
|
1623
|
+
return true;
|
|
1624
|
+
return names.has("js") && names.has("wasm");
|
|
1625
|
+
}
|
|
1617
1626
|
const PRIVATE_DIRECTORIES = [
|
|
1618
1627
|
{
|
|
1619
|
-
match: /^(Local Storage|Session Storage|IndexedDB|Service Worker
|
|
1628
|
+
match: /^(Local Storage|Session Storage|IndexedDB|Service Worker)$/i,
|
|
1629
|
+
because: "a browser profile, which holds the sites you are signed in to",
|
|
1630
|
+
},
|
|
1631
|
+
/*
|
|
1632
|
+
"Cache" is also an ordinary name for source: FreeType keeps ftcache.c in
|
|
1633
|
+
`src/cache`, and a Godot checkout was refused as "a browser profile" for
|
|
1634
|
+
it. So these three only count when what is inside is a browser's cache.
|
|
1635
|
+
*/
|
|
1636
|
+
{
|
|
1637
|
+
match: /^(Cache|Code Cache|GPUCache)$/i,
|
|
1620
1638
|
because: "a browser profile, which holds the sites you are signed in to",
|
|
1639
|
+
inside: looksLikeBrowserCache,
|
|
1621
1640
|
},
|
|
1622
1641
|
{ match: /^\.wrangler$/i, because: "local service state, including emulated databases" },
|
|
1623
1642
|
{ match: /^\.aws$/i, because: "cloud credentials" },
|
|
@@ -1667,7 +1686,15 @@ async function detectPrivateDirectories(root) {
|
|
|
1667
1686
|
continue;
|
|
1668
1687
|
const full = node_path_1.default.join(directory, entry.name);
|
|
1669
1688
|
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
1670
|
-
|
|
1689
|
+
let rule;
|
|
1690
|
+
for (const candidate of PRIVATE_DIRECTORIES) {
|
|
1691
|
+
if (!candidate.match.test(entry.name))
|
|
1692
|
+
continue;
|
|
1693
|
+
if (candidate.inside && !(await candidate.inside(full)))
|
|
1694
|
+
continue;
|
|
1695
|
+
rule = candidate;
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1671
1698
|
if (rule) {
|
|
1672
1699
|
found.push({
|
|
1673
1700
|
path: relative,
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -31,6 +31,7 @@ const attach_command_js_1 = require("./attach_command.js");
|
|
|
31
31
|
const build_command_js_1 = require("./build_command.js");
|
|
32
32
|
const node_check_js_1 = require("./node_check.js");
|
|
33
33
|
const pages_command_js_1 = require("./pages_command.js");
|
|
34
|
+
const mods_command_js_1 = require("./mods_command.js");
|
|
34
35
|
const project_commands_js_1 = require("./project_commands.js");
|
|
35
36
|
const track_commands_js_1 = require("./track_commands.js");
|
|
36
37
|
const tracks_js_1 = require("../../cbx/src/core/tracks.js");
|
|
@@ -2792,6 +2793,72 @@ const SPECS = [
|
|
|
2792
2793
|
],
|
|
2793
2794
|
run: pages_command_js_1.commandPages,
|
|
2794
2795
|
},
|
|
2796
|
+
{
|
|
2797
|
+
name: "mods",
|
|
2798
|
+
group: "Your projects",
|
|
2799
|
+
summary: "mod repositories for games: set one up, publish to one",
|
|
2800
|
+
usage: "mods <create|repos|info|settings|builds|list|publish|remove|block|reports|delete> …",
|
|
2801
|
+
detail: "A mod repository is where a game's players get mods from. Its owner\n" +
|
|
2802
|
+
"sets the rules — which file types a mod may hold, archives opened and\n" +
|
|
2803
|
+
"checked too, the manifest each upload carries, size limits, and whether\n" +
|
|
2804
|
+
"anybody or only the owner may upload. Modders publish with their own\n" +
|
|
2805
|
+
"CodeRook accounts; nothing is listed until it passes the rules and a\n" +
|
|
2806
|
+
"malware scan, and anything that runs (.exe, .dll, scripts) is refused\n" +
|
|
2807
|
+
"in every repository. The game's client reads a catalogue signed with\n" +
|
|
2808
|
+
"the repository's own key: see coderook.com/docs#mod-repositories.\n\n" +
|
|
2809
|
+
" create <repo> --name \"Game\" set one up; prints its address and public key\n" +
|
|
2810
|
+
" repos the repositories you run\n" +
|
|
2811
|
+
" info <repo> a repository's rules, for anybody\n" +
|
|
2812
|
+
" settings <repo> [rules…] change the rules (owner)\n" +
|
|
2813
|
+
" builds <repo> game builds allowed to read it (owner)\n" +
|
|
2814
|
+
" publish <folder|zip> --repo <repo> upload a mod; waits for its checks\n" +
|
|
2815
|
+
" list <repo> [--all] your mods and how their checks went\n" +
|
|
2816
|
+
" remove <mod> --repo <repo> take a mod down\n" +
|
|
2817
|
+
" block <username> --repo <repo> stop an uploader (owner)\n" +
|
|
2818
|
+
" reports <repo> what players reported (owner)\n" +
|
|
2819
|
+
" delete <repo> delete a repository and everything in it",
|
|
2820
|
+
options: [
|
|
2821
|
+
{ flags: "--name <text>", description: "create/settings: the game's name" },
|
|
2822
|
+
{ flags: "--allow <exts>", description: "create/settings: file types a mod may hold, e.g. .png,.json" },
|
|
2823
|
+
{ flags: "--nested <exts>", description: "create/settings: archives inside a mod that are opened and checked too" },
|
|
2824
|
+
{ flags: "--manifest <file|none>", description: "create/settings: the manifest every part carries (default mod.json)" },
|
|
2825
|
+
{ flags: "--fields <list>", description: "create/settings: manifest fields that must match: id,name,folder,version" },
|
|
2826
|
+
{ flags: "--uploads <anyone|owner>", description: "create/settings: who may upload" },
|
|
2827
|
+
{ flags: "--max-part <size>", description: "create/settings: largest part, e.g. 2GB" },
|
|
2828
|
+
{ flags: "--register <file>", description: "builds: register the build secret in this file (never printed)" },
|
|
2829
|
+
{ flags: "--new", description: "builds: have the service make a build secret (shown once)" },
|
|
2830
|
+
{ flags: "--off <id>", description: "builds: turn a build off" },
|
|
2831
|
+
{ flags: "--repo <repo>", description: "publish/list/remove/block: which repository" },
|
|
2832
|
+
{ flags: "--part <id>", description: "publish: which part of the mod this is (default main)" },
|
|
2833
|
+
{ flags: "--id <mod>", description: "publish: the mod's id (default: manifest, then folder name)" },
|
|
2834
|
+
{ flags: "--version <v>", description: "publish: the mod's version" },
|
|
2835
|
+
{ flags: "--folder <name>", description: "publish: the folder the game installs it into" },
|
|
2836
|
+
{ flags: "--part-name <text>", description: "publish: a readable name for the part" },
|
|
2837
|
+
{ flags: "--about <text>", description: "create/settings/publish: a description" },
|
|
2838
|
+
{ flags: "--notice <text>", description: "publish: a notice shown with the mod (credits, licences)" },
|
|
2839
|
+
{ flags: "--block <exts>", description: "create/settings: more file types to refuse" },
|
|
2840
|
+
{ flags: "--max-files <n>", description: "create/settings: most files in a part" },
|
|
2841
|
+
{ flags: "--max-ratio <n>", description: "create/settings: how many times its size a part may unpack to" },
|
|
2842
|
+
{ flags: "--max-parts <n>", description: "create/settings: most parts in a mod" },
|
|
2843
|
+
{ flags: "--expires <date>", description: "builds: when a registered build stops working" },
|
|
2844
|
+
{ flags: "--all", description: "list: every mod in the repository (owner)" },
|
|
2845
|
+
{ flags: "--as-owner", description: "remove: as the repository's owner, any mod" },
|
|
2846
|
+
{ flags: "--disable", description: "settings: stop serving and taking uploads" },
|
|
2847
|
+
{ flags: "--enable", description: "settings: start again" },
|
|
2848
|
+
{ flags: "--undo", description: "block: let the uploader back in" },
|
|
2849
|
+
{ flags: "-y, --yes", description: "remove/delete: without asking" },
|
|
2850
|
+
{ flags: "--no-wait", description: "publish: do not wait for the checks" },
|
|
2851
|
+
{ flags: "--take-down", description: "block: also hide the uploader's mods" },
|
|
2852
|
+
],
|
|
2853
|
+
examples: [
|
|
2854
|
+
'cbx mods create my-game --name "My Game" --allow .png,.ogg,.json --nested .pak',
|
|
2855
|
+
"cbx mods builds my-game --register release/build-secret.bin",
|
|
2856
|
+
"cbx mods publish ./mods/BetterTrees --repo my-game",
|
|
2857
|
+
"cbx mods list my-game",
|
|
2858
|
+
"cbx mods publish ./HD-Art --repo zt1 --part animals",
|
|
2859
|
+
],
|
|
2860
|
+
run: mods_command_js_1.commandMods,
|
|
2861
|
+
},
|
|
2795
2862
|
{
|
|
2796
2863
|
name: "issues",
|
|
2797
2864
|
group: "Your projects",
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.commandMods = commandMods;
|
|
7
|
+
/**
|
|
8
|
+
* cbx mods — mod repositories for games.
|
|
9
|
+
*
|
|
10
|
+
* A game's developer sets one up (`cbx mods create`) and gets an address and
|
|
11
|
+
* a public key to build into the game; modders publish to it with their own
|
|
12
|
+
* CodeRook accounts (`cbx mods publish`), and nothing they send is listed
|
|
13
|
+
* until it passes the repository's checks and a malware scan. The game's own
|
|
14
|
+
* client reads the signed catalogue and downloads; that side is a protocol,
|
|
15
|
+
* described in the docs, not something this command does.
|
|
16
|
+
*/
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
18
|
+
const node_fs_1 = require("node:fs");
|
|
19
|
+
const promises_1 = require("node:fs/promises");
|
|
20
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
21
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
22
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
23
|
+
const promises_2 = require("node:readline/promises");
|
|
24
|
+
const config_js_1 = require("./config.js");
|
|
25
|
+
const colour_js_1 = require("./colour.js");
|
|
26
|
+
const zip_writer_js_1 = require("./zip_writer.js");
|
|
27
|
+
const ROOT = "/v1/modrepo";
|
|
28
|
+
function modsOrigin() {
|
|
29
|
+
return (node_process_1.default.env.CODEROOK_MODS_URL ?? "https://mods.coderook.com").replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
const flag = (parsed, ...names) => {
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
const value = parsed.flags.get(name);
|
|
34
|
+
if (typeof value === "string")
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
const has = (parsed, ...names) => names.some((name) => parsed.flags.has(name));
|
|
40
|
+
async function call(route, method = "GET", body, raw, auth = true) {
|
|
41
|
+
const headers = { accept: "application/json", "user-agent": "CodeRook-CLI/0.1" };
|
|
42
|
+
if (auth) {
|
|
43
|
+
const token = await (0, config_js_1.loadToken)();
|
|
44
|
+
if (!token)
|
|
45
|
+
throw new Error("Not signed in. Run: cbx sign-in");
|
|
46
|
+
headers.authorization = `Bearer ${token}`;
|
|
47
|
+
}
|
|
48
|
+
if (body !== undefined)
|
|
49
|
+
headers["content-type"] = "application/json";
|
|
50
|
+
if (raw)
|
|
51
|
+
headers["content-type"] = "application/octet-stream";
|
|
52
|
+
const response = await fetch(`${modsOrigin()}${route}`, {
|
|
53
|
+
method,
|
|
54
|
+
headers,
|
|
55
|
+
body: raw ?? (body === undefined ? undefined : JSON.stringify(body)),
|
|
56
|
+
});
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
let parsed = {};
|
|
59
|
+
try {
|
|
60
|
+
parsed = text ? JSON.parse(text) : {};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
parsed = { message: text.slice(0, 200) };
|
|
64
|
+
}
|
|
65
|
+
return { status: response.status, body: parsed };
|
|
66
|
+
}
|
|
67
|
+
function fail(answer, doing) {
|
|
68
|
+
if (answer.status === 401) {
|
|
69
|
+
console.error((0, colour_js_1.red)(`${doing}: not allowed. Sign in with cbx sign-in, and check you own this repository.`));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
console.error((0, colour_js_1.red)(`${doing}: ${answer.body.message ?? answer.body.error ?? `failed (${answer.status})`}`));
|
|
73
|
+
}
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
const list = (text) => text === undefined ? undefined : text.split(",").map((one) => one.trim()).filter(Boolean).map((e) => (e.startsWith(".") ? e.toLowerCase() : `.${e.toLowerCase()}`));
|
|
77
|
+
/** Rules named on the command line, for create and settings. */
|
|
78
|
+
function settingsFrom(parsed) {
|
|
79
|
+
const settings = {};
|
|
80
|
+
const allowed = list(flag(parsed, "allow"));
|
|
81
|
+
const blocked = list(flag(parsed, "block"));
|
|
82
|
+
const nested = list(flag(parsed, "nested"));
|
|
83
|
+
if (allowed)
|
|
84
|
+
settings.allowed = allowed;
|
|
85
|
+
if (blocked)
|
|
86
|
+
settings.blocked = blocked;
|
|
87
|
+
if (nested)
|
|
88
|
+
settings.nested = nested;
|
|
89
|
+
const manifest = flag(parsed, "manifest");
|
|
90
|
+
if (manifest !== undefined)
|
|
91
|
+
settings.manifest = manifest === "none" ? null : manifest;
|
|
92
|
+
const fields = flag(parsed, "fields");
|
|
93
|
+
if (fields !== undefined)
|
|
94
|
+
settings.manifestFields = fields.split(",").map((f) => f.trim()).filter(Boolean);
|
|
95
|
+
for (const [name, key] of [["max-files", "maxFiles"], ["max-ratio", "maxRatio"], ["max-parts", "maxParts"]]) {
|
|
96
|
+
const value = flag(parsed, name);
|
|
97
|
+
if (value !== undefined)
|
|
98
|
+
settings[key] = Number(value);
|
|
99
|
+
}
|
|
100
|
+
const maxPart = flag(parsed, "max-part");
|
|
101
|
+
if (maxPart !== undefined)
|
|
102
|
+
settings.maxPartBytes = parseSize(maxPart);
|
|
103
|
+
const uploads = flag(parsed, "uploads");
|
|
104
|
+
if (uploads !== undefined)
|
|
105
|
+
settings.uploads = uploads;
|
|
106
|
+
return settings;
|
|
107
|
+
}
|
|
108
|
+
function parseSize(text) {
|
|
109
|
+
const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(text.trim());
|
|
110
|
+
if (!match)
|
|
111
|
+
return Number.NaN;
|
|
112
|
+
const unit = (match[2] ?? "b").toLowerCase();
|
|
113
|
+
const factor = unit === "gb" ? 1024 ** 3 : unit === "mb" ? 1024 ** 2 : unit === "kb" ? 1024 : 1;
|
|
114
|
+
return Math.round(Number(match[1]) * factor);
|
|
115
|
+
}
|
|
116
|
+
function bytes(n) {
|
|
117
|
+
if (n === null || n === undefined)
|
|
118
|
+
return "—";
|
|
119
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
120
|
+
let value = n;
|
|
121
|
+
let unit = 0;
|
|
122
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
123
|
+
value /= 1024;
|
|
124
|
+
unit += 1;
|
|
125
|
+
}
|
|
126
|
+
return `${value.toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
|
127
|
+
}
|
|
128
|
+
function printRepo(view) {
|
|
129
|
+
console.log(`${(0, colour_js_1.bold)(view.name)} ${(0, colour_js_1.dim)(view.repo)}`);
|
|
130
|
+
if (view.about)
|
|
131
|
+
console.log((0, colour_js_1.dim)(view.about));
|
|
132
|
+
console.log(`Address ${(0, colour_js_1.accent)(view.base)}`);
|
|
133
|
+
console.log(`Public key ${view.publicKey}`);
|
|
134
|
+
console.log(`Uploads ${view.rules.uploads === "anyone" ? "any CodeRook account" : "the owner only"}`);
|
|
135
|
+
console.log(`Allowed ${view.rules.allowed.join(" ") || "(nothing)"}`);
|
|
136
|
+
if (view.rules.nested.length)
|
|
137
|
+
console.log(`Opened too ${view.rules.nested.join(" ")} (files inside may have no extension)`);
|
|
138
|
+
console.log(`Manifest ${view.rules.manifest ? `${view.rules.manifest}, matching ${view.rules.manifestFields.join(", ")}` : "none"}`);
|
|
139
|
+
console.log(`Limits ${view.rules.maxFiles.toLocaleString()} files, ${bytes(view.rules.maxPartBytes)} a part, ${view.rules.maxParts} parts, unpacked ≤ ${view.rules.maxRatio}× the zip`);
|
|
140
|
+
console.log((0, colour_js_1.dim)(`Never allowed: ${view.rules.blocked.join(" ")}`));
|
|
141
|
+
}
|
|
142
|
+
/* ------------------------------------------------------------------ subcommands */
|
|
143
|
+
async function create(parsed) {
|
|
144
|
+
const slug = parsed.positional[1];
|
|
145
|
+
const name = flag(parsed, "name");
|
|
146
|
+
if (!slug || !name) {
|
|
147
|
+
console.error((0, colour_js_1.red)('Usage: cbx mods create <repo> --name "Game name" [--allow .png,.json] [--nested .pak] [--manifest mod.json] [--uploads anyone|owner]'));
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
const answer = await call(ROOT, "POST", { repo: slug, name, about: flag(parsed, "about"), settings: settingsFrom(parsed) });
|
|
151
|
+
if (answer.status !== 201)
|
|
152
|
+
return fail(answer, "Creating the repository");
|
|
153
|
+
console.log((0, colour_js_1.green)(`Created ${answer.body.repo}.`));
|
|
154
|
+
printRepo(answer.body);
|
|
155
|
+
console.log((0, colour_js_1.dim)("\nBuild the public key into your game to check the catalogue's signature, and register your\n") +
|
|
156
|
+
(0, colour_js_1.dim)("game's build secret with: ") + (0, colour_js_1.accent)(`cbx mods builds ${answer.body.repo} --register <file>`));
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
async function repos() {
|
|
160
|
+
const answer = await call(`${ROOT}/mine`);
|
|
161
|
+
if (answer.status !== 200)
|
|
162
|
+
return fail(answer, "Listing your repositories");
|
|
163
|
+
if (!answer.body.repos.length) {
|
|
164
|
+
console.log((0, colour_js_1.dim)("You have no mod repositories. Create one with: ") + (0, colour_js_1.accent)('cbx mods create <repo> --name "Game"'));
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
for (const view of answer.body.repos)
|
|
168
|
+
console.log(`${(0, colour_js_1.accent)(view.repo.padEnd(24))} ${view.name} ${(0, colour_js_1.dim)(view.base)}`);
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
async function info(parsed) {
|
|
172
|
+
const slug = parsed.positional[1];
|
|
173
|
+
if (!slug) {
|
|
174
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods info <repo>"));
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
const answer = await call(`${ROOT}/${encodeURIComponent(slug)}/info`, "GET", undefined, undefined, false);
|
|
178
|
+
if (answer.status !== 200)
|
|
179
|
+
return fail(answer, `Reading ${slug}`);
|
|
180
|
+
printRepo(answer.body);
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
async function settings(parsed) {
|
|
184
|
+
const slug = parsed.positional[1];
|
|
185
|
+
if (!slug) {
|
|
186
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods settings <repo> [--allow …] [--uploads anyone|owner] …"));
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
const changes = settingsFrom(parsed);
|
|
190
|
+
const name = flag(parsed, "name");
|
|
191
|
+
const about = flag(parsed, "about");
|
|
192
|
+
const body = {};
|
|
193
|
+
if (Object.keys(changes).length)
|
|
194
|
+
body.settings = changes;
|
|
195
|
+
if (name !== undefined)
|
|
196
|
+
body.name = name;
|
|
197
|
+
if (about !== undefined)
|
|
198
|
+
body.about = about;
|
|
199
|
+
if (has(parsed, "disable"))
|
|
200
|
+
body.disabled = true;
|
|
201
|
+
if (has(parsed, "enable"))
|
|
202
|
+
body.disabled = false;
|
|
203
|
+
const route = `${ROOT}/${encodeURIComponent(slug)}/admin/settings`;
|
|
204
|
+
const answer = Object.keys(body).length ? await call(route, "PUT", body) : await call(route);
|
|
205
|
+
if (answer.status !== 200)
|
|
206
|
+
return fail(answer, `Settings for ${slug}`);
|
|
207
|
+
if (Object.keys(body).length)
|
|
208
|
+
console.log((0, colour_js_1.green)("Saved."));
|
|
209
|
+
printRepo(answer.body);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
async function builds(parsed) {
|
|
213
|
+
const slug = parsed.positional[1];
|
|
214
|
+
if (!slug) {
|
|
215
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods builds <repo> [--register <file> | --new | --off <id>]"));
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
const base = `${ROOT}/${encodeURIComponent(slug)}/admin/builds`;
|
|
219
|
+
const off = flag(parsed, "off");
|
|
220
|
+
if (off) {
|
|
221
|
+
const answer = await call(`${base}/${encodeURIComponent(off)}`, "DELETE");
|
|
222
|
+
if (answer.status !== 200)
|
|
223
|
+
return fail(answer, `Turning off ${off}`);
|
|
224
|
+
console.log((0, colour_js_1.green)(`Build ${off} is off: its signatures are refused from now on.`));
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
const register = flag(parsed, "register");
|
|
228
|
+
const expires = flag(parsed, "expires");
|
|
229
|
+
if (register || has(parsed, "new")) {
|
|
230
|
+
let body = {};
|
|
231
|
+
if (register) {
|
|
232
|
+
/* The secret is read from a file and sent straight to the service; it is never printed or passed on a command line. */
|
|
233
|
+
const secret = (await (0, promises_1.readFile)(register, "utf8")).trim().toLowerCase();
|
|
234
|
+
const hexSecret = /^[0-9a-f]{64}$/.test(secret) ? secret : Buffer.from(await (0, promises_1.readFile)(register)).length === 32 ? Buffer.from(await (0, promises_1.readFile)(register)).toString("hex") : "";
|
|
235
|
+
if (!hexSecret) {
|
|
236
|
+
console.error((0, colour_js_1.red)("The file must hold the 32-byte build secret, raw or as 64 hex characters."));
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
body = { secret: hexSecret };
|
|
240
|
+
}
|
|
241
|
+
if (expires)
|
|
242
|
+
body.expires = expires;
|
|
243
|
+
const answer = await call(base, "POST", body);
|
|
244
|
+
if (answer.status !== 201)
|
|
245
|
+
return fail(answer, "Registering the build");
|
|
246
|
+
console.log((0, colour_js_1.green)(`Build ${answer.body.build} registered.`));
|
|
247
|
+
if (answer.body.secret) {
|
|
248
|
+
console.log((0, colour_js_1.bold)("Secret (shown once — compile it into this build and keep it out of any repository):"));
|
|
249
|
+
console.log(answer.body.secret);
|
|
250
|
+
}
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
const answer = await call(base);
|
|
254
|
+
if (answer.status !== 200)
|
|
255
|
+
return fail(answer, `Builds of ${slug}`);
|
|
256
|
+
if (!answer.body.builds.length)
|
|
257
|
+
console.log((0, colour_js_1.dim)("No builds registered yet."));
|
|
258
|
+
for (const b of answer.body.builds) {
|
|
259
|
+
console.log(`${b.disabled ? (0, colour_js_1.dim)(b.build) : (0, colour_js_1.accent)(b.build)} ${b.disabled ? (0, colour_js_1.red)("off") : (0, colour_js_1.green)("on ")} ${(0, colour_js_1.dim)(`since ${b.created.slice(0, 10)}${b.expires ? `, until ${b.expires.slice(0, 10)}` : ""}`)}`);
|
|
260
|
+
}
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
function printMod(mod) {
|
|
264
|
+
console.log(`${(0, colour_js_1.bold)(mod.entry.name)} ${(0, colour_js_1.dim)(mod.mod)}${mod.entry.version ? (0, colour_js_1.dim)(` v${mod.entry.version}`) : ""}${mod.hidden ? (0, colour_js_1.red)(" hidden") : ""}`);
|
|
265
|
+
for (const part of mod.parts) {
|
|
266
|
+
const state = part.check.state;
|
|
267
|
+
const colour = state === "live" ? colour_js_1.green : state === "rejected" || state === "error" ? colour_js_1.red : colour_js_1.accent;
|
|
268
|
+
const live = part.live ? (0, colour_js_1.dim)(` live: ${bytes(part.live.size)}, ${part.live.files.toLocaleString()} files`) : (0, colour_js_1.dim)(" not listed");
|
|
269
|
+
console.log(` ${part.part.padEnd(16)} ${colour(state)}${live}`);
|
|
270
|
+
for (const problem of part.check.problems ?? [])
|
|
271
|
+
console.log(` ${(0, colour_js_1.red)("·")} ${problem}`);
|
|
272
|
+
for (const found of part.check.infected ?? [])
|
|
273
|
+
console.log(` ${(0, colour_js_1.red)("✗")} ${found.path}: ${found.signature}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function mine(parsed) {
|
|
277
|
+
const slug = flag(parsed, "repo") ?? parsed.positional[1];
|
|
278
|
+
if (!slug) {
|
|
279
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods list <repo>"));
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
const route = has(parsed, "all") ? `${ROOT}/${encodeURIComponent(slug)}/admin/mods` : `${ROOT}/${encodeURIComponent(slug)}/my/mods`;
|
|
283
|
+
const answer = await call(route);
|
|
284
|
+
if (answer.status !== 200)
|
|
285
|
+
return fail(answer, `Mods in ${slug}`);
|
|
286
|
+
if (!answer.body.mods.length)
|
|
287
|
+
console.log((0, colour_js_1.dim)("No mods yet. Publish one with: ") + (0, colour_js_1.accent)(`cbx mods publish <folder> --repo ${slug}`));
|
|
288
|
+
for (const mod of answer.body.mods)
|
|
289
|
+
printMod(mod);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
async function sha256File(file) {
|
|
293
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
294
|
+
for await (const chunk of (0, node_fs_1.createReadStream)(file))
|
|
295
|
+
hash.update(chunk);
|
|
296
|
+
return hash.digest("hex");
|
|
297
|
+
}
|
|
298
|
+
async function readChunk(file, start, length) {
|
|
299
|
+
const parts = [];
|
|
300
|
+
for await (const chunk of (0, node_fs_1.createReadStream)(file, { start, end: start + length - 1 }))
|
|
301
|
+
parts.push(chunk);
|
|
302
|
+
return new Uint8Array(Buffer.concat(parts));
|
|
303
|
+
}
|
|
304
|
+
async function publish(parsed) {
|
|
305
|
+
const target = parsed.positional[1];
|
|
306
|
+
const slug = flag(parsed, "repo");
|
|
307
|
+
if (!target || !slug) {
|
|
308
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods publish <folder|zip> --repo <repo> [--part <id>] [--id <mod>] [--name <text>] [--version <v>]"));
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
const source = node_path_1.default.resolve(target);
|
|
312
|
+
if (!(0, node_fs_1.existsSync)(source)) {
|
|
313
|
+
console.error((0, colour_js_1.red)(`${target} does not exist.`));
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
const base = `${ROOT}/${encodeURIComponent(slug)}`;
|
|
317
|
+
const rules = await call(`${base}/info`, "GET", undefined, undefined, false);
|
|
318
|
+
if (rules.status !== 200)
|
|
319
|
+
return fail(rules, `Reading ${slug}`);
|
|
320
|
+
const manifestName = rules.body.rules.manifest;
|
|
321
|
+
/* The mod's identity: flags first, then the manifest in the folder, then the folder's name. */
|
|
322
|
+
const isZip = (await (0, promises_1.stat)(source)).isFile();
|
|
323
|
+
let manifest = {};
|
|
324
|
+
if (!isZip && manifestName && (0, node_fs_1.existsSync)(node_path_1.default.join(source, manifestName))) {
|
|
325
|
+
try {
|
|
326
|
+
manifest = JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(source, manifestName), "utf8"));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
console.error((0, colour_js_1.red)(`${manifestName} in that folder is not valid JSON.`));
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const folderName = node_path_1.default.basename(source).replace(/\.zip$/i, "");
|
|
334
|
+
const id = (flag(parsed, "id") ?? manifest.id ?? folderName.toLowerCase().replace(/[^a-z0-9._-]+/g, "-")).toLowerCase();
|
|
335
|
+
const name = flag(parsed, "name") ?? manifest.name ?? folderName;
|
|
336
|
+
const folder = flag(parsed, "folder") ?? manifest.folder ?? folderName.replace(/[^A-Za-z0-9 ._-]+/g, "-");
|
|
337
|
+
const version = flag(parsed, "version") ?? manifest.version;
|
|
338
|
+
const partId = (flag(parsed, "part") ?? "main").toLowerCase();
|
|
339
|
+
/* Keep the parts the mod already declares, and add this one. */
|
|
340
|
+
const existing = await call(`${base}/my/mods/${encodeURIComponent(id)}`);
|
|
341
|
+
const parts = existing.status === 200 ? [...existing.body.entry.parts] : [];
|
|
342
|
+
if (!parts.some((p) => p.id === partId))
|
|
343
|
+
parts.push({ id: partId, name: flag(parsed, "part-name") ?? partId });
|
|
344
|
+
const entry = {
|
|
345
|
+
id, name, folder, parts,
|
|
346
|
+
...(version ? { version } : {}),
|
|
347
|
+
...(flag(parsed, "about") ? { about: flag(parsed, "about") } : existing.status === 200 && existing.body.entry.about ? { about: existing.body.entry.about } : {}),
|
|
348
|
+
...(flag(parsed, "notice") ? { notice: flag(parsed, "notice") } : {}),
|
|
349
|
+
};
|
|
350
|
+
const saved = await call(`${base}/my/mods`, "POST", entry);
|
|
351
|
+
if (saved.status !== 200 && saved.status !== 201)
|
|
352
|
+
return fail(saved, "Saving the mod's entry");
|
|
353
|
+
/* A folder is zipped here, with the manifest written to match the entry; a zip is sent as it is. */
|
|
354
|
+
let zipPath = source;
|
|
355
|
+
let scratch = null;
|
|
356
|
+
try {
|
|
357
|
+
if (!isZip) {
|
|
358
|
+
scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "cbx-mod-"));
|
|
359
|
+
zipPath = node_path_1.default.join(scratch, `${id}-${partId}.zip`);
|
|
360
|
+
const files = await (0, zip_writer_js_1.listFiles)(source, (relative) => relative === ".git" || relative === ".coderook" || relative.endsWith(".DS_Store"));
|
|
361
|
+
const extra = manifestName
|
|
362
|
+
? [{ path: manifestName, bytes: new TextEncoder().encode(JSON.stringify({ ...manifest, id, name, folder, ...(version ? { version } : {}) }, null, 2) + "\n") }]
|
|
363
|
+
: [];
|
|
364
|
+
node_process_1.default.stdout.write((0, colour_js_1.dim)(`Zipping ${files.length.toLocaleString()} files… `));
|
|
365
|
+
const zipped = await (0, zip_writer_js_1.writeZip)(source, files, zipPath, extra);
|
|
366
|
+
console.log((0, colour_js_1.dim)(bytes(zipped.bytes)));
|
|
367
|
+
}
|
|
368
|
+
const size = (await (0, promises_1.stat)(zipPath)).size;
|
|
369
|
+
if (size > rules.body.rules.maxPartBytes) {
|
|
370
|
+
console.error((0, colour_js_1.red)(`The part is ${bytes(size)}; ${slug} takes up to ${bytes(rules.body.rules.maxPartBytes)} a part. Split it with --part.`));
|
|
371
|
+
return 1;
|
|
372
|
+
}
|
|
373
|
+
const sha256 = await sha256File(zipPath);
|
|
374
|
+
const begun = await call(`${base}/my/mods/${encodeURIComponent(id)}/parts/${encodeURIComponent(partId)}`, "PUT", { size, sha256 });
|
|
375
|
+
if (begun.status !== 201)
|
|
376
|
+
return fail(begun, "Starting the upload");
|
|
377
|
+
for (let n = 1; n <= begun.body.chunks; n += 1) {
|
|
378
|
+
const start = (n - 1) * begun.body.chunkBytes;
|
|
379
|
+
const chunk = await readChunk(zipPath, start, Math.min(begun.body.chunkBytes, size - start));
|
|
380
|
+
let sent = null;
|
|
381
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
382
|
+
try {
|
|
383
|
+
sent = await call(`${base}/my/mods/${encodeURIComponent(id)}/parts/${encodeURIComponent(partId)}/chunks/${n}?upload=${encodeURIComponent(begun.body.upload)}`, "PUT", undefined, chunk);
|
|
384
|
+
if (sent.status === 200)
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
/* tried again below */
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (!sent || sent.status !== 200)
|
|
392
|
+
return fail(sent ?? { status: 0, body: { message: "the connection kept failing" } }, `Sending chunk ${n}`);
|
|
393
|
+
node_process_1.default.stdout.write(`\r${(0, colour_js_1.dim)(`Sent ${n} of ${begun.body.chunks} (${bytes(Math.min(size, n * begun.body.chunkBytes))} of ${bytes(size)})`)} `);
|
|
394
|
+
}
|
|
395
|
+
console.log("");
|
|
396
|
+
const done = await call(`${base}/my/mods/${encodeURIComponent(id)}/parts/${encodeURIComponent(partId)}/complete`, "POST", { upload: begun.body.upload });
|
|
397
|
+
if (done.status !== 202)
|
|
398
|
+
return fail(done, "Finishing the upload");
|
|
399
|
+
console.log((0, colour_js_1.green)(`Uploaded ${id}/${partId}.`) + (0, colour_js_1.dim)(" It is listed once its checks pass."));
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
if (scratch)
|
|
403
|
+
await (0, promises_1.rm)(scratch, { recursive: true, force: true });
|
|
404
|
+
}
|
|
405
|
+
if (has(parsed, "no-wait")) {
|
|
406
|
+
console.log((0, colour_js_1.dim)("See how the checks went with: ") + (0, colour_js_1.accent)(`cbx mods list ${slug}`));
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
node_process_1.default.stdout.write((0, colour_js_1.dim)("Checking (structure, file types, malware scan)… "));
|
|
410
|
+
const started = Date.now();
|
|
411
|
+
for (;;) {
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
413
|
+
const now = await call(`${base}/my/mods/${encodeURIComponent(id)}`);
|
|
414
|
+
const part = now.body.parts?.find((p) => p.part === partId);
|
|
415
|
+
const state = part?.check.state ?? "queued";
|
|
416
|
+
if (!["queued", "checking"].includes(state)) {
|
|
417
|
+
console.log("");
|
|
418
|
+
printMod(now.body);
|
|
419
|
+
return state === "live" ? 0 : 1;
|
|
420
|
+
}
|
|
421
|
+
if (Date.now() - started > 30 * 60 * 1000) {
|
|
422
|
+
console.log("");
|
|
423
|
+
console.log((0, colour_js_1.dim)("Still checking after 30 minutes; it carries on without you. Look later with ") + (0, colour_js_1.accent)(`cbx mods list ${slug}`));
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function remove(parsed) {
|
|
429
|
+
const id = parsed.positional[1];
|
|
430
|
+
const slug = flag(parsed, "repo");
|
|
431
|
+
if (!id || !slug) {
|
|
432
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods remove <mod> --repo <repo> [--yes]"));
|
|
433
|
+
return 1;
|
|
434
|
+
}
|
|
435
|
+
if (!has(parsed, "yes", "y")) {
|
|
436
|
+
const prompt = (0, promises_2.createInterface)({ input: node_process_1.default.stdin, output: node_process_1.default.stdout });
|
|
437
|
+
const typed = (await prompt.question(`This takes ${id} out of ${slug} and deletes its files. Type its id to confirm: `)).trim();
|
|
438
|
+
prompt.close();
|
|
439
|
+
if (typed !== id) {
|
|
440
|
+
console.error((0, colour_js_1.red)("That did not match. Nothing was removed."));
|
|
441
|
+
return 1;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const owner = has(parsed, "as-owner") ? "admin" : "my";
|
|
445
|
+
const answer = await call(`${ROOT}/${encodeURIComponent(slug)}/${owner}/mods/${encodeURIComponent(id)}`, "DELETE");
|
|
446
|
+
if (answer.status !== 200)
|
|
447
|
+
return fail(answer, `Removing ${id}`);
|
|
448
|
+
console.log((0, colour_js_1.green)(`Removed ${id}.`));
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
async function block(parsed) {
|
|
452
|
+
const account = parsed.positional[1];
|
|
453
|
+
const slug = flag(parsed, "repo");
|
|
454
|
+
if (!account || !slug) {
|
|
455
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods block <username> --repo <repo> [--take-down] | --undo"));
|
|
456
|
+
return 1;
|
|
457
|
+
}
|
|
458
|
+
const verb = has(parsed, "undo") ? "unblock" : "block";
|
|
459
|
+
const answer = await call(`${ROOT}/${encodeURIComponent(slug)}/admin/uploaders/${encodeURIComponent(account)}/${verb}`, "POST", { takeDown: has(parsed, "take-down") });
|
|
460
|
+
if (answer.status !== 200)
|
|
461
|
+
return fail(answer, `${verb === "block" ? "Blocking" : "Unblocking"} ${account}`);
|
|
462
|
+
console.log((0, colour_js_1.green)(verb === "block" ? `${account} can no longer upload to ${slug}${answer.body.hidden ? ", and their mods are hidden" : ""}.` : `${account} can upload to ${slug} again.`));
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
async function reports(parsed) {
|
|
466
|
+
const slug = parsed.positional[1];
|
|
467
|
+
if (!slug) {
|
|
468
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods reports <repo>"));
|
|
469
|
+
return 1;
|
|
470
|
+
}
|
|
471
|
+
const answer = await call(`${ROOT}/${encodeURIComponent(slug)}/admin/reports`);
|
|
472
|
+
if (answer.status !== 200)
|
|
473
|
+
return fail(answer, `Reports for ${slug}`);
|
|
474
|
+
if (!answer.body.reports.length)
|
|
475
|
+
console.log((0, colour_js_1.dim)("No reports."));
|
|
476
|
+
for (const r of answer.body.reports) {
|
|
477
|
+
console.log(`${(0, colour_js_1.dim)(r.created.slice(0, 16).replace("T", " "))} ${(0, colour_js_1.accent)(r.modId)}${r.partId ? (0, colour_js_1.dim)(`/${r.partId}`) : ""} ${(0, colour_js_1.bold)(r.reason)}${r.detail ? ` ${r.detail}` : ""}`);
|
|
478
|
+
}
|
|
479
|
+
return 0;
|
|
480
|
+
}
|
|
481
|
+
async function deleteRepo(parsed) {
|
|
482
|
+
const slug = parsed.positional[1];
|
|
483
|
+
if (!slug) {
|
|
484
|
+
console.error((0, colour_js_1.red)("Usage: cbx mods delete <repo>"));
|
|
485
|
+
return 1;
|
|
486
|
+
}
|
|
487
|
+
const prompt = (0, promises_2.createInterface)({ input: node_process_1.default.stdin, output: node_process_1.default.stdout });
|
|
488
|
+
const typed = has(parsed, "yes") ? slug : (await prompt.question(`This deletes ${slug}, every mod in it and every build registration, for good. Type ${slug} to confirm: `)).trim();
|
|
489
|
+
prompt.close();
|
|
490
|
+
if (typed !== slug) {
|
|
491
|
+
console.error((0, colour_js_1.red)("That did not match. Nothing was deleted."));
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
494
|
+
const answer = await call(`${ROOT}/${encodeURIComponent(slug)}`, "DELETE", { confirm: slug });
|
|
495
|
+
if (answer.status !== 200)
|
|
496
|
+
return fail(answer, `Deleting ${slug}`);
|
|
497
|
+
console.log((0, colour_js_1.green)(`Deleted ${slug}.`));
|
|
498
|
+
return 0;
|
|
499
|
+
}
|
|
500
|
+
async function commandMods(parsed) {
|
|
501
|
+
const sub = parsed.positional[0];
|
|
502
|
+
switch (sub) {
|
|
503
|
+
case "create": return create(parsed);
|
|
504
|
+
case "repos": return repos();
|
|
505
|
+
case "info": return info(parsed);
|
|
506
|
+
case "settings": return settings(parsed);
|
|
507
|
+
case "builds": return builds(parsed);
|
|
508
|
+
case "list": return mine(parsed);
|
|
509
|
+
case "publish": return publish(parsed);
|
|
510
|
+
case "remove": return remove(parsed);
|
|
511
|
+
case "block": return block(parsed);
|
|
512
|
+
case "reports": return reports(parsed);
|
|
513
|
+
case "delete": return deleteRepo(parsed);
|
|
514
|
+
default:
|
|
515
|
+
console.error((0, colour_js_1.red)(sub ? `cbx mods has no "${sub}". ` : "") + "See: " + (0, colour_js_1.accent)("cbx help mods"));
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.crc32 = crc32;
|
|
7
|
+
exports.listFiles = listFiles;
|
|
8
|
+
exports.writeZip = writeZip;
|
|
9
|
+
/**
|
|
10
|
+
* Write a folder as a zip, one file at a time, to disk.
|
|
11
|
+
*
|
|
12
|
+
* Mod repositories take each part as a zip, and a game's mod can hold
|
|
13
|
+
* hundreds of thousands of files and run to gigabytes — ZT1's art packs do —
|
|
14
|
+
* so the archive is written as it goes rather than built in memory, and
|
|
15
|
+
* ZIP64 records are used once sizes, offsets or the file count pass what the
|
|
16
|
+
* classic format can hold. Each file is deflated, or stored when deflating
|
|
17
|
+
* would not make it smaller (already-compressed images and audio).
|
|
18
|
+
*/
|
|
19
|
+
const node_fs_1 = require("node:fs");
|
|
20
|
+
const promises_1 = require("node:fs/promises");
|
|
21
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
22
|
+
const node_zlib_1 = require("node:zlib");
|
|
23
|
+
const CRC_TABLE = (() => {
|
|
24
|
+
const table = new Uint32Array(256);
|
|
25
|
+
for (let n = 0; n < 256; n += 1) {
|
|
26
|
+
let c = n;
|
|
27
|
+
for (let k = 0; k < 8; k += 1)
|
|
28
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
29
|
+
table[n] = c >>> 0;
|
|
30
|
+
}
|
|
31
|
+
return table;
|
|
32
|
+
})();
|
|
33
|
+
function crc32(bytes) {
|
|
34
|
+
let crc = 0xffffffff;
|
|
35
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
36
|
+
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
|
37
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
38
|
+
}
|
|
39
|
+
const MAX32 = 0xffffffff;
|
|
40
|
+
const MAX16 = 0xffff;
|
|
41
|
+
function dosTime(when) {
|
|
42
|
+
return {
|
|
43
|
+
time: (when.getHours() << 11) | (when.getMinutes() << 5) | Math.floor(when.getSeconds() / 2),
|
|
44
|
+
date: ((Math.max(1980, when.getFullYear()) - 1980) << 9) | ((when.getMonth() + 1) << 5) | when.getDate(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Every file under a folder, as zip paths with forward slashes, in a stable order. */
|
|
48
|
+
async function listFiles(root, skip = () => false) {
|
|
49
|
+
const found = [];
|
|
50
|
+
const walk = async (dir) => {
|
|
51
|
+
const entries = (await (0, promises_1.readdir)(node_path_1.default.join(root, dir), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
const relative = dir ? `${dir}/${entry.name}` : entry.name;
|
|
54
|
+
if (skip(relative))
|
|
55
|
+
continue;
|
|
56
|
+
if (entry.isSymbolicLink())
|
|
57
|
+
continue;
|
|
58
|
+
if (entry.isDirectory())
|
|
59
|
+
await walk(relative);
|
|
60
|
+
else if (entry.isFile())
|
|
61
|
+
found.push(relative);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
await walk("");
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Zip these files (paths relative to root) into `out`. `extra` adds entries
|
|
69
|
+
* written from memory — a generated manifest, for one.
|
|
70
|
+
*/
|
|
71
|
+
async function writeZip(root, files, out, extra = [], onProgress) {
|
|
72
|
+
const stream = (0, node_fs_1.createWriteStream)(out);
|
|
73
|
+
let failed = null;
|
|
74
|
+
stream.on("error", (error) => {
|
|
75
|
+
failed = error;
|
|
76
|
+
});
|
|
77
|
+
const write = async (chunk) => {
|
|
78
|
+
if (failed)
|
|
79
|
+
throw failed;
|
|
80
|
+
if (!stream.write(chunk))
|
|
81
|
+
await new Promise((resolve) => stream.once("drain", resolve));
|
|
82
|
+
if (failed)
|
|
83
|
+
throw failed;
|
|
84
|
+
};
|
|
85
|
+
const written = [];
|
|
86
|
+
let offset = 0;
|
|
87
|
+
const { time, date } = dosTime(new Date());
|
|
88
|
+
const extraNames = new Set(extra.map((one) => one.path));
|
|
89
|
+
const sources = [
|
|
90
|
+
...extra.map((one) => ({ path: one.path, load: async () => one.bytes })),
|
|
91
|
+
...files.filter((file) => !extraNames.has(file)).map((file) => ({ path: file, load: () => (0, promises_1.readFile)(node_path_1.default.join(root, file)) })),
|
|
92
|
+
];
|
|
93
|
+
for (const [index, source] of sources.entries()) {
|
|
94
|
+
const raw = await source.load();
|
|
95
|
+
const deflated = (0, node_zlib_1.deflateRawSync)(raw, { level: 6 });
|
|
96
|
+
const store = deflated.length >= raw.length;
|
|
97
|
+
const body = store ? raw : deflated;
|
|
98
|
+
const name = Buffer.from(source.path, "utf8");
|
|
99
|
+
const crc = crc32(raw);
|
|
100
|
+
const zip64 = raw.length >= MAX32 || body.length >= MAX32;
|
|
101
|
+
const local = Buffer.alloc(30);
|
|
102
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
103
|
+
local.writeUInt16LE(zip64 ? 45 : 20, 4);
|
|
104
|
+
local.writeUInt16LE(0x0800, 6); /* UTF-8 names */
|
|
105
|
+
local.writeUInt16LE(store ? 0 : 8, 8);
|
|
106
|
+
local.writeUInt16LE(time, 10);
|
|
107
|
+
local.writeUInt16LE(date, 12);
|
|
108
|
+
local.writeUInt32LE(crc, 14);
|
|
109
|
+
local.writeUInt32LE(zip64 ? MAX32 : body.length, 18);
|
|
110
|
+
local.writeUInt32LE(zip64 ? MAX32 : raw.length, 22);
|
|
111
|
+
local.writeUInt16LE(name.length, 26);
|
|
112
|
+
const localExtra = zip64 ? zip64Extra([raw.length, body.length]) : Buffer.alloc(0);
|
|
113
|
+
local.writeUInt16LE(localExtra.length, 28);
|
|
114
|
+
await write(local);
|
|
115
|
+
await write(name);
|
|
116
|
+
if (localExtra.length)
|
|
117
|
+
await write(localExtra);
|
|
118
|
+
await write(body);
|
|
119
|
+
written.push({ name, crc, method: store ? 0 : 8, compressed: body.length, size: raw.length, offset, time, date });
|
|
120
|
+
offset += local.length + name.length + localExtra.length + body.length;
|
|
121
|
+
onProgress?.(index + 1, sources.length);
|
|
122
|
+
}
|
|
123
|
+
const centralStart = offset;
|
|
124
|
+
for (const entry of written) {
|
|
125
|
+
const big = [entry.size >= MAX32, entry.compressed >= MAX32, entry.offset >= MAX32];
|
|
126
|
+
const values = [
|
|
127
|
+
...(big[0] ? [entry.size] : []),
|
|
128
|
+
...(big[1] ? [entry.compressed] : []),
|
|
129
|
+
...(big[2] ? [entry.offset] : []),
|
|
130
|
+
];
|
|
131
|
+
const extraField = values.length ? zip64Extra(values) : Buffer.alloc(0);
|
|
132
|
+
const central = Buffer.alloc(46);
|
|
133
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
134
|
+
central.writeUInt16LE(values.length ? 45 : 20, 4);
|
|
135
|
+
central.writeUInt16LE(values.length ? 45 : 20, 6);
|
|
136
|
+
central.writeUInt16LE(0x0800, 8);
|
|
137
|
+
central.writeUInt16LE(entry.method, 10);
|
|
138
|
+
central.writeUInt16LE(entry.time, 12);
|
|
139
|
+
central.writeUInt16LE(entry.date, 14);
|
|
140
|
+
central.writeUInt32LE(entry.crc, 16);
|
|
141
|
+
central.writeUInt32LE(big[1] ? MAX32 : entry.compressed, 20);
|
|
142
|
+
central.writeUInt32LE(big[0] ? MAX32 : entry.size, 24);
|
|
143
|
+
central.writeUInt16LE(entry.name.length, 28);
|
|
144
|
+
central.writeUInt16LE(extraField.length, 30);
|
|
145
|
+
central.writeUInt32LE((0o100644 * 0x10000) >>> 0, 38); /* a plain file, never a link */
|
|
146
|
+
central.writeUInt32LE(big[2] ? MAX32 : entry.offset, 42);
|
|
147
|
+
await write(central);
|
|
148
|
+
await write(entry.name);
|
|
149
|
+
if (extraField.length)
|
|
150
|
+
await write(extraField);
|
|
151
|
+
offset += central.length + entry.name.length + extraField.length;
|
|
152
|
+
}
|
|
153
|
+
const centralSize = offset - centralStart;
|
|
154
|
+
const needs64 = written.length >= MAX16 || centralStart >= MAX32 || centralSize >= MAX32;
|
|
155
|
+
if (needs64) {
|
|
156
|
+
const record = Buffer.alloc(56);
|
|
157
|
+
record.writeUInt32LE(0x06064b50, 0);
|
|
158
|
+
record.writeBigUInt64LE(44n, 4);
|
|
159
|
+
record.writeUInt16LE(45, 12);
|
|
160
|
+
record.writeUInt16LE(45, 14);
|
|
161
|
+
record.writeBigUInt64LE(BigInt(written.length), 24);
|
|
162
|
+
record.writeBigUInt64LE(BigInt(written.length), 32);
|
|
163
|
+
record.writeBigUInt64LE(BigInt(centralSize), 40);
|
|
164
|
+
record.writeBigUInt64LE(BigInt(centralStart), 48);
|
|
165
|
+
const locator = Buffer.alloc(20);
|
|
166
|
+
locator.writeUInt32LE(0x07064b50, 0);
|
|
167
|
+
locator.writeBigUInt64LE(BigInt(offset), 8);
|
|
168
|
+
locator.writeUInt32LE(1, 16);
|
|
169
|
+
await write(record);
|
|
170
|
+
await write(locator);
|
|
171
|
+
}
|
|
172
|
+
const end = Buffer.alloc(22);
|
|
173
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
174
|
+
end.writeUInt16LE(needs64 ? MAX16 : written.length, 8);
|
|
175
|
+
end.writeUInt16LE(needs64 ? MAX16 : written.length, 10);
|
|
176
|
+
end.writeUInt32LE(needs64 ? MAX32 : centralSize, 12);
|
|
177
|
+
end.writeUInt32LE(needs64 ? MAX32 : centralStart, 16);
|
|
178
|
+
await write(end);
|
|
179
|
+
await new Promise((resolve, reject) => stream.end((error) => (error ? reject(error) : resolve())));
|
|
180
|
+
return { files: written.length, bytes: offset + (needs64 ? 76 : 0) + 22 };
|
|
181
|
+
}
|
|
182
|
+
function zip64Extra(values) {
|
|
183
|
+
const field = Buffer.alloc(4 + values.length * 8);
|
|
184
|
+
field.writeUInt16LE(0x0001, 0);
|
|
185
|
+
field.writeUInt16LE(values.length * 8, 2);
|
|
186
|
+
values.forEach((value, index) => field.writeBigUInt64LE(BigInt(value), 4 + index * 8));
|
|
187
|
+
return field;
|
|
188
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderook/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://coderook.com",
|
package/skills/coderook/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: coderook
|
|
3
|
-
description: Save, browse and restore versions of a project on CodeRook — a version host where every version is a complete snapshot, reached with the cbx command or with plain git. Use when asked to save, submit or push work to CodeRook, check what has changed, look at a project's versions or files, fetch a project, start or switch a line of work, resolve a save that landed at the same time as somebody else's,
|
|
3
|
+
description: Save, browse and restore versions of a project on CodeRook — a version host where every version is a complete snapshot, reached with the cbx command or with plain git. Use when asked to save, submit or push work to CodeRook, check what has changed, look at a project's versions or files, fetch a project, start or switch a line of work, resolve a save that landed at the same time as somebody else's, build a project's executables for Windows, Linux and macOS from one machine (cbx build), or publish a mod to a game's mod repository or set one up (cbx mods).
|
|
4
4
|
allowed-tools: Bash Read
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -306,6 +306,31 @@ unasked. `--ship` attaches the results to the newest save, so **ask before
|
|
|
306
306
|
`--ship`**, as before any save. A project's own build description lives in
|
|
307
307
|
`coderook.build.json`.
|
|
308
308
|
|
|
309
|
+
## Mod repositories for games
|
|
310
|
+
|
|
311
|
+
A game's maker can run a mod repository: players' game clients read a signed
|
|
312
|
+
catalogue from it, modders publish to it, and nothing is listed until it
|
|
313
|
+
passes the repository's rules and a malware scan.
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
cbx mods info zt1 # a repository's rules, for anybody
|
|
317
|
+
cbx mods publish ./BetterTrees --repo zt1 # zip, upload, wait for the checks
|
|
318
|
+
cbx mods publish ./HD-Art --repo zt1 --part animals # one part of a bigger mod
|
|
319
|
+
cbx mods list zt1 # your mods and how their checks went
|
|
320
|
+
cbx mods create my-game --name "My Game" --allow .png,.ogg,.json
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`publish` reads the folder's `mod.json` (or the repository's manifest) for the
|
|
324
|
+
mod's id, name, folder and version; `--id`, `--name`, `--folder` and
|
|
325
|
+
`--version` override it. It waits up to 30 minutes for the checks and prints
|
|
326
|
+
what failed, file by file — pass that on as it is. Programs and scripts
|
|
327
|
+
(`.exe`, `.dll`, `.ps1`, `.js` …) are refused in every repository, so do not
|
|
328
|
+
try to rename one past the check. **Ask before `publish`**, as before any
|
|
329
|
+
save: it makes the mod public once it passes. Ask before `remove`, `block`
|
|
330
|
+
and `delete`, which only the person should decide. The game-side protocol
|
|
331
|
+
(signed requests, the catalogue signature, downloads) is at
|
|
332
|
+
https://coderook.com/docs#mod-repositories.
|
|
333
|
+
|
|
309
334
|
## Things not to do
|
|
310
335
|
|
|
311
336
|
- Do not run `cbx delete` unless the person has asked for that project to
|