@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Twisted Signal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Raven
|
|
2
|
+
|
|
3
|
+
A CLI for the [Roblox Open Cloud API](https://create.roblox.com/docs/cloud). Upload assets, publish places, edit data stores, and manage live servers from your terminal or CI.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Requires Node.js 20 or newer.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g https://github.com/twistedsignal/raven/archive/refs/heads/main.tar.gz
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> `npm install -g github:twistedsignal/raven` currently leaves a broken install on npm 11 (npm links the package to a temporary clone and then deletes it), so use the tarball URL above.
|
|
14
|
+
|
|
15
|
+
To update, run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
raven update # install the latest version
|
|
19
|
+
raven update --check # just check whether there's a new one
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or from source:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/twistedsignal/raven
|
|
26
|
+
cd raven
|
|
27
|
+
pnpm install && pnpm build
|
|
28
|
+
npm link
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The compiled `dist/` is committed so installing from GitHub doesn't need a build step. If you change anything in `src/`, run `pnpm build` and commit `dist/` with it; CI fails if it's out of date.
|
|
32
|
+
|
|
33
|
+
## Logging in
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
raven auth
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Raven asks which commands you want to use, tells you exactly which permissions to give your Open Cloud API key for those, then verifies and saves it. Commands you didn't pick are disabled (and marked as such in `raven -h`), so your key only ever needs the permissions you actually use.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
raven auth commands # change which commands are enabled
|
|
43
|
+
raven auth status # show the saved key and enabled commands
|
|
44
|
+
raven auth logout # remove the saved key
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Keys are stored at `~/.config/raven/credentials.json` (`%APPDATA%\raven` on Windows) with `0600` permissions. In CI, set `RAVEN_API_KEY` instead of running `raven auth`. You can also pass `--api-key <key>` to any command.
|
|
48
|
+
|
|
49
|
+
## Commands
|
|
50
|
+
|
|
51
|
+
Run `raven -h` or `raven <command> -h` for the full list of options.
|
|
52
|
+
|
|
53
|
+
### Assets
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Upload (type is inferred from the extension if --type is omitted)
|
|
57
|
+
raven asset upload --path sword.fbx --type Model --name "Sword" --description "A sword" --creator user:12345
|
|
58
|
+
raven asset upload --path logo.png --creator group:67890
|
|
59
|
+
|
|
60
|
+
# Upload a new version and/or change metadata
|
|
61
|
+
raven asset update --id 1234567890 --path sword_v2.fbx
|
|
62
|
+
raven asset update --id 1234567890 --name "Better Sword" --description "Sharper"
|
|
63
|
+
|
|
64
|
+
# Roll back to a previous version
|
|
65
|
+
raven asset rollback --id 1234567890 --to 3
|
|
66
|
+
|
|
67
|
+
# Inspect
|
|
68
|
+
raven asset get --id 1234567890
|
|
69
|
+
raven asset versions --id 1234567890
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`--creator` accepts `user:<id>`, `group:<id>`, or a bare user ID. Supported asset types are `Audio`, `Decal`, `Model`, `Video`, and `Animation`.
|
|
73
|
+
|
|
74
|
+
### Publishing places
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
raven publish --path game.rbxlx --universe 1234 --place 5678
|
|
78
|
+
raven publish --path game.rbxl --universe 1234 --place 5678 --saved # save without publishing
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Data stores
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
raven datastore list --universe 1234 # list data stores
|
|
85
|
+
raven datastore list --universe 1234 --datastore Players --prefix user_ # list keys
|
|
86
|
+
raven datastore get --universe 1234 --datastore Players --key user_1
|
|
87
|
+
raven datastore set --universe 1234 --datastore Players --key user_1 --value '{"coins": 100}'
|
|
88
|
+
raven datastore set --universe 1234 --datastore Players --key user_1 --file data.json --users 1
|
|
89
|
+
raven datastore increment --universe 1234 --datastore Stats --key visits --by 5
|
|
90
|
+
raven datastore delete --universe 1234 --datastore Players --key user_1
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`datastore delete` is irreversible, so it only runs interactively in a terminal and asks you to type the data store name to confirm. It can't be run from scripts or CI.
|
|
94
|
+
|
|
95
|
+
`datastore` can be shortened to `ds`. Use `--scope <name>` to target a scope other than `global`.
|
|
96
|
+
|
|
97
|
+
### Game passes and developer products
|
|
98
|
+
|
|
99
|
+
Every `product` command takes `--type gamepass` or `--type devProduct`.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
raven product add --type gamepass --universe 1234 --name "VIP" --description "VIP perks" --price 100 --icon vip.png
|
|
103
|
+
raven product add --type devProduct --universe 1234 --name "100 Coins" --price 25 --managed-pricing
|
|
104
|
+
raven product update --type devProduct --universe 1234 --id 5678 --price 30 --no-managed-pricing
|
|
105
|
+
raven product disable --type gamepass --universe 1234 --id 5678 # take off sale
|
|
106
|
+
raven product enable --type gamepass --universe 1234 --id 5678 # put back on sale
|
|
107
|
+
raven product list --type devProduct --universe 1234
|
|
108
|
+
raven product get --type gamepass --universe 1234 --id 5678
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- `--managed-pricing` / `--no-managed-pricing` turns Roblox's managed (regional) pricing on or off. Leave both out to keep the current setting.
|
|
112
|
+
- `add` puts the product on sale when you give it a `--price` (pass `--offsale` to skip that).
|
|
113
|
+
- Roblox doesn't allow deleting game passes or developer products, so `disable` takes them off sale instead. Players who already own a game pass keep it.
|
|
114
|
+
|
|
115
|
+
### Servers
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
raven server restart --universe 1234 # restarts outdated servers
|
|
119
|
+
raven server message --universe 1234 --topic Announcements --message "Restarting in 5 minutes"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`server message` publishes through [MessagingService](https://create.roblox.com/docs/reference/engine/classes/MessagingService), so your game needs to subscribe to the topic.
|
|
123
|
+
|
|
124
|
+
## Scripting
|
|
125
|
+
|
|
126
|
+
| Variable | Equivalent flag |
|
|
127
|
+
| --------------------- | --------------- |
|
|
128
|
+
| `RAVEN_API_KEY` | `--api-key` |
|
|
129
|
+
| `RAVEN_UNIVERSE_ID` | `--universe` |
|
|
130
|
+
| `RAVEN_PLACE_ID` | `--place` |
|
|
131
|
+
| `RAVEN_CREATOR` | `--creator` |
|
|
132
|
+
|
|
133
|
+
Pass `--json` to any command for machine-readable output.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
raven --json datastore get -u 1234 -d Players -k user_1 | jq .value
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## API key permissions
|
|
140
|
+
|
|
141
|
+
`raven auth` shows only the permissions for the commands you pick. For reference:
|
|
142
|
+
|
|
143
|
+
| Command | API system | Operations |
|
|
144
|
+
| -------------------------------------------- | ----------------------------- | ----------------- |
|
|
145
|
+
| `asset` | `assets` | Read, Write |
|
|
146
|
+
| `product --type gamepass` | `game-pass` | Read, Write |
|
|
147
|
+
| `product --type devProduct` | `developer-product` | Read, Write |
|
|
148
|
+
| `publish` | `universe-places` | Write |
|
|
149
|
+
| `datastore list`, `datastore get` | `universe-datastores.control` | List |
|
|
150
|
+
| | `universe-datastores.objects` | List, Read |
|
|
151
|
+
| `datastore set`, `datastore increment` | `universe-datastores.objects` | Create, Update |
|
|
152
|
+
| `datastore delete` | `universe-datastores.objects` | Delete |
|
|
153
|
+
| `server restart` | `universe` | Write |
|
|
154
|
+
| `server message` | `universe-messaging-service` | Publish |
|
|
155
|
+
|
|
156
|
+
When you use `RAVEN_API_KEY` or `--api-key`, every command is enabled.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
[MIT](LICENSE) © 2026 Twisted Signal
|
|
@@ -0,0 +1,207 @@
|
|
|
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, sleep } from "../lib/api.js";
|
|
6
|
+
import { parseCreator, parseId, parsePositiveInt } 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 ASSET_TYPES = ["Audio", "Decal", "Model", "Video", "Animation"];
|
|
11
|
+
const CONTENT_TYPES = {
|
|
12
|
+
".mp3": { contentType: "audio/mpeg", assetType: "Audio" },
|
|
13
|
+
".ogg": { contentType: "audio/ogg", assetType: "Audio" },
|
|
14
|
+
".wav": { contentType: "audio/wav", assetType: "Audio" },
|
|
15
|
+
".flac": { contentType: "audio/flac", assetType: "Audio" },
|
|
16
|
+
".png": { contentType: "image/png", assetType: "Decal" },
|
|
17
|
+
".jpg": { contentType: "image/jpeg", assetType: "Decal" },
|
|
18
|
+
".jpeg": { contentType: "image/jpeg", assetType: "Decal" },
|
|
19
|
+
".bmp": { contentType: "image/bmp", assetType: "Decal" },
|
|
20
|
+
".tga": { contentType: "image/tga", assetType: "Decal" },
|
|
21
|
+
".fbx": { contentType: "model/fbx", assetType: "Model" },
|
|
22
|
+
".gltf": { contentType: "model/gltf+json", assetType: "Model" },
|
|
23
|
+
".glb": { contentType: "model/gltf-binary", assetType: "Model" },
|
|
24
|
+
".rbxm": { contentType: "model/x-rbxm", assetType: "Model" },
|
|
25
|
+
".rbxmx": { contentType: "model/x-rbxm", assetType: "Model" },
|
|
26
|
+
".mp4": { contentType: "video/mp4", assetType: "Video" },
|
|
27
|
+
".mov": { contentType: "video/mov", assetType: "Video" },
|
|
28
|
+
};
|
|
29
|
+
function parseAssetType(value) {
|
|
30
|
+
const match = ASSET_TYPES.find((t) => t.toLowerCase() === value.toLowerCase());
|
|
31
|
+
if (!match)
|
|
32
|
+
throw new InvalidArgumentError(`Expected one of: ${ASSET_TYPES.join(", ")}.`);
|
|
33
|
+
return match;
|
|
34
|
+
}
|
|
35
|
+
function contentTypeFor(path) {
|
|
36
|
+
const entry = CONTENT_TYPES[extname(path).toLowerCase()];
|
|
37
|
+
if (!entry) {
|
|
38
|
+
throw new CliError(`Unsupported file extension "${extname(path)}". Supported: ${Object.keys(CONTENT_TYPES).join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
async function readAssetFile(path) {
|
|
43
|
+
const { contentType } = contentTypeFor(path);
|
|
44
|
+
try {
|
|
45
|
+
return new Blob([await readFile(path)], { type: contentType });
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new CliError(`Could not read ${path}: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Polls an asset operation until it finishes. */
|
|
52
|
+
async function waitForOperation(op, onTick) {
|
|
53
|
+
const id = op.path.split("/").pop();
|
|
54
|
+
let current = op;
|
|
55
|
+
for (let attempt = 0; !current.done; attempt++) {
|
|
56
|
+
if (attempt >= 30)
|
|
57
|
+
throw new CliError(`Timed out waiting for operation ${id}. Check it later on the Creator Dashboard.`);
|
|
58
|
+
onTick?.(attempt);
|
|
59
|
+
await sleep(Math.min(1000 * 1.5 ** attempt, 5000));
|
|
60
|
+
current = await request("GET", `/assets/v1/operations/${id}`);
|
|
61
|
+
}
|
|
62
|
+
if (current.error)
|
|
63
|
+
throw new CliError(`Operation failed: ${current.error.message ?? JSON.stringify(current.error)}`);
|
|
64
|
+
return current.response ?? {};
|
|
65
|
+
}
|
|
66
|
+
function printAsset(asset) {
|
|
67
|
+
field("Asset ID", asset.assetId);
|
|
68
|
+
field("Type", asset.assetType);
|
|
69
|
+
field("Name", asset.displayName);
|
|
70
|
+
field("Description", asset.description);
|
|
71
|
+
field("Revision", asset.revisionId);
|
|
72
|
+
field("Updated", asset.revisionCreateTime);
|
|
73
|
+
field("Moderation", asset.moderationResult?.moderationState);
|
|
74
|
+
if (asset.assetId)
|
|
75
|
+
field("URL", pc.underline(`https://create.roblox.com/store/asset/${asset.assetId}`));
|
|
76
|
+
}
|
|
77
|
+
export function assetCommand() {
|
|
78
|
+
const asset = new Command("asset").description("upload, update, and roll back assets").addHelpText("after", examples(["raven asset upload --path sword.fbx --creator user:123", "type is inferred from the extension"], ["raven asset upload --path logo.png --type Decal --name Logo --creator group:456"], ["raven asset update --id 987 --path sword_v2.fbx", "upload a new version"], ["raven asset rollback --id 987 --to 3"], ["raven asset versions --id 987"]));
|
|
79
|
+
asset
|
|
80
|
+
.command("upload")
|
|
81
|
+
.description("upload a new asset")
|
|
82
|
+
.requiredOption("--path <file>", "file to upload")
|
|
83
|
+
.addOption(new Option("--type <type>", "asset type (inferred from the file extension if omitted)")
|
|
84
|
+
.choices(ASSET_TYPES)
|
|
85
|
+
.argParser(parseAssetType))
|
|
86
|
+
.option("--name <name>", "display name (defaults to the file name)")
|
|
87
|
+
.option("--description <text>", "asset description", "")
|
|
88
|
+
.addOption(new Option("--creator <creator>", "owner as `user:<id>` or `group:<id>`")
|
|
89
|
+
.env("RAVEN_CREATOR")
|
|
90
|
+
.argParser(parseCreator)
|
|
91
|
+
.makeOptionMandatory())
|
|
92
|
+
.option("--no-wait", "don't wait for processing to finish")
|
|
93
|
+
.action(async (opts) => {
|
|
94
|
+
const { assetType: inferred } = contentTypeFor(opts.path);
|
|
95
|
+
const assetType = opts.type ?? inferred;
|
|
96
|
+
const displayName = opts.name ?? basename(opts.path, extname(opts.path));
|
|
97
|
+
const form = new FormData();
|
|
98
|
+
form.append("request", JSON.stringify({
|
|
99
|
+
assetType,
|
|
100
|
+
displayName,
|
|
101
|
+
description: opts.description,
|
|
102
|
+
creationContext: { creator: opts.creator },
|
|
103
|
+
}));
|
|
104
|
+
form.append("fileContent", await readAssetFile(opts.path), basename(opts.path));
|
|
105
|
+
const result = await withSpinner(`Uploading ${basename(opts.path)}...`, async (spinner) => {
|
|
106
|
+
const op = await request("POST", "/assets/v1/assets", { body: form });
|
|
107
|
+
if (!opts.wait)
|
|
108
|
+
return op;
|
|
109
|
+
spinner.text = "Processing asset...";
|
|
110
|
+
return waitForOperation(op);
|
|
111
|
+
});
|
|
112
|
+
output(result, () => {
|
|
113
|
+
if ("path" in result && result.path?.startsWith("operations/")) {
|
|
114
|
+
success(`Upload started (${result.path}).`);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
success(`Uploaded ${pc.cyan(displayName)}`);
|
|
118
|
+
printAsset(result);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
asset
|
|
123
|
+
.command("update")
|
|
124
|
+
.description("upload a new version of an asset and/or update its metadata")
|
|
125
|
+
.requiredOption("--id <assetId>", "asset ID", parseId)
|
|
126
|
+
.option("--path <file>", "new file contents")
|
|
127
|
+
.option("--name <name>", "new display name")
|
|
128
|
+
.option("--description <text>", "new description")
|
|
129
|
+
.option("--no-wait", "don't wait for processing to finish")
|
|
130
|
+
.action(async (opts) => {
|
|
131
|
+
if (!opts.path && opts.name === undefined && opts.description === undefined) {
|
|
132
|
+
throw new CliError("Nothing to update. Pass --path, --name, and/or --description.");
|
|
133
|
+
}
|
|
134
|
+
const body = { assetId: opts.id };
|
|
135
|
+
const mask = [];
|
|
136
|
+
if (opts.name !== undefined) {
|
|
137
|
+
body.displayName = opts.name;
|
|
138
|
+
mask.push("displayName");
|
|
139
|
+
}
|
|
140
|
+
if (opts.description !== undefined) {
|
|
141
|
+
body.description = opts.description;
|
|
142
|
+
mask.push("description");
|
|
143
|
+
}
|
|
144
|
+
const form = new FormData();
|
|
145
|
+
form.append("request", JSON.stringify(body));
|
|
146
|
+
if (opts.path)
|
|
147
|
+
form.append("fileContent", await readAssetFile(opts.path), basename(opts.path));
|
|
148
|
+
const result = await withSpinner(`Updating asset ${opts.id}...`, async (spinner) => {
|
|
149
|
+
const op = await request("PATCH", `/assets/v1/assets/${opts.id}`, {
|
|
150
|
+
body: form,
|
|
151
|
+
query: { updateMask: mask.length ? mask.join(",") : undefined },
|
|
152
|
+
});
|
|
153
|
+
if (!opts.wait)
|
|
154
|
+
return op;
|
|
155
|
+
spinner.text = "Processing asset...";
|
|
156
|
+
return waitForOperation(op);
|
|
157
|
+
});
|
|
158
|
+
output(result, () => {
|
|
159
|
+
success(`Updated asset ${pc.cyan(opts.id)}`);
|
|
160
|
+
if (opts.wait)
|
|
161
|
+
printAsset(result);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
asset
|
|
165
|
+
.command("rollback")
|
|
166
|
+
.description("roll an asset back to a previous version")
|
|
167
|
+
.requiredOption("--id <assetId>", "asset ID", parseId)
|
|
168
|
+
.requiredOption("--to <version>", "version number to roll back to", parsePositiveInt)
|
|
169
|
+
.action(async (opts) => {
|
|
170
|
+
const form = new FormData();
|
|
171
|
+
form.append("assetVersion", `assets/${opts.id}/versions/${opts.to}`);
|
|
172
|
+
const result = await withSpinner(`Rolling back asset ${opts.id}...`, () => request("POST", `/assets/v1/assets/${opts.id}/versions:rollback`, { body: form }));
|
|
173
|
+
output(result, () => {
|
|
174
|
+
success(`Rolled back asset ${pc.cyan(opts.id)} to version ${opts.to}`);
|
|
175
|
+
field("New version", result?.path?.split("/").pop());
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
asset
|
|
179
|
+
.command("get")
|
|
180
|
+
.description("show information about an asset")
|
|
181
|
+
.requiredOption("--id <assetId>", "asset ID", parseId)
|
|
182
|
+
.action(async (opts) => {
|
|
183
|
+
const result = await withSpinner(`Fetching asset ${opts.id}...`, () => request("GET", `/assets/v1/assets/${opts.id}`, {
|
|
184
|
+
query: { readMask: "assetType,creationContext,description,displayName,path,revisionId,revisionCreateTime,state,moderationResult" },
|
|
185
|
+
}));
|
|
186
|
+
output(result, () => printAsset(result));
|
|
187
|
+
});
|
|
188
|
+
asset
|
|
189
|
+
.command("versions")
|
|
190
|
+
.description("list versions of an asset")
|
|
191
|
+
.requiredOption("--id <assetId>", "asset ID", parseId)
|
|
192
|
+
.option("--limit <n>", "maximum number of versions to show", parsePositiveInt, 10)
|
|
193
|
+
.action(async (opts) => {
|
|
194
|
+
const result = await withSpinner(`Fetching versions of ${opts.id}...`, () => request("GET", `/assets/v1/assets/${opts.id}/versions`, { query: { maxPageSize: Math.min(opts.limit, 50) } }));
|
|
195
|
+
const versions = (result.assetVersions ?? []).slice(0, opts.limit);
|
|
196
|
+
output(versions, () => {
|
|
197
|
+
if (!versions.length)
|
|
198
|
+
return console.log("No versions found.");
|
|
199
|
+
for (const v of versions) {
|
|
200
|
+
const num = v.path.split("/").pop();
|
|
201
|
+
const state = v.moderationResult?.moderationState;
|
|
202
|
+
console.log(` ${pc.bold(`v${num}`)}${v.published ? pc.green(" published") : ""}${state ? pc.dim(` ${state}`) : ""}`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
return asset;
|
|
207
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { checkbox, password } from "@inquirer/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { ApiError, request } from "../lib/api.js";
|
|
6
|
+
import { openBrowser } from "../lib/browser.js";
|
|
7
|
+
import { clearCredentials, CliError, credentialsPath, loadCredentials, saveCredentials, } from "../lib/config.js";
|
|
8
|
+
import { examples } from "../lib/help.js";
|
|
9
|
+
import { field, output, success, withSpinner } from "../lib/output.js";
|
|
10
|
+
import { FEATURE_IDS, FEATURES, featureCommands, printEnabledCommands, printPermissionTable, } from "../lib/permissions.js";
|
|
11
|
+
const CREDENTIALS_URL = "https://create.roblox.com/dashboard/credentials?activeTab=ApiKeysTab";
|
|
12
|
+
export async function introspect(apiKey) {
|
|
13
|
+
return request("POST", "/api-keys/v1/introspect", { json: { apiKey }, noAuth: true });
|
|
14
|
+
}
|
|
15
|
+
async function pressEnter(message) {
|
|
16
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
17
|
+
try {
|
|
18
|
+
await rl.question(message);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
rl.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function requireTty() {
|
|
25
|
+
if (!process.stdin.isTTY) {
|
|
26
|
+
throw new CliError("This command is interactive. In non-interactive environments, set RAVEN_API_KEY instead.");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The checklist of commands to enable. `previous` pre-checks an earlier selection. */
|
|
30
|
+
async function pickFeatures(previous) {
|
|
31
|
+
return checkbox({
|
|
32
|
+
message: "Which commands do you want to use?",
|
|
33
|
+
choices: FEATURES.map((f) => ({
|
|
34
|
+
value: f.id,
|
|
35
|
+
name: f.name,
|
|
36
|
+
description: `raven ${featureCommands(f).replaceAll(", ", ", raven ")}`,
|
|
37
|
+
checked: previous?.includes(f.id) ?? false,
|
|
38
|
+
})),
|
|
39
|
+
required: true,
|
|
40
|
+
loop: false,
|
|
41
|
+
pageSize: FEATURES.length,
|
|
42
|
+
theme: { style: { renderSelectedChoices: (selected) => `${selected.length} selected` } },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function printKeyInstructions(features, heading) {
|
|
46
|
+
console.log();
|
|
47
|
+
console.log(heading);
|
|
48
|
+
console.log();
|
|
49
|
+
printPermissionTable(FEATURES.filter((f) => features.includes(f.id)));
|
|
50
|
+
console.log();
|
|
51
|
+
console.log(pc.dim(" For each API system, select the experiences you want Raven to manage."));
|
|
52
|
+
}
|
|
53
|
+
async function verify(apiKey) {
|
|
54
|
+
const info = await withSpinner("Verifying token...", async () => {
|
|
55
|
+
try {
|
|
56
|
+
return await introspect(apiKey);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err instanceof ApiError && (err.status === 400 || err.status === 401 || err.status === 403)) {
|
|
60
|
+
throw new CliError("That token is invalid. Double check you copied the whole key and try again.");
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
if (info.enabled === false)
|
|
66
|
+
throw new CliError("That API key is disabled. Enable it on the Creator Dashboard.");
|
|
67
|
+
if (info.expired)
|
|
68
|
+
throw new CliError("That API key has expired. Create a new one and try again.");
|
|
69
|
+
return info;
|
|
70
|
+
}
|
|
71
|
+
function printMissingHint(ok) {
|
|
72
|
+
if (ok)
|
|
73
|
+
return;
|
|
74
|
+
console.log();
|
|
75
|
+
console.log(pc.yellow("Some enabled commands need permissions this key doesn't have."));
|
|
76
|
+
console.log(pc.yellow(`Edit the key at ${pc.underline(CREDENTIALS_URL)} and add them.`));
|
|
77
|
+
}
|
|
78
|
+
async function login() {
|
|
79
|
+
requireTty();
|
|
80
|
+
const previous = await loadCredentials();
|
|
81
|
+
console.log();
|
|
82
|
+
console.log(pc.bold("Welcome to Raven!") + " Let's get you logged in with a Roblox Open Cloud API key.");
|
|
83
|
+
console.log(pc.dim("Pick only what you need. Your key will only need permissions for those commands."));
|
|
84
|
+
console.log();
|
|
85
|
+
const features = await pickFeatures(previous?.features);
|
|
86
|
+
console.log();
|
|
87
|
+
await pressEnter(`${pc.cyan("?")} Press ${pc.bold("Enter")} to open ${pc.underline(CREDENTIALS_URL)} `);
|
|
88
|
+
const opened = await openBrowser(CREDENTIALS_URL);
|
|
89
|
+
if (!opened)
|
|
90
|
+
console.log(pc.dim(` Couldn't open a browser. Visit the link above manually.`));
|
|
91
|
+
printKeyInstructions(features, `Click ${pc.bold("Create API Key")}, give it a name, then click ${pc.bold("Add API System")} and add:`);
|
|
92
|
+
console.log(pc.dim(" Under Security, add your IP address (or 0.0.0.0/0 to allow any IP)."));
|
|
93
|
+
console.log(pc.dim(` Click ${pc.bold("Save & Generate Key")}, then copy the key.`));
|
|
94
|
+
console.log();
|
|
95
|
+
await pressEnter(`${pc.cyan("?")} Press ${pc.bold("Enter")} once you've created and copied the key `);
|
|
96
|
+
console.log();
|
|
97
|
+
const apiKey = (await password({
|
|
98
|
+
message: "Enter token:",
|
|
99
|
+
mask: "*",
|
|
100
|
+
validate: (v) => (v.trim().length > 0 ? true : "Token cannot be empty."),
|
|
101
|
+
})).trim();
|
|
102
|
+
console.log();
|
|
103
|
+
const info = await verify(apiKey);
|
|
104
|
+
await saveCredentials({
|
|
105
|
+
apiKey,
|
|
106
|
+
name: info.name,
|
|
107
|
+
ownerId: info.authorizedUserId !== undefined ? String(info.authorizedUserId) : undefined,
|
|
108
|
+
features,
|
|
109
|
+
savedAt: new Date().toISOString(),
|
|
110
|
+
});
|
|
111
|
+
success(pc.bold("Success!"));
|
|
112
|
+
console.log(`You are now logged into Raven${info.name ? ` with key ${pc.cyan(info.name)}` : ""}.`);
|
|
113
|
+
console.log();
|
|
114
|
+
printMissingHint(printEnabledCommands(features, info.scopes));
|
|
115
|
+
console.log();
|
|
116
|
+
console.log(pc.dim(`Change which commands are enabled any time with ${pc.cyan("raven auth commands")}.`));
|
|
117
|
+
}
|
|
118
|
+
async function changeCommands() {
|
|
119
|
+
requireTty();
|
|
120
|
+
const creds = await loadCredentials();
|
|
121
|
+
if (!creds)
|
|
122
|
+
throw new CliError("You are not logged in. Run `raven auth` to log in.");
|
|
123
|
+
const before = creds.features ?? FEATURE_IDS;
|
|
124
|
+
const features = await pickFeatures(before);
|
|
125
|
+
const added = features.filter((f) => !before.includes(f));
|
|
126
|
+
await saveCredentials({ ...creds, features });
|
|
127
|
+
console.log();
|
|
128
|
+
success("Saved.");
|
|
129
|
+
if (added.length) {
|
|
130
|
+
printKeyInstructions(added, `Make sure your API key has these permissions for the newly enabled commands (${pc.underline(CREDENTIALS_URL)}):`);
|
|
131
|
+
}
|
|
132
|
+
console.log();
|
|
133
|
+
const info = await withSpinner("Checking token...", () => introspect(creds.apiKey)).catch(() => undefined);
|
|
134
|
+
printMissingHint(printEnabledCommands(features, info?.scopes));
|
|
135
|
+
}
|
|
136
|
+
export function authCommand() {
|
|
137
|
+
const auth = new Command("auth")
|
|
138
|
+
.description("log in with a Roblox Open Cloud API key")
|
|
139
|
+
.addHelpText("after", `
|
|
140
|
+
Running \`raven auth\` asks which commands you want, tells you exactly which
|
|
141
|
+
permissions to give your API key, then verifies and saves it.
|
|
142
|
+
${examples(["raven auth", "log in interactively"], ["raven auth commands", "change which commands are enabled"], ["raven auth status", "show the saved key and enabled commands"], ["raven auth logout", "remove the saved key"])}
|
|
143
|
+
|
|
144
|
+
In CI, set RAVEN_API_KEY instead of running \`raven auth\`. All commands are
|
|
145
|
+
enabled when using RAVEN_API_KEY or --api-key.`)
|
|
146
|
+
.action(login);
|
|
147
|
+
auth.command("commands").description("choose which commands are enabled").action(changeCommands);
|
|
148
|
+
auth
|
|
149
|
+
.command("status")
|
|
150
|
+
.description("show the saved API key and enabled commands")
|
|
151
|
+
.action(async () => {
|
|
152
|
+
const creds = await loadCredentials();
|
|
153
|
+
if (!creds)
|
|
154
|
+
throw new CliError("You are not logged in. Run `raven auth` to log in.");
|
|
155
|
+
const info = await withSpinner("Checking token...", () => introspect(creds.apiKey));
|
|
156
|
+
const features = creds.features ?? FEATURE_IDS;
|
|
157
|
+
output({ ...info, enabledCommands: features, savedAt: creds.savedAt, path: credentialsPath() }, () => {
|
|
158
|
+
success(`Logged in${info.name ? ` as ${pc.cyan(info.name)}` : ""}`);
|
|
159
|
+
field("Owner", info.authorizedUserId);
|
|
160
|
+
field("Enabled", info.enabled);
|
|
161
|
+
field("Expires", info.expirationTime ?? "never");
|
|
162
|
+
field("Saved", creds.savedAt);
|
|
163
|
+
field("Stored at", credentialsPath());
|
|
164
|
+
console.log();
|
|
165
|
+
printMissingHint(printEnabledCommands(features, info.scopes));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
auth
|
|
169
|
+
.command("logout")
|
|
170
|
+
.description("remove the saved API key")
|
|
171
|
+
.action(async () => {
|
|
172
|
+
const removed = await clearCredentials();
|
|
173
|
+
success(removed ? "Logged out." : "You weren't logged in.");
|
|
174
|
+
});
|
|
175
|
+
return auth;
|
|
176
|
+
}
|