@twistedsignal/raven 0.2.1 → 0.3.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/README.md CHANGED
@@ -7,16 +7,14 @@ A CLI for the [Roblox Open Cloud API](https://create.roblox.com/docs/cloud). Upl
7
7
  Requires Node.js 20 or newer.
8
8
 
9
9
  ```bash
10
- npm install -g https://github.com/twistedsignal/raven/archive/refs/heads/main.tar.gz
10
+ npm install -g @twistedsignal/raven
11
11
  ```
12
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:
13
+ To update to the latest published version, run:
16
14
 
17
15
  ```bash
18
- raven update # install the latest version
19
- raven update --check # just check whether there's a new one
16
+ raven update
17
+ raven update --check # check without installing
20
18
  ```
21
19
 
22
20
  Or from source:
@@ -28,7 +26,7 @@ pnpm install && pnpm build
28
26
  npm link
29
27
  ```
30
28
 
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.
29
+ If you change anything in `src/`, run `pnpm build` and commit `dist/` with it; CI fails if it's out of date.
32
30
 
33
31
  ## Logging in
34
32
 
@@ -2,6 +2,7 @@ import { Command, InvalidArgumentError, Option } from "commander";
2
2
  import pc from "picocolors";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { basename, extname } from "node:path";
5
+ import { downloadAsset } from "../lib/download.js";
5
6
  import { request, sleep } from "../lib/api.js";
6
7
  import { parseCreator, parseId, parsePositiveInt } from "../lib/args.js";
7
8
  import { CliError } from "../lib/config.js";
@@ -75,7 +76,16 @@ function printAsset(asset) {
75
76
  field("URL", pc.underline(`https://create.roblox.com/store/asset/${asset.assetId}`));
76
77
  }
77
78
  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
+ const asset = new Command("asset").description("download, 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"]));
80
+ asset
81
+ .command("download")
82
+ .description("download asset content using Legacy Assets Manage permission")
83
+ .requiredOption("--id <id>", "asset ID", parseId)
84
+ .requiredOption("--output <file>", "destination file")
85
+ .action(async (opts) => {
86
+ const result = await withSpinner("Downloading asset…", () => downloadAsset(opts.id, opts.output));
87
+ output(result, () => success(`Downloaded ${result.assetId} to ${result.path} (${result.bytes} bytes)`));
88
+ });
79
89
  asset
80
90
  .command("upload")
81
91
  .description("upload a new asset")
@@ -1,4 +1,4 @@
1
- import { Command } from "commander";
1
+ import { Command, Option } from "commander";
2
2
  import { checkbox, password } from "@inquirer/prompts";
3
3
  import pc from "picocolors";
4
4
  import { createInterface } from "node:readline/promises";
@@ -145,6 +145,23 @@ In CI, set RAVEN_API_KEY instead of running \`raven auth\`. All commands are
145
145
  enabled when using RAVEN_API_KEY or --api-key.`)
146
146
  .action(login);
147
147
  auth.command("commands").description("choose which commands are enabled").action(changeCommands);
148
+ auth
149
+ .command("enable")
150
+ .description("enable one command feature without opening a prompt")
151
+ .addOption(new Option("--feature <feature>", "feature to enable").choices(FEATURE_IDS).makeOptionMandatory())
152
+ .action(async (opts) => {
153
+ if (process.env.RAVEN_API_KEY) {
154
+ output({ enabled: opts.feature }, () => success("Environment keys already enable every command."));
155
+ return;
156
+ }
157
+ const creds = await loadCredentials();
158
+ if (!creds)
159
+ throw new CliError("No saved key. Run `raven auth` or set RAVEN_API_KEY.");
160
+ const features = creds.features ?? FEATURE_IDS;
161
+ if (!features.includes(opts.feature))
162
+ await saveCredentials({ ...creds, features: [...features, opts.feature] });
163
+ output({ enabled: opts.feature }, () => success(`Enabled ${opts.feature}. The key still needs its API permissions.`));
164
+ });
148
165
  auth
149
166
  .command("status")
150
167
  .description("show the saved API key and enabled commands")
@@ -7,19 +7,18 @@ import { fileURLToPath } from "node:url";
7
7
  import { CliError } from "../lib/config.js";
8
8
  import { examples } from "../lib/help.js";
9
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`;
10
+ const PACKAGE = "@twistedsignal/raven";
11
+ const LATEST_URL = "https://registry.npmjs.org/@twistedsignal%2Fraven/latest";
13
12
  async function latestVersion() {
14
13
  let res;
15
14
  try {
16
- res = await fetch(PACKAGE_JSON_URL, { headers: { "cache-control": "no-cache" } });
15
+ res = await fetch(LATEST_URL, { headers: { "cache-control": "no-cache" } });
17
16
  }
18
17
  catch (err) {
19
- throw new CliError(`Couldn't reach GitHub: ${err.message}`);
18
+ throw new CliError(`Couldn't reach npm: ${err.message}`);
20
19
  }
21
20
  if (!res.ok)
22
- throw new CliError(`Couldn't check for updates (GitHub returned ${res.status}).`);
21
+ throw new CliError(`Couldn't check for updates (npm returned ${res.status}).`);
23
22
  return (await res.json()).version;
24
23
  }
25
24
  /** Returns >0 if a is newer than b. */
@@ -39,11 +38,12 @@ function installRoot() {
39
38
  }
40
39
  /** Picks the install command matching the package manager Raven was installed with. */
41
40
  function installCommand(root) {
41
+ const latest = `${PACKAGE}@latest`;
42
42
  if (/[\\/]\.?pnpm[\\/]/.test(root))
43
- return ["pnpm", ["add", "-g", TARBALL_URL]];
43
+ return ["pnpm", ["add", "-g", latest]];
44
44
  if (/[\\/]\.bun[\\/]/.test(root))
45
- return ["bun", ["add", "-g", TARBALL_URL]];
46
- return ["npm", ["install", "-g", TARBALL_URL]];
45
+ return ["bun", ["add", "-g", latest]];
46
+ return ["npm", ["install", "-g", latest]];
47
47
  }
48
48
  function run(cmd, args) {
49
49
  return new Promise((resolve, reject) => {
package/dist/lib/api.js CHANGED
@@ -27,7 +27,7 @@ export async function request(method, path, opts = {}) {
27
27
  for (let attempt = 0;; attempt++) {
28
28
  let res;
29
29
  try {
30
- res = await fetch(url, { method, headers, body });
30
+ res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(opts.timeout ?? 30_000), redirect: opts.noRedirect ? "error" : "follow" });
31
31
  }
32
32
  catch (err) {
33
33
  throw new CliError(`Network error contacting ${url.host}: ${err.message}`);
@@ -35,7 +35,8 @@ export async function request(method, path, opts = {}) {
35
35
  if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
36
36
  const retryAfter = Number(res.headers.get("retry-after"));
37
37
  const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt;
38
- await sleep(delay);
38
+ await res.body?.cancel();
39
+ await sleep(Math.min(delay, 10_000));
39
40
  continue;
40
41
  }
41
42
  const text = await res.text();
@@ -0,0 +1,86 @@
1
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { CliError } from "./config.js";
5
+ import { ApiError, request, sleep } from "./api.js";
6
+ const MAX_BYTES = 128 * 1024 * 1024;
7
+ const TIMEOUT = 30_000;
8
+ /** Download CDN bytes separately: the API key must never accompany this request. */
9
+ export async function fetchBytes(location) {
10
+ const url = new URL(location);
11
+ if (url.protocol !== "https:" && !(process.env.RAVEN_API_BASE_URL && url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname))) {
12
+ throw new CliError("Asset delivery returned an insecure download URL.");
13
+ }
14
+ for (let attempt = 0;; attempt++) {
15
+ let response;
16
+ try {
17
+ response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
18
+ }
19
+ catch {
20
+ throw new CliError(`Could not download asset from ${url.host}; the request failed or timed out.`);
21
+ }
22
+ if ((response.status === 429 || response.status >= 500) && attempt < 3) {
23
+ await response.body?.cancel();
24
+ const retryAfter = Number(response.headers.get("retry-after"));
25
+ await sleep(Math.min(10_000, retryAfter > 0 ? retryAfter * 1000 : 1000 * 2 ** attempt));
26
+ continue;
27
+ }
28
+ if (!response.ok)
29
+ throw new CliError(`Asset download returned HTTP ${response.status}.`);
30
+ const reader = response.body?.getReader();
31
+ if (!reader)
32
+ throw new CliError("Asset download was empty.");
33
+ const chunks = [];
34
+ let length = 0;
35
+ try {
36
+ while (true) {
37
+ const { done, value } = await reader.read();
38
+ if (done)
39
+ break;
40
+ length += value.byteLength;
41
+ if (length > MAX_BYTES)
42
+ throw new CliError("Asset exceeds the 128 MB download limit.");
43
+ chunks.push(value);
44
+ }
45
+ }
46
+ catch (error) {
47
+ await reader.cancel().catch(() => { });
48
+ if (error instanceof CliError)
49
+ throw error;
50
+ throw new CliError("Asset download was interrupted or timed out.");
51
+ }
52
+ if (!length)
53
+ throw new CliError("Asset download was empty.");
54
+ return Buffer.concat(chunks, length);
55
+ }
56
+ }
57
+ export async function downloadAsset(assetId, outputPath) {
58
+ let delivery;
59
+ try {
60
+ delivery = await request("GET", `/asset-delivery-api/v1/assetId/${assetId}`, { timeout: TIMEOUT, noRedirect: true });
61
+ }
62
+ catch (error) {
63
+ if (error instanceof ApiError && error.status === 403) {
64
+ throw new CliError("Asset delivery denied access. Add Legacy Assets > Manage (legacy-asset:manage) to your key, and check access to this asset.");
65
+ }
66
+ throw error;
67
+ }
68
+ const location = delivery?.location ?? delivery?.locations?.[0]?.location;
69
+ if (typeof location !== "string")
70
+ throw new CliError("Asset delivery did not return a download location.");
71
+ const bytes = await fetchBytes(location);
72
+ const path = resolve(outputPath);
73
+ await mkdir(dirname(path), { recursive: true });
74
+ const temporary = `${path}.${randomUUID()}.tmp`;
75
+ try {
76
+ await writeFile(temporary, bytes, { flag: "wx" });
77
+ await rename(temporary, path);
78
+ }
79
+ catch {
80
+ throw new CliError(`Could not write asset to ${path}.`);
81
+ }
82
+ finally {
83
+ await rm(temporary, { force: true });
84
+ }
85
+ return { assetId, path, bytes: bytes.byteLength };
86
+ }
@@ -4,9 +4,15 @@ export const FEATURES = [
4
4
  {
5
5
  id: "asset",
6
6
  name: "Assets: upload, update, and roll back",
7
- commands: ["asset"],
7
+ commands: ["asset upload", "asset update", "asset rollback", "asset get", "asset versions"],
8
8
  scopes: [["assets", ["Read", "Write"]]],
9
9
  },
10
+ {
11
+ id: "asset-download",
12
+ name: "Assets: download content",
13
+ commands: ["asset download"],
14
+ scopes: [["legacy-asset", ["Manage"]]],
15
+ },
10
16
  {
11
17
  id: "gamepass",
12
18
  name: "Game passes",
@@ -76,7 +82,7 @@ export function featuresFor(path, productType) {
76
82
  }
77
83
  /** Merges the scopes of several features into one list per API system. */
78
84
  function mergeScopes(features) {
79
- const ORDER = ["List", "Read", "Create", "Update", "Delete", "Write", "Publish"];
85
+ const ORDER = ["List", "Read", "Create", "Update", "Delete", "Write", "Publish", "Manage"];
80
86
  const merged = new Map();
81
87
  for (const f of features) {
82
88
  for (const [api, ops] of f.scopes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twistedsignal/raven",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A CLI for the Roblox Open Cloud API",
5
5
  "license": "MIT",
6
6
  "author": "Twisted Signal",