@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.
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/dist/commands/asset.js +207 -0
- package/dist/commands/auth.js +176 -0
- package/dist/commands/datastore.js +208 -0
- package/dist/commands/product.js +242 -0
- package/dist/commands/publish.js +46 -0
- package/dist/commands/server.js +45 -0
- package/dist/commands/update.js +93 -0
- package/dist/index.js +107 -0
- package/dist/lib/api.js +83 -0
- package/dist/lib/args.js +35 -0
- package/dist/lib/browser.js +22 -0
- package/dist/lib/config.js +65 -0
- package/dist/lib/help.js +6 -0
- package/dist/lib/output.js +55 -0
- package/dist/lib/permissions.js +157 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, CommanderError } from "commander";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { assetCommand } from "./commands/asset.js";
|
|
6
|
+
import { authCommand } from "./commands/auth.js";
|
|
7
|
+
import { datastoreCommand } from "./commands/datastore.js";
|
|
8
|
+
import { productCommand } from "./commands/product.js";
|
|
9
|
+
import { publishCommand } from "./commands/publish.js";
|
|
10
|
+
import { serverCommand } from "./commands/server.js";
|
|
11
|
+
import { updateCommand } from "./commands/update.js";
|
|
12
|
+
import { CliError, enabledFeatures, setApiKeyOverride } from "./lib/config.js";
|
|
13
|
+
import { examples } from "./lib/help.js";
|
|
14
|
+
import { setJsonMode } from "./lib/output.js";
|
|
15
|
+
import { featuresFor } from "./lib/permissions.js";
|
|
16
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
17
|
+
const program = new Command("raven")
|
|
18
|
+
.description("A CLI for the Roblox Open Cloud API.")
|
|
19
|
+
.version(version, "-v, --version")
|
|
20
|
+
.option("--api-key <key>", "use this API key instead of the saved one")
|
|
21
|
+
.option("--json", "output raw JSON")
|
|
22
|
+
.showHelpAfterError()
|
|
23
|
+
.addHelpText("before", `${pc.bold("Raven")} ${pc.dim(`v${version}`)}\n`)
|
|
24
|
+
.addHelpText("after", `
|
|
25
|
+
Getting started:
|
|
26
|
+
Run ${pc.cyan("raven auth")} to log in, then ${pc.cyan("raven <command> -h")} for help with a command.
|
|
27
|
+
${examples(["raven asset upload --path sword.fbx --creator user:123"], ["raven product add -t gamepass -u 123 --name VIP --price 100"], ["raven publish --path game.rbxlx --universe 123 --place 456"], ["raven ds get -u 123 -d Players -k user_1"], ["raven server restart --universe 123"])}
|
|
28
|
+
|
|
29
|
+
Environment:
|
|
30
|
+
RAVEN_API_KEY API key to use instead of the saved one
|
|
31
|
+
RAVEN_UNIVERSE_ID default for --universe
|
|
32
|
+
RAVEN_PLACE_ID default for --place
|
|
33
|
+
RAVEN_CREATOR default for --creator
|
|
34
|
+
|
|
35
|
+
Docs: ${pc.underline("https://github.com/twistedsignal/raven")}`)
|
|
36
|
+
.hook("preAction", async (cmd, actionCommand) => {
|
|
37
|
+
const opts = cmd.optsWithGlobals();
|
|
38
|
+
setApiKeyOverride(opts.apiKey);
|
|
39
|
+
setJsonMode(Boolean(opts.json));
|
|
40
|
+
await assertEnabled(actionCommand);
|
|
41
|
+
});
|
|
42
|
+
program.addCommand(authCommand());
|
|
43
|
+
program.addCommand(assetCommand());
|
|
44
|
+
program.addCommand(productCommand());
|
|
45
|
+
program.addCommand(publishCommand());
|
|
46
|
+
program.addCommand(datastoreCommand());
|
|
47
|
+
program.addCommand(serverCommand());
|
|
48
|
+
program.addCommand(updateCommand(version));
|
|
49
|
+
/** "datastore get" for `raven datastore get`. */
|
|
50
|
+
function commandPath(cmd) {
|
|
51
|
+
const names = [];
|
|
52
|
+
for (let c = cmd; c && c !== program; c = c.parent)
|
|
53
|
+
names.unshift(c.name());
|
|
54
|
+
return names.join(" ");
|
|
55
|
+
}
|
|
56
|
+
/** Blocks commands that weren't enabled during `raven auth`. */
|
|
57
|
+
async function assertEnabled(cmd) {
|
|
58
|
+
const path = commandPath(cmd);
|
|
59
|
+
if (path === "auth" || path.startsWith("auth "))
|
|
60
|
+
return;
|
|
61
|
+
const enabled = await enabledFeatures();
|
|
62
|
+
if (!enabled)
|
|
63
|
+
return;
|
|
64
|
+
const type = cmd.opts().type;
|
|
65
|
+
const features = featuresFor(path, path.startsWith("product") ? type : undefined);
|
|
66
|
+
if (features.length && !features.some((f) => enabled.includes(f.id))) {
|
|
67
|
+
const name = `raven ${path}${path.startsWith("product") && type ? ` --type ${type}` : ""}`;
|
|
68
|
+
throw new CliError(`\`${name}\` is disabled. Run \`raven auth commands\` to enable it.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Marks commands that aren't enabled for the saved key in the help output. */
|
|
72
|
+
function annotateDisabled(cmd, enabled) {
|
|
73
|
+
for (const sub of cmd.commands) {
|
|
74
|
+
const path = commandPath(sub);
|
|
75
|
+
if (path === "auth" || path === "help")
|
|
76
|
+
continue;
|
|
77
|
+
const features = featuresFor(path);
|
|
78
|
+
const on = features.filter((f) => enabled.includes(f.id));
|
|
79
|
+
if (features.length && !on.length) {
|
|
80
|
+
sub.description(`${sub.description()} ${pc.dim("(disabled)")}`);
|
|
81
|
+
}
|
|
82
|
+
else if (on.length < features.length && on.every((f) => f.productType)) {
|
|
83
|
+
sub.description(`${sub.description()} ${pc.dim(`(${on.map((f) => f.productType).join(", ")} only)`)}`);
|
|
84
|
+
}
|
|
85
|
+
annotateDisabled(sub, enabled);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const usingOverride = process.env.RAVEN_API_KEY || process.argv.some((a) => a === "--api-key" || a.startsWith("--api-key="));
|
|
89
|
+
const enabled = usingOverride ? undefined : await enabledFeatures();
|
|
90
|
+
if (enabled)
|
|
91
|
+
annotateDisabled(program, enabled);
|
|
92
|
+
try {
|
|
93
|
+
await program.parseAsync();
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err instanceof CommanderError)
|
|
97
|
+
process.exit(err.exitCode);
|
|
98
|
+
if (err instanceof Error && err.name === "ExitPromptError") {
|
|
99
|
+
console.error(pc.dim("\nCancelled."));
|
|
100
|
+
process.exit(130);
|
|
101
|
+
}
|
|
102
|
+
if (err instanceof CliError) {
|
|
103
|
+
console.error(`${pc.red("✖")} ${err.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { CliError, resolveApiKey } from "./config.js";
|
|
2
|
+
export const BASE_URL = process.env.RAVEN_API_BASE_URL || "https://apis.roblox.com";
|
|
3
|
+
export class ApiError extends CliError {
|
|
4
|
+
status;
|
|
5
|
+
body;
|
|
6
|
+
constructor(status, message, body) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const MAX_RETRIES = 3;
|
|
13
|
+
export async function request(method, path, opts = {}) {
|
|
14
|
+
const url = new URL(path, BASE_URL);
|
|
15
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
16
|
+
if (v !== undefined)
|
|
17
|
+
url.searchParams.set(k, String(v));
|
|
18
|
+
}
|
|
19
|
+
const headers = { ...opts.headers };
|
|
20
|
+
if (!opts.noAuth)
|
|
21
|
+
headers["x-api-key"] = opts.apiKey ?? (await resolveApiKey());
|
|
22
|
+
let body = opts.body;
|
|
23
|
+
if (opts.json !== undefined) {
|
|
24
|
+
headers["content-type"] = "application/json";
|
|
25
|
+
body = JSON.stringify(opts.json);
|
|
26
|
+
}
|
|
27
|
+
for (let attempt = 0;; attempt++) {
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await fetch(url, { method, headers, body });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
throw new CliError(`Network error contacting ${url.host}: ${err.message}`);
|
|
34
|
+
}
|
|
35
|
+
if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
|
|
36
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
37
|
+
const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt;
|
|
38
|
+
await sleep(delay);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let data = text;
|
|
43
|
+
if (text && res.headers.get("content-type")?.includes("json")) {
|
|
44
|
+
try {
|
|
45
|
+
data = JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// leave as text
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
throw new ApiError(res.status, formatError(res.status, data), data);
|
|
53
|
+
return (text ? data : undefined);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function formatError(status, data) {
|
|
57
|
+
let detail = "";
|
|
58
|
+
if (data && typeof data === "object") {
|
|
59
|
+
const d = data;
|
|
60
|
+
detail =
|
|
61
|
+
d.message ??
|
|
62
|
+
d.errorMessage ??
|
|
63
|
+
d.errors?.[0]?.message ??
|
|
64
|
+
d.error?.message ??
|
|
65
|
+
(typeof d.error === "string" ? d.error : "") ??
|
|
66
|
+
"";
|
|
67
|
+
const code = typeof d.code === "string" ? d.code : d.errorCode;
|
|
68
|
+
if (code && typeof code === "string" && detail)
|
|
69
|
+
detail = `${code}: ${detail}`;
|
|
70
|
+
}
|
|
71
|
+
else if (typeof data === "string") {
|
|
72
|
+
detail = data.trim().slice(0, 300);
|
|
73
|
+
}
|
|
74
|
+
const hint = status === 401
|
|
75
|
+
? " (is your API key valid? run `raven auth`)"
|
|
76
|
+
: status === 403
|
|
77
|
+
? " (does your API key have the required permission and access to this resource?)"
|
|
78
|
+
: "";
|
|
79
|
+
return `Request failed with ${status}${detail ? `: ${detail}` : ""}${hint}`;
|
|
80
|
+
}
|
|
81
|
+
export function sleep(ms) {
|
|
82
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
83
|
+
}
|
package/dist/lib/args.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { InvalidArgumentError, Option } from "commander";
|
|
2
|
+
export function parseId(value) {
|
|
3
|
+
if (!/^\d+$/.test(value))
|
|
4
|
+
throw new InvalidArgumentError("Expected a numeric ID.");
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
export function parsePositiveInt(value) {
|
|
8
|
+
const n = Number(value);
|
|
9
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
10
|
+
throw new InvalidArgumentError("Expected a positive integer.");
|
|
11
|
+
return n;
|
|
12
|
+
}
|
|
13
|
+
export function parseNumber(value) {
|
|
14
|
+
const n = Number(value);
|
|
15
|
+
if (!Number.isFinite(n))
|
|
16
|
+
throw new InvalidArgumentError("Expected a number.");
|
|
17
|
+
return n;
|
|
18
|
+
}
|
|
19
|
+
/** Accepts `user:123`, `group:456`, or a bare ID (treated as a user). */
|
|
20
|
+
export function parseCreator(value) {
|
|
21
|
+
const match = /^(?:(user|group):)?(\d+)$/i.exec(value.trim());
|
|
22
|
+
if (!match)
|
|
23
|
+
throw new InvalidArgumentError("Expected `user:<id>`, `group:<id>`, or a user ID.");
|
|
24
|
+
const [, kind, id] = match;
|
|
25
|
+
return kind?.toLowerCase() === "group" ? { groupId: id } : { userId: id };
|
|
26
|
+
}
|
|
27
|
+
export function universeOption() {
|
|
28
|
+
return new Option("-u, --universe <id>", "universe (experience) ID")
|
|
29
|
+
.env("RAVEN_UNIVERSE_ID")
|
|
30
|
+
.argParser(parseId)
|
|
31
|
+
.makeOptionMandatory();
|
|
32
|
+
}
|
|
33
|
+
export function placeOption() {
|
|
34
|
+
return new Option("-p, --place <id>", "place ID").env("RAVEN_PLACE_ID").argParser(parseId).makeOptionMandatory();
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Best-effort attempt to open a URL in the user's default browser. */
|
|
3
|
+
export function openBrowser(url) {
|
|
4
|
+
const [cmd, args] = process.platform === "darwin"
|
|
5
|
+
? ["open", [url]]
|
|
6
|
+
: process.platform === "win32"
|
|
7
|
+
? ["cmd", ["/c", "start", '""', url]]
|
|
8
|
+
: ["xdg-open", [url]];
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
try {
|
|
11
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
12
|
+
child.on("error", () => resolve(false));
|
|
13
|
+
child.on("spawn", () => {
|
|
14
|
+
child.unref();
|
|
15
|
+
resolve(true);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
resolve(false);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { FEATURE_IDS } from "./permissions.js";
|
|
5
|
+
export function configDir() {
|
|
6
|
+
if (process.env.RAVEN_CONFIG_DIR)
|
|
7
|
+
return process.env.RAVEN_CONFIG_DIR;
|
|
8
|
+
if (process.platform === "win32" && process.env.APPDATA) {
|
|
9
|
+
return join(process.env.APPDATA, "raven");
|
|
10
|
+
}
|
|
11
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
12
|
+
return join(base, "raven");
|
|
13
|
+
}
|
|
14
|
+
export function credentialsPath() {
|
|
15
|
+
return join(configDir(), "credentials.json");
|
|
16
|
+
}
|
|
17
|
+
export async function loadCredentials() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = await readFile(credentialsPath(), "utf8");
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
return parsed.apiKey ? parsed : undefined;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function saveCredentials(creds) {
|
|
28
|
+
await mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
29
|
+
const path = credentialsPath();
|
|
30
|
+
await writeFile(path, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
31
|
+
// writeFile only applies mode on creation, so enforce it on overwrite too.
|
|
32
|
+
await chmod(path, 0o600).catch(() => { });
|
|
33
|
+
}
|
|
34
|
+
export async function clearCredentials() {
|
|
35
|
+
const existing = await loadCredentials();
|
|
36
|
+
await rm(credentialsPath(), { force: true });
|
|
37
|
+
return existing !== undefined;
|
|
38
|
+
}
|
|
39
|
+
let overrideKey;
|
|
40
|
+
export function setApiKeyOverride(key) {
|
|
41
|
+
overrideKey = key;
|
|
42
|
+
}
|
|
43
|
+
/** Resolves the API key from --api-key, RAVEN_API_KEY, or the saved credentials. */
|
|
44
|
+
export async function resolveApiKey() {
|
|
45
|
+
if (overrideKey)
|
|
46
|
+
return overrideKey;
|
|
47
|
+
if (process.env.RAVEN_API_KEY)
|
|
48
|
+
return process.env.RAVEN_API_KEY;
|
|
49
|
+
const creds = await loadCredentials();
|
|
50
|
+
if (creds)
|
|
51
|
+
return creds.apiKey;
|
|
52
|
+
throw new CliError("You are not logged in. Run `raven auth` to log in, or set RAVEN_API_KEY.");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The features enabled for the saved key, or undefined if every command is allowed
|
|
56
|
+
* (a key passed via --api-key / RAVEN_API_KEY, or a login from before features existed).
|
|
57
|
+
*/
|
|
58
|
+
export async function enabledFeatures() {
|
|
59
|
+
if (overrideKey || process.env.RAVEN_API_KEY)
|
|
60
|
+
return undefined;
|
|
61
|
+
const features = (await loadCredentials())?.features;
|
|
62
|
+
return features?.filter((f) => FEATURE_IDS.includes(f));
|
|
63
|
+
}
|
|
64
|
+
export class CliError extends Error {
|
|
65
|
+
}
|
package/dist/lib/help.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
/** Formats an "Examples:" block for addHelpText. Comments are printed above their command. */
|
|
3
|
+
export function examples(...lines) {
|
|
4
|
+
const body = lines.map(([c, comment]) => `${comment ? ` ${pc.dim(`# ${comment}`)}\n` : ""} $ ${c}`);
|
|
5
|
+
return `\nExamples:\n${body.join("\n")}`;
|
|
6
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
let jsonMode = false;
|
|
3
|
+
export function setJsonMode(value) {
|
|
4
|
+
jsonMode = value;
|
|
5
|
+
}
|
|
6
|
+
export function isJsonMode() {
|
|
7
|
+
return jsonMode;
|
|
8
|
+
}
|
|
9
|
+
/** Prints raw JSON in --json mode, otherwise runs the human-readable printer. */
|
|
10
|
+
export function output(data, human) {
|
|
11
|
+
if (jsonMode) {
|
|
12
|
+
console.log(JSON.stringify(data, null, 2));
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
human();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function success(msg) {
|
|
19
|
+
if (!jsonMode)
|
|
20
|
+
console.log(`${pc.green("✔")} ${msg}`);
|
|
21
|
+
}
|
|
22
|
+
export function info(msg) {
|
|
23
|
+
if (!jsonMode)
|
|
24
|
+
console.log(msg);
|
|
25
|
+
}
|
|
26
|
+
export function warn(msg) {
|
|
27
|
+
console.error(`${pc.yellow("!")} ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
export function field(label, value) {
|
|
30
|
+
if (value === undefined || value === null || value === "")
|
|
31
|
+
return;
|
|
32
|
+
console.log(` ${pc.dim(label.padEnd(14))} ${value}`);
|
|
33
|
+
}
|
|
34
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
35
|
+
/** Shows a spinner on stderr while `fn` runs. Silent in --json mode or when stderr isn't a TTY. */
|
|
36
|
+
export async function withSpinner(text, fn) {
|
|
37
|
+
const spinner = { text };
|
|
38
|
+
const stream = process.stderr;
|
|
39
|
+
if (jsonMode || !stream.isTTY)
|
|
40
|
+
return fn(spinner);
|
|
41
|
+
let frame = 0;
|
|
42
|
+
const render = () => {
|
|
43
|
+
stream.write(`\r\x1b[2K${pc.cyan(FRAMES[frame++ % FRAMES.length])} ${spinner.text}`);
|
|
44
|
+
};
|
|
45
|
+
stream.write("\x1b[?25l");
|
|
46
|
+
render();
|
|
47
|
+
const timer = setInterval(render, 80);
|
|
48
|
+
try {
|
|
49
|
+
return await fn(spinner);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearInterval(timer);
|
|
53
|
+
stream.write("\r\x1b[2K\x1b[?25h");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
/** The features you can pick during `raven auth`, and the Open Cloud permissions each one needs. */
|
|
3
|
+
export const FEATURES = [
|
|
4
|
+
{
|
|
5
|
+
id: "asset",
|
|
6
|
+
name: "Assets: upload, update, and roll back",
|
|
7
|
+
commands: ["asset"],
|
|
8
|
+
scopes: [["assets", ["Read", "Write"]]],
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "gamepass",
|
|
12
|
+
name: "Game passes",
|
|
13
|
+
commands: ["product"],
|
|
14
|
+
productType: "gamepass",
|
|
15
|
+
scopes: [["game-pass", ["Read", "Write"]]],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "devProduct",
|
|
19
|
+
name: "Developer products",
|
|
20
|
+
commands: ["product"],
|
|
21
|
+
productType: "devProduct",
|
|
22
|
+
scopes: [["developer-product", ["Read", "Write"]]],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "publish",
|
|
26
|
+
name: "Publish places",
|
|
27
|
+
commands: ["publish"],
|
|
28
|
+
scopes: [["universe-places", ["Write"]]],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "datastore-read",
|
|
32
|
+
name: "Data stores: list and read",
|
|
33
|
+
commands: ["datastore list", "datastore get"],
|
|
34
|
+
scopes: [
|
|
35
|
+
["universe-datastores.control", ["List"]],
|
|
36
|
+
["universe-datastores.objects", ["List", "Read"]],
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "datastore-write",
|
|
41
|
+
name: "Data stores: set and increment",
|
|
42
|
+
commands: ["datastore set", "datastore increment"],
|
|
43
|
+
scopes: [["universe-datastores.objects", ["Create", "Update"]]],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "datastore-delete",
|
|
47
|
+
name: "Data stores: delete",
|
|
48
|
+
commands: ["datastore delete"],
|
|
49
|
+
scopes: [["universe-datastores.objects", ["Delete"]]],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "server-restart",
|
|
53
|
+
name: "Restart servers",
|
|
54
|
+
commands: ["server restart"],
|
|
55
|
+
scopes: [["universe", ["Write"]]],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "server-message",
|
|
59
|
+
name: "Message servers (MessagingService)",
|
|
60
|
+
commands: ["server message"],
|
|
61
|
+
scopes: [["universe-messaging-service", ["Publish"]]],
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
export const FEATURE_IDS = FEATURES.map((f) => f.id);
|
|
65
|
+
/** Human-readable command names for a feature, e.g. "datastore list, datastore get". */
|
|
66
|
+
export function featureCommands(f) {
|
|
67
|
+
return f.productType ? `product --type ${f.productType}` : f.commands.join(", ");
|
|
68
|
+
}
|
|
69
|
+
function covers(featurePath, path) {
|
|
70
|
+
return path === featurePath || path.startsWith(`${featurePath} `);
|
|
71
|
+
}
|
|
72
|
+
/** Features that unlock the given command path. `productType` narrows `product` commands. */
|
|
73
|
+
export function featuresFor(path, productType) {
|
|
74
|
+
return FEATURES.filter((f) => f.commands.some((c) => covers(c, path) || covers(path, c)) &&
|
|
75
|
+
(!productType || !f.productType || f.productType === productType));
|
|
76
|
+
}
|
|
77
|
+
/** Merges the scopes of several features into one list per API system. */
|
|
78
|
+
function mergeScopes(features) {
|
|
79
|
+
const ORDER = ["List", "Read", "Create", "Update", "Delete", "Write", "Publish"];
|
|
80
|
+
const merged = new Map();
|
|
81
|
+
for (const f of features) {
|
|
82
|
+
for (const [api, ops] of f.scopes) {
|
|
83
|
+
if (!merged.has(api))
|
|
84
|
+
merged.set(api, new Set());
|
|
85
|
+
for (const op of ops)
|
|
86
|
+
merged.get(api).add(op);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...merged].map(([api, ops]) => [api, [...ops].sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b))]);
|
|
90
|
+
}
|
|
91
|
+
/** Prints the permissions to add to the API key for the selected features. */
|
|
92
|
+
export function printPermissionTable(features) {
|
|
93
|
+
const rows = mergeScopes(features);
|
|
94
|
+
const width = Math.max(...rows.map(([api]) => api.length)) + 4;
|
|
95
|
+
console.log(` ${pc.dim("API system".padEnd(width))}${pc.dim("Operations")}`);
|
|
96
|
+
for (const [api, ops] of rows)
|
|
97
|
+
console.log(` ${pc.bold(api.padEnd(width))}${ops.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Builds a map of API system -> granted operations. Tolerates both
|
|
101
|
+
* `{ name: "assets", operations: ["read"] }` and `"assets:read"` style values.
|
|
102
|
+
*/
|
|
103
|
+
function grantedMap(granted) {
|
|
104
|
+
const map = new Map();
|
|
105
|
+
const add = (api, op) => {
|
|
106
|
+
const key = api.toLowerCase();
|
|
107
|
+
if (!map.has(key))
|
|
108
|
+
map.set(key, new Set());
|
|
109
|
+
if (op)
|
|
110
|
+
map.get(key).add(op.toLowerCase());
|
|
111
|
+
};
|
|
112
|
+
for (const scope of granted) {
|
|
113
|
+
const [api, op] = scope.name.split(":");
|
|
114
|
+
add(api, op);
|
|
115
|
+
for (const operation of scope.operations ?? [])
|
|
116
|
+
add(api, operation.split(":").pop());
|
|
117
|
+
}
|
|
118
|
+
return map;
|
|
119
|
+
}
|
|
120
|
+
/** Scope names that differ from the API system name shown on the Creator Dashboard. */
|
|
121
|
+
const SCOPE_NAMES = { assets: "asset" };
|
|
122
|
+
/** Returns the permissions a feature needs that the key doesn't have. */
|
|
123
|
+
function missingFor(feature, granted) {
|
|
124
|
+
const missing = [];
|
|
125
|
+
for (const [api, ops] of feature.scopes) {
|
|
126
|
+
const have = granted.get(SCOPE_NAMES[api] ?? api) ?? granted.get(api);
|
|
127
|
+
const lacking = have ? ops.filter((o) => !have.has(o.toLowerCase())) : ops;
|
|
128
|
+
if (lacking.length)
|
|
129
|
+
missing.push(`${api}: ${lacking.join(", ")}`);
|
|
130
|
+
}
|
|
131
|
+
return missing;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Prints the enabled commands, flagging any whose permissions the key appears to be missing.
|
|
135
|
+
* Returns true if every enabled feature looks fully permitted (or scopes are unknown).
|
|
136
|
+
*/
|
|
137
|
+
export function printEnabledCommands(enabled, granted) {
|
|
138
|
+
const map = granted?.length ? grantedMap(granted) : undefined;
|
|
139
|
+
let ok = true;
|
|
140
|
+
console.log(pc.dim("Enabled commands:"));
|
|
141
|
+
for (const f of FEATURES) {
|
|
142
|
+
const label = featureCommands(f);
|
|
143
|
+
if (!enabled.includes(f.id)) {
|
|
144
|
+
console.log(` ${pc.dim(`- ${label} (disabled)`)}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const missing = map ? missingFor(f, map) : [];
|
|
148
|
+
if (missing.length) {
|
|
149
|
+
ok = false;
|
|
150
|
+
console.log(` ${pc.yellow("!")} ${label} ${pc.yellow(`key is missing ${missing.join("; ")}`)}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
console.log(` ${pc.green("✔")} ${label}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return ok;
|
|
157
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@twistedsignal/raven",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "A CLI for the Roblox Open Cloud API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Twisted Signal",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"raven": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "rm -rf dist && tsc && chmod +x dist/index.js",
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"start": "node dist/index.js",
|
|
22
|
+
"prepublishOnly": "pnpm build"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"roblox",
|
|
26
|
+
"opencloud",
|
|
27
|
+
"open-cloud",
|
|
28
|
+
"cli",
|
|
29
|
+
"datastore",
|
|
30
|
+
"assets"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/twistedsignal/raven.git"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@inquirer/prompts": "^8.7.2",
|
|
38
|
+
"commander": "^15.0.0",
|
|
39
|
+
"picocolors": "^1.1.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^26.6.3",
|
|
43
|
+
"typescript": "^7.0.2"
|
|
44
|
+
},
|
|
45
|
+
"packageManager": "pnpm@11.28.2"
|
|
46
|
+
}
|