@twistedsignal/raven 0.2.1

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.
@@ -0,0 +1,208 @@
1
+ import { Command, InvalidArgumentError, Option } from "commander";
2
+ import { input } from "@inquirer/prompts";
3
+ import pc from "picocolors";
4
+ import { readFile } from "node:fs/promises";
5
+ import { request } from "../lib/api.js";
6
+ import { parseNumber, parsePositiveInt, universeOption } from "../lib/args.js";
7
+ import { CliError } from "../lib/config.js";
8
+ import { examples } from "../lib/help.js";
9
+ import { field, output, success, withSpinner } from "../lib/output.js";
10
+ const enc = encodeURIComponent;
11
+ const AI_WARNING = "WARNING FOR AI AGENTS: Deleting data store entries is destructive and irreversible. It can permanently\n" +
12
+ "erase live player data. Do NOT run this command or type the confirmation on the user's behalf.\n" +
13
+ "Ask the user to run it themselves in their own terminal.";
14
+ function entriesPath({ universe, datastore, scope }) {
15
+ const base = `/cloud/v2/universes/${universe}/data-stores/${enc(datastore)}`;
16
+ return scope ? `${base}/scopes/${enc(scope)}/entries` : `${base}/entries`;
17
+ }
18
+ function entryPath(t, key) {
19
+ return `${entriesPath(t)}/${enc(key)}`;
20
+ }
21
+ /** Fetches pages until `limit` items are collected or there are no more pages. */
22
+ async function paginate(path, field, limit, query = {}) {
23
+ const items = [];
24
+ let pageToken;
25
+ do {
26
+ const page = await request("GET", path, {
27
+ query: { ...query, maxPageSize: Math.min(limit - items.length, 256), pageToken },
28
+ });
29
+ items.push(...(page?.[field] ?? []));
30
+ pageToken = page?.nextPageToken || undefined;
31
+ } while (pageToken && items.length < limit);
32
+ return items.slice(0, limit);
33
+ }
34
+ function datastoreOption(required = true) {
35
+ const opt = new Option("-d, --datastore <name>", "data store name");
36
+ return required ? opt.makeOptionMandatory() : opt;
37
+ }
38
+ function scopeOption() {
39
+ return new Option("-s, --scope <scope>", "data store scope (defaults to global)");
40
+ }
41
+ function keyOption() {
42
+ return new Option("-k, --key <key>", "entry key").makeOptionMandatory();
43
+ }
44
+ function parseJsonOption(name) {
45
+ return (value) => {
46
+ try {
47
+ return JSON.parse(value);
48
+ }
49
+ catch {
50
+ throw new InvalidArgumentError("Expected valid JSON.");
51
+ }
52
+ };
53
+ }
54
+ function formatValue(value) {
55
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
56
+ }
57
+ export function datastoreCommand() {
58
+ const ds = new Command("datastore")
59
+ .alias("ds")
60
+ .description("read and write data store entries")
61
+ .addHelpText("after", examples(["raven ds list -u 123", "list data stores"], ["raven ds list -u 123 -d Players --prefix user_", "list keys"], ["raven ds get -u 123 -d Players -k user_1"], [`raven ds set -u 123 -d Players -k user_1 --value '{"coins":100}'`], ["raven ds increment -u 123 -d Stats -k visits --by 5"], ["raven ds delete -u 123 -d Players -k user_1", "asks you to confirm"]));
62
+ ds.command("list")
63
+ .description("list data stores, or the keys in a data store when --datastore is given")
64
+ .addOption(universeOption())
65
+ .addOption(datastoreOption(false))
66
+ .addOption(scopeOption())
67
+ .option("--prefix <prefix>", "only show names/keys starting with this prefix")
68
+ .option("--limit <n>", "maximum number of results", parsePositiveInt, 100)
69
+ .action(async (opts) => {
70
+ const filter = opts.prefix !== undefined ? `id.startsWith(${JSON.stringify(opts.prefix)})` : undefined;
71
+ if (!opts.datastore) {
72
+ if (opts.scope)
73
+ throw new CliError("--scope requires --datastore.");
74
+ const stores = await withSpinner("Fetching data stores...", () => paginate(`/cloud/v2/universes/${opts.universe}/data-stores`, "dataStores", opts.limit, { filter }));
75
+ return output(stores, () => {
76
+ if (!stores.length)
77
+ return console.log("No data stores found.");
78
+ for (const s of stores)
79
+ console.log(`${s.id}${s.createTime ? pc.dim(` created ${s.createTime}`) : ""}`);
80
+ });
81
+ }
82
+ const entries = await withSpinner(`Fetching keys in ${opts.datastore}...`, () => paginate(entriesPath(opts), "dataStoreEntries", opts.limit, { filter }));
83
+ output(entries, () => {
84
+ if (!entries.length)
85
+ return console.log("No entries found.");
86
+ for (const e of entries)
87
+ console.log(e.id ?? e.path?.split("/").pop());
88
+ });
89
+ });
90
+ ds.command("get")
91
+ .description("get the value of an entry")
92
+ .addOption(universeOption())
93
+ .addOption(datastoreOption())
94
+ .addOption(keyOption())
95
+ .addOption(scopeOption())
96
+ .option("--meta", "also show entry metadata")
97
+ .action(async (opts) => {
98
+ const entry = await withSpinner(`Fetching ${opts.key}...`, () => request("GET", entryPath(opts, opts.key)));
99
+ output(entry, () => {
100
+ if (opts.meta) {
101
+ field("Key", entry.id);
102
+ field("Revision", entry.revisionId);
103
+ field("Updated", entry.revisionCreateTime);
104
+ field("Created", entry.createTime);
105
+ field("Users", entry.users?.join(", "));
106
+ if (entry.attributes && Object.keys(entry.attributes).length)
107
+ field("Attributes", JSON.stringify(entry.attributes));
108
+ console.log();
109
+ }
110
+ console.log(formatValue(entry.value));
111
+ });
112
+ });
113
+ ds.command("set")
114
+ .description("create or overwrite an entry")
115
+ .addOption(universeOption())
116
+ .addOption(datastoreOption())
117
+ .addOption(keyOption())
118
+ .addOption(scopeOption())
119
+ .option("--value <json>", "value as JSON (non-JSON input is stored as a string)")
120
+ .option("--file <path>", "read the value from a JSON file")
121
+ .option("--string", "store --value as a string without parsing it as JSON")
122
+ .option("--users <ids>", "comma-separated user IDs to associate with the entry")
123
+ .option("--attributes <json>", "entry attributes as a JSON object", parseJsonOption("attributes"))
124
+ .action(async (opts) => {
125
+ if ((opts.value === undefined) === (opts.file === undefined)) {
126
+ throw new CliError("Pass exactly one of --value or --file.");
127
+ }
128
+ let value;
129
+ if (opts.file) {
130
+ let raw;
131
+ try {
132
+ raw = await readFile(opts.file, "utf8");
133
+ }
134
+ catch (err) {
135
+ throw new CliError(`Could not read ${opts.file}: ${err.message}`);
136
+ }
137
+ try {
138
+ value = JSON.parse(raw);
139
+ }
140
+ catch {
141
+ throw new CliError(`${opts.file} is not valid JSON.`);
142
+ }
143
+ }
144
+ else if (opts.string) {
145
+ value = opts.value;
146
+ }
147
+ else {
148
+ try {
149
+ value = JSON.parse(opts.value);
150
+ }
151
+ catch {
152
+ value = opts.value;
153
+ }
154
+ }
155
+ const users = opts.users
156
+ ?.split(",")
157
+ .map((u) => u.trim())
158
+ .filter(Boolean)
159
+ .map((u) => (u.startsWith("users/") ? u : `users/${u}`));
160
+ const entry = await withSpinner(`Writing ${opts.key}...`, () => request("PATCH", entryPath(opts, opts.key), {
161
+ query: { allowMissing: true },
162
+ json: { value, users, attributes: opts.attributes },
163
+ }));
164
+ output(entry, () => {
165
+ success(`Set ${pc.cyan(opts.key)} in ${pc.cyan(opts.datastore)}`);
166
+ field("Revision", entry?.revisionId);
167
+ });
168
+ });
169
+ ds.command("increment")
170
+ .description("atomically increment a numeric entry")
171
+ .addOption(universeOption())
172
+ .addOption(datastoreOption())
173
+ .addOption(keyOption())
174
+ .addOption(scopeOption())
175
+ .option("--by <amount>", "amount to increment by (can be negative)", parseNumber, 1)
176
+ .action(async (opts) => {
177
+ const entry = await withSpinner(`Incrementing ${opts.key}...`, () => request("POST", `${entryPath(opts, opts.key)}:increment`, { json: { amount: opts.by } }));
178
+ output(entry, () => success(`${pc.cyan(opts.key)} is now ${pc.bold(formatValue(entry?.value))}`));
179
+ });
180
+ ds.command("delete")
181
+ .description("permanently delete an entry (interactive confirmation required)")
182
+ .addOption(universeOption())
183
+ .addOption(datastoreOption())
184
+ .addOption(keyOption())
185
+ .addOption(scopeOption())
186
+ .addHelpText("after", `\n${AI_WARNING}`)
187
+ .action(async (opts) => {
188
+ const where = `${opts.datastore}${opts.scope ? ` (scope ${opts.scope})` : ""} in universe ${opts.universe}`;
189
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
190
+ throw new CliError(`Refusing to delete: \`raven datastore delete\` must be confirmed interactively in a terminal.\n\n${AI_WARNING}`);
191
+ }
192
+ console.error();
193
+ console.error(pc.bgRed(pc.white(pc.bold(" DANGER "))) + pc.red(pc.bold(" This action cannot be undone.")));
194
+ console.error();
195
+ console.error(`This will permanently delete the key ${pc.bold(opts.key)} from ${pc.bold(where)}.`);
196
+ console.error("Any player data or game state stored under this key will be lost.");
197
+ console.error();
198
+ console.error(pc.yellow(AI_WARNING));
199
+ console.error();
200
+ const typed = await input({ message: `To confirm, type the data store name ${pc.bold(`"${opts.datastore}"`)}:` });
201
+ if (typed !== opts.datastore) {
202
+ throw new CliError("Data store name did not match. Nothing was deleted.");
203
+ }
204
+ await withSpinner(`Deleting ${opts.key}...`, () => request("DELETE", entryPath(opts, opts.key)));
205
+ output({ deleted: opts.key, datastore: opts.datastore, scope: opts.scope }, () => success(`Deleted ${pc.cyan(opts.key)} from ${pc.cyan(opts.datastore)}`));
206
+ });
207
+ return ds;
208
+ }
@@ -0,0 +1,242 @@
1
+ import { Command, InvalidArgumentError, Option } from "commander";
2
+ import pc from "picocolors";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename, extname } from "node:path";
5
+ import { request } from "../lib/api.js";
6
+ import { parseId, parsePositiveInt, universeOption } from "../lib/args.js";
7
+ import { CliError } from "../lib/config.js";
8
+ import { examples } from "../lib/help.js";
9
+ import { field, output, success, withSpinner } from "../lib/output.js";
10
+ /** The game pass and developer product APIs are near-identical; these are the parts that differ. */
11
+ const TYPES = {
12
+ gamepass: {
13
+ label: "game pass",
14
+ base: (u) => `/game-passes/v1/universes/${u}/game-passes`,
15
+ idField: "gamePassId",
16
+ iconField: "iconAssetId",
17
+ listField: "gamePasses",
18
+ createIconField: "imageFile",
19
+ updateIconField: "file",
20
+ url: (id) => `https://www.roblox.com/game-pass/${id}`,
21
+ },
22
+ devProduct: {
23
+ label: "developer product",
24
+ base: (u) => `/developer-products/v2/universes/${u}/developer-products`,
25
+ idField: "productId",
26
+ iconField: "iconImageAssetId",
27
+ listField: "developerProducts",
28
+ createIconField: "imageFile",
29
+ updateIconField: "imageFile",
30
+ },
31
+ };
32
+ const TYPE_ALIASES = {
33
+ gamepass: "gamepass",
34
+ "game-pass": "gamepass",
35
+ pass: "gamepass",
36
+ devproduct: "devProduct",
37
+ "dev-product": "devProduct",
38
+ developerproduct: "devProduct",
39
+ "developer-product": "devProduct",
40
+ product: "devProduct",
41
+ };
42
+ function parseType(value) {
43
+ const type = TYPE_ALIASES[value.toLowerCase()];
44
+ if (!type)
45
+ throw new InvalidArgumentError("Expected `gamepass` or `devProduct`.");
46
+ return type;
47
+ }
48
+ function typeOption() {
49
+ return new Option("-t, --type <type>", "product type")
50
+ .choices(["gamepass", "devProduct"])
51
+ .argParser(parseType)
52
+ .makeOptionMandatory();
53
+ }
54
+ const IMAGE_TYPES = {
55
+ ".png": "image/png",
56
+ ".jpg": "image/jpeg",
57
+ ".jpeg": "image/jpeg",
58
+ ".bmp": "image/bmp",
59
+ ".tga": "image/tga",
60
+ };
61
+ async function readIcon(path) {
62
+ const type = IMAGE_TYPES[extname(path).toLowerCase()];
63
+ if (!type)
64
+ throw new CliError(`Icons must be one of: ${Object.keys(IMAGE_TYPES).join(", ")}`);
65
+ try {
66
+ return new Blob([await readFile(path)], { type });
67
+ }
68
+ catch (err) {
69
+ throw new CliError(`Could not read ${path}: ${err.message}`);
70
+ }
71
+ }
72
+ /** Maps a raw API response onto the shared Product shape. */
73
+ function normalize(type, raw) {
74
+ const t = TYPES[type];
75
+ return { ...raw, id: raw[t.idField], iconAssetId: raw[t.iconField] };
76
+ }
77
+ async function getProduct(type, universe, id) {
78
+ return normalize(type, await request("GET", `${TYPES[type].base(universe)}/${id}/creator`));
79
+ }
80
+ function price(p) {
81
+ const robux = p.priceInformation?.defaultPriceInRobux;
82
+ return robux == null ? "not set" : `R$${robux}`;
83
+ }
84
+ function managedPricing(p) {
85
+ return p.priceInformation?.enabledFeatures?.includes("RegionalPricing") ?? false;
86
+ }
87
+ function printProduct(type, p) {
88
+ field("ID", p.id);
89
+ field("Type", TYPES[type].label);
90
+ field("Name", p.name);
91
+ field("Description", p.description);
92
+ field("Price", price(p));
93
+ field("Status", p.isForSale ? pc.green("on sale") : pc.yellow("off sale"));
94
+ field("Managed price", managedPricing(p) ? "on" : "off");
95
+ field("Icon asset", p.iconAssetId || undefined);
96
+ field("Updated", p.updatedTimestamp);
97
+ if (TYPES[type].url)
98
+ field("URL", pc.underline(TYPES[type].url(p.id)));
99
+ }
100
+ async function buildForm(opts, iconField) {
101
+ if (opts.onsale && opts.offsale)
102
+ throw new CliError("Pass only one of --onsale or --offsale.");
103
+ const form = new FormData();
104
+ if (opts.name !== undefined)
105
+ form.append("name", opts.name);
106
+ if (opts.description !== undefined)
107
+ form.append("description", opts.description);
108
+ if (opts.price !== undefined)
109
+ form.append("price", String(opts.price));
110
+ if (opts.onsale)
111
+ form.append("isForSale", "true");
112
+ if (opts.offsale)
113
+ form.append("isForSale", "false");
114
+ if (opts.managedPricing !== undefined)
115
+ form.append("isRegionalPricingEnabled", String(opts.managedPricing));
116
+ if (opts.icon)
117
+ form.append(iconField, await readIcon(opts.icon), basename(opts.icon));
118
+ return form;
119
+ }
120
+ async function updateAndFetch(type, universe, id, form) {
121
+ await request("PATCH", `${TYPES[type].base(universe)}/${id}`, { body: form });
122
+ return getProduct(type, universe, id);
123
+ }
124
+ function managedPricingOptions(cmd) {
125
+ return cmd
126
+ .option("--managed-pricing", "turn on managed (regional) pricing")
127
+ .option("--no-managed-pricing", "turn off managed (regional) pricing");
128
+ }
129
+ export function productCommand() {
130
+ const product = new Command("product")
131
+ .description("create and manage game passes and developer products")
132
+ .addHelpText("after", `
133
+ Every subcommand needs --type gamepass or --type devProduct.
134
+ ${examples(["raven product add -t gamepass -u 123 --name VIP --price 100 --icon vip.png"], ["raven product add -t devProduct -u 123 --name Coins --price 25 --managed-pricing"], ["raven product update -t devProduct -u 123 --id 456 --no-managed-pricing"], ["raven product disable -t gamepass -u 123 --id 456", "take off sale"], ["raven product list -t devProduct -u 123"])}`);
135
+ managedPricingOptions(product
136
+ .command("add")
137
+ .description("create a new game pass or developer product")
138
+ .addOption(typeOption())
139
+ .addOption(universeOption())
140
+ .requiredOption("--name <name>", "product name")
141
+ .option("--description <text>", "product description")
142
+ .option("--price <robux>", "price in Robux (puts it on sale unless --offsale is given)", parsePositiveInt)
143
+ .option("--icon <file>", "icon image (.png, .jpg, .bmp, .tga)")
144
+ .option("--offsale", "create it without putting it on sale")).action(async (opts) => {
145
+ const t = TYPES[opts.type];
146
+ const form = await buildForm({ ...opts, onsale: opts.price !== undefined && !opts.offsale }, t.createIconField);
147
+ const created = await withSpinner(`Creating ${t.label} ${opts.name}...`, async () => normalize(opts.type, await request("POST", t.base(opts.universe), { body: form })));
148
+ output(created, () => {
149
+ success(`Created ${t.label} ${pc.cyan(created.name)}`);
150
+ printProduct(opts.type, created);
151
+ });
152
+ });
153
+ managedPricingOptions(product
154
+ .command("update")
155
+ .description("update a game pass or developer product")
156
+ .addOption(typeOption())
157
+ .addOption(universeOption())
158
+ .requiredOption("--id <id>", "game pass or developer product ID", parseId)
159
+ .option("--name <name>", "new name")
160
+ .option("--description <text>", "new description")
161
+ .option("--price <robux>", "new price in Robux", parsePositiveInt)
162
+ .option("--icon <file>", "new icon image (.png, .jpg, .bmp, .tga)")
163
+ .option("--onsale", "put it on sale")
164
+ .option("--offsale", "take it off sale")).action(async (opts) => {
165
+ const t = TYPES[opts.type];
166
+ const form = await buildForm(opts, t.updateIconField);
167
+ if ([...form.keys()].length === 0) {
168
+ throw new CliError("Nothing to update. Pass at least one of --name, --description, --price, --icon, --onsale, --offsale, or --[no-]managed-pricing.");
169
+ }
170
+ const updated = await withSpinner(`Updating ${t.label} ${opts.id}...`, () => updateAndFetch(opts.type, opts.universe, opts.id, form));
171
+ output(updated, () => {
172
+ success(`Updated ${t.label} ${pc.cyan(updated.name)}`);
173
+ printProduct(opts.type, updated);
174
+ });
175
+ });
176
+ product
177
+ .command("disable")
178
+ .alias("offsale")
179
+ .description("take a game pass or developer product off sale (they can't be deleted)")
180
+ .addOption(typeOption())
181
+ .addOption(universeOption())
182
+ .requiredOption("--id <id>", "game pass or developer product ID", parseId)
183
+ .action(async (opts) => {
184
+ const t = TYPES[opts.type];
185
+ const updated = await withSpinner(`Disabling ${t.label} ${opts.id}...`, async () => updateAndFetch(opts.type, opts.universe, opts.id, await buildForm({ offsale: true }, t.updateIconField)));
186
+ output(updated, () => success(`${pc.cyan(updated.name)} is now off sale.`));
187
+ });
188
+ product
189
+ .command("enable")
190
+ .alias("onsale")
191
+ .description("put a game pass or developer product back on sale")
192
+ .addOption(typeOption())
193
+ .addOption(universeOption())
194
+ .requiredOption("--id <id>", "game pass or developer product ID", parseId)
195
+ .option("--price <robux>", "price in Robux (required if it has no price yet)", parsePositiveInt)
196
+ .action(async (opts) => {
197
+ const t = TYPES[opts.type];
198
+ const updated = await withSpinner(`Enabling ${t.label} ${opts.id}...`, async () => updateAndFetch(opts.type, opts.universe, opts.id, await buildForm({ onsale: true, price: opts.price }, t.updateIconField)));
199
+ output(updated, () => success(`${pc.cyan(updated.name)} is now on sale for ${price(updated)}.`));
200
+ });
201
+ product
202
+ .command("get")
203
+ .description("show a game pass or developer product")
204
+ .addOption(typeOption())
205
+ .addOption(universeOption())
206
+ .requiredOption("--id <id>", "game pass or developer product ID", parseId)
207
+ .action(async (opts) => {
208
+ const p = await withSpinner(`Fetching ${TYPES[opts.type].label} ${opts.id}...`, () => getProduct(opts.type, opts.universe, opts.id));
209
+ output(p, () => printProduct(opts.type, p));
210
+ });
211
+ product
212
+ .command("list")
213
+ .description("list game passes or developer products in an experience")
214
+ .addOption(typeOption())
215
+ .addOption(universeOption())
216
+ .option("--limit <n>", "maximum number of results", parsePositiveInt, 100)
217
+ .action(async (opts) => {
218
+ const t = TYPES[opts.type];
219
+ const items = await withSpinner(`Fetching ${t.label}s...`, async () => {
220
+ const items = [];
221
+ let pageToken;
222
+ do {
223
+ const page = await request("GET", `${t.base(opts.universe)}/creator`, {
224
+ query: { pageSize: Math.min(opts.limit - items.length, 100), pageToken },
225
+ });
226
+ items.push(...(page[t.listField] ?? []).map((raw) => normalize(opts.type, raw)));
227
+ pageToken = page.nextPageToken || undefined;
228
+ } while (pageToken && items.length < opts.limit);
229
+ return items.slice(0, opts.limit);
230
+ });
231
+ output(items, () => {
232
+ if (!items.length)
233
+ return console.log(`No ${t.label}s found.`);
234
+ for (const p of items) {
235
+ const status = p.isForSale ? pc.green(price(p)) : pc.yellow("off sale");
236
+ const managed = managedPricing(p) ? pc.dim(" managed") : "";
237
+ console.log(`${pc.dim(String(p.id).padEnd(14))} ${p.name} ${status}${managed}`);
238
+ }
239
+ });
240
+ });
241
+ return product;
242
+ }
@@ -0,0 +1,46 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename, extname } from "node:path";
5
+ import { request } from "../lib/api.js";
6
+ import { placeOption, universeOption } from "../lib/args.js";
7
+ import { CliError } from "../lib/config.js";
8
+ import { examples } from "../lib/help.js";
9
+ import { field, output, success, withSpinner } from "../lib/output.js";
10
+ const CONTENT_TYPES = {
11
+ ".rbxl": "application/octet-stream",
12
+ ".rbxlx": "application/xml",
13
+ };
14
+ export function publishCommand() {
15
+ return new Command("publish")
16
+ .description("publish a place file to an experience")
17
+ .requiredOption("--path <file>", "place file (.rbxl or .rbxlx)")
18
+ .addOption(universeOption())
19
+ .addOption(placeOption())
20
+ .option("--saved", "save the version without publishing it live")
21
+ .addHelpText("after", examples(["raven publish --path game.rbxlx --universe 123 --place 456"], ["raven publish --path game.rbxl -u 123 -p 456 --saved", "save without going live"]))
22
+ .action(async (opts) => {
23
+ const contentType = CONTENT_TYPES[extname(opts.path).toLowerCase()];
24
+ if (!contentType)
25
+ throw new CliError("Place files must be .rbxl or .rbxlx.");
26
+ let file;
27
+ try {
28
+ file = await readFile(opts.path);
29
+ }
30
+ catch (err) {
31
+ throw new CliError(`Could not read ${opts.path}: ${err.message}`);
32
+ }
33
+ const versionType = opts.saved ? "Saved" : "Published";
34
+ const result = await withSpinner(`${opts.saved ? "Saving" : "Publishing"} ${basename(opts.path)}...`, () => request("POST", `/universes/v1/${opts.universe}/places/${opts.place}/versions`, {
35
+ query: { versionType },
36
+ body: new Uint8Array(file),
37
+ headers: { "content-type": contentType },
38
+ }));
39
+ output(result, () => {
40
+ success(`${opts.saved ? "Saved" : "Published"} ${pc.cyan(basename(opts.path))} as version ${pc.bold(String(result.versionNumber))}`);
41
+ field("Universe", opts.universe);
42
+ field("Place", opts.place);
43
+ field("URL", pc.underline(`https://www.roblox.com/games/${opts.place}`));
44
+ });
45
+ });
46
+ }
@@ -0,0 +1,45 @@
1
+ import { Command } from "commander";
2
+ import { confirm } from "@inquirer/prompts";
3
+ import pc from "picocolors";
4
+ import { request } from "../lib/api.js";
5
+ import { universeOption } from "../lib/args.js";
6
+ import { CliError } from "../lib/config.js";
7
+ import { examples } from "../lib/help.js";
8
+ import { output, success, withSpinner } from "../lib/output.js";
9
+ export function serverCommand() {
10
+ const server = new Command("server")
11
+ .description("restart servers and message them")
12
+ .addHelpText("after", examples(["raven server restart --universe 123"], [`raven server message -u 123 --topic Announcements --message "Restarting soon"`]));
13
+ server
14
+ .command("restart")
15
+ .description("restart all servers running an outdated version of the experience")
16
+ .addOption(universeOption())
17
+ .option("-y, --yes", "skip the confirmation prompt")
18
+ .action(async (opts) => {
19
+ if (!opts.yes) {
20
+ if (!process.stdin.isTTY)
21
+ throw new CliError("Refusing to restart servers without --yes in a non-interactive shell.");
22
+ const ok = await confirm({
23
+ message: `Restart servers for universe ${opts.universe}? Players will be disconnected.`,
24
+ default: false,
25
+ });
26
+ if (!ok)
27
+ return console.log(pc.dim("Cancelled."));
28
+ }
29
+ await withSpinner("Restarting servers...", () => request("POST", `/cloud/v2/universes/${opts.universe}:restartServers`, { json: {} }));
30
+ output({ restarted: true, universe: opts.universe }, () => success(`Restarting servers for universe ${pc.cyan(opts.universe)}`));
31
+ });
32
+ server
33
+ .command("message")
34
+ .description("publish a message to live servers via MessagingService")
35
+ .addOption(universeOption())
36
+ .requiredOption("-t, --topic <topic>", "MessagingService topic")
37
+ .requiredOption("-m, --message <message>", "message to send")
38
+ .action(async (opts) => {
39
+ await withSpinner(`Publishing to ${opts.topic}...`, () => request("POST", `/cloud/v2/universes/${opts.universe}:publishMessage`, {
40
+ json: { topic: opts.topic, message: opts.message },
41
+ }));
42
+ output({ published: true, topic: opts.topic }, () => success(`Published message to ${pc.cyan(opts.topic)}`));
43
+ });
44
+ return server;
45
+ }
@@ -0,0 +1,93 @@
1
+ import { Command } from "commander";
2
+ import pc from "picocolors";
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { CliError } from "../lib/config.js";
8
+ import { examples } from "../lib/help.js";
9
+ import { info, success, withSpinner } from "../lib/output.js";
10
+ const REPO = "twistedsignal/raven";
11
+ const PACKAGE_JSON_URL = `https://raw.githubusercontent.com/${REPO}/main/package.json`;
12
+ const TARBALL_URL = `https://github.com/${REPO}/archive/refs/heads/main.tar.gz`;
13
+ async function latestVersion() {
14
+ let res;
15
+ try {
16
+ res = await fetch(PACKAGE_JSON_URL, { headers: { "cache-control": "no-cache" } });
17
+ }
18
+ catch (err) {
19
+ throw new CliError(`Couldn't reach GitHub: ${err.message}`);
20
+ }
21
+ if (!res.ok)
22
+ throw new CliError(`Couldn't check for updates (GitHub returned ${res.status}).`);
23
+ return (await res.json()).version;
24
+ }
25
+ /** Returns >0 if a is newer than b. */
26
+ function compareVersions(a, b) {
27
+ const pa = a.split(/[.-]/).map(Number);
28
+ const pb = b.split(/[.-]/).map(Number);
29
+ for (let i = 0; i < 3; i++) {
30
+ const diff = (pa[i] || 0) - (pb[i] || 0);
31
+ if (diff)
32
+ return diff;
33
+ }
34
+ return 0;
35
+ }
36
+ /** The directory Raven is installed in (the package root). */
37
+ function installRoot() {
38
+ return dirname(dirname(dirname(realpathSync(fileURLToPath(import.meta.url)))));
39
+ }
40
+ /** Picks the install command matching the package manager Raven was installed with. */
41
+ function installCommand(root) {
42
+ if (/[\\/]\.?pnpm[\\/]/.test(root))
43
+ return ["pnpm", ["add", "-g", TARBALL_URL]];
44
+ if (/[\\/]\.bun[\\/]/.test(root))
45
+ return ["bun", ["add", "-g", TARBALL_URL]];
46
+ return ["npm", ["install", "-g", TARBALL_URL]];
47
+ }
48
+ function run(cmd, args) {
49
+ return new Promise((resolve, reject) => {
50
+ const child = spawn(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
51
+ child.on("error", reject);
52
+ child.on("close", (code) => resolve(code ?? 1));
53
+ });
54
+ }
55
+ export function updateCommand(version) {
56
+ return new Command("update")
57
+ .description("update Raven to the latest version")
58
+ .option("--check", "only check whether an update is available")
59
+ .option("-f, --force", "reinstall even if already up to date")
60
+ .addHelpText("after", examples(["raven update"], ["raven update --check", "just check, don't install"]))
61
+ .action(async (opts) => {
62
+ const root = installRoot();
63
+ if (existsSync(join(root, ".git"))) {
64
+ throw new CliError(`Raven is running from a git checkout (${root}).\nUpdate it with: git pull && pnpm install && pnpm build`);
65
+ }
66
+ const latest = await withSpinner("Checking for updates...", latestVersion);
67
+ const newer = compareVersions(latest, version) > 0;
68
+ if (!newer && !opts.force) {
69
+ success(`Raven is up to date (v${version}).`);
70
+ return;
71
+ }
72
+ if (opts.check) {
73
+ info(`Update available: ${pc.dim(`v${version}`)} → ${pc.green(`v${latest}`)}. Run ${pc.cyan("raven update")} to install it.`);
74
+ return;
75
+ }
76
+ const [cmd, args] = installCommand(root);
77
+ info(newer ? `Updating Raven ${pc.dim(`v${version}`)} → ${pc.green(`v${latest}`)}...` : `Reinstalling Raven v${latest}...`);
78
+ info(pc.dim(`$ ${cmd} ${args.join(" ")}`));
79
+ info("");
80
+ let code;
81
+ try {
82
+ code = await run(cmd, args);
83
+ }
84
+ catch (err) {
85
+ throw new CliError(`Couldn't run ${cmd}: ${err.message}`);
86
+ }
87
+ if (code !== 0) {
88
+ throw new CliError(`Update failed (${cmd} exited with code ${code}). If it's a permissions error, try running it with sudo or fix your npm prefix.`);
89
+ }
90
+ info("");
91
+ success(`Raven updated to v${latest}.`);
92
+ });
93
+ }