diff --git a/docs/pages/tools/dev-server.mdx b/docs/pages/tools/dev-server.mdx
index 1bbac5082ea..8c26cfbbe55 100644
--- a/docs/pages/tools/dev-server.mdx
+++ b/docs/pages/tools/dev-server.mdx
@@ -244,6 +244,29 @@ Test Files 1 passed (1)
Liveblocks dev server shut down
```
+You can also pass extra arguments after the command. They will be appended to
+the end of the command string:
+
+```bash
+npx liveblocks dev --cmd 'vitest run' my-test.test.ts
+# Runs: vitest run my-test.test.ts
+```
+
+If you need precise control over where the extra arguments are inserted, use
+`{}` as a placeholder:
+
+```bash
+npx liveblocks dev --cmd 'vitest run {} --reporter=verbose' my-test.test.ts
+# Runs: vitest run my-test.test.ts --reporter=verbose
+```
+
+Use `--` before any extra arguments that start with `-`:
+
+```bash
+npx liveblocks dev --cmd 'vitest run' -- --coverage my-test.test.ts
+# Runs: vitest run --coverage my-test.test.ts
+```
+
We recommend:
1. `npx liveblocks dev` for manual testing and development, which retains data
diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json
index 4a684e49ccd..a04ee119451 100644
--- a/packages/liveblocks-server/package.json
+++ b/packages/liveblocks-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/server",
- "version": "1.2.0",
+ "version": "1.3.0",
"description": "Liveblocks backend server foundation.",
"type": "module",
"main": "./dist/index.js",
diff --git a/tools/liveblocks-cli/CHANGELOG.md b/tools/liveblocks-cli/CHANGELOG.md
index f6a921725fb..25ee9a06a3f 100644
--- a/tools/liveblocks-cli/CHANGELOG.md
+++ b/tools/liveblocks-cli/CHANGELOG.md
@@ -1,5 +1,13 @@
## vNEXT (not yet released)
+## v1.3.0
+
+- Add feeds support (`feeds:write` permission)
+- Add verbose logging toggle
+- Fix permission validation to accept all valid permission combinations
+- Support passing extra arguments to `--cmd` (`-c`), appended to the command or
+ replacing `{}` if present
+
## v1.2.0
- Add live socket inspector view
diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json
index d7239a7f97e..b967fc6b014 100644
--- a/tools/liveblocks-cli/package.json
+++ b/tools/liveblocks-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "liveblocks",
- "version": "1.2.0",
+ "version": "1.3.0",
"description": "Liveblocks command line interface",
"type": "module",
"bin": {
diff --git a/tools/liveblocks-cli/src/dev-server/auth.ts b/tools/liveblocks-cli/src/dev-server/auth.ts
index ced3af51095..9bb3c90af6c 100644
--- a/tools/liveblocks-cli/src/dev-server/auth.ts
+++ b/tools/liveblocks-cli/src/dev-server/auth.ts
@@ -15,13 +15,14 @@
* along with this program. If not, see .
*/
-import { nanoid, Permission } from "@liveblocks/core";
+import { nanoid } from "@liveblocks/core";
import type { CreateTicketOptions } from "@liveblocks/server";
import { ProtocolVersion } from "@liveblocks/server";
import * as Rooms from "./db/rooms";
import type { LiteAccessToken, LiteIdToken, LiteToken } from "./lib/jwt-lite";
import { verifyJwtLite } from "./lib/jwt-lite";
+import { Permission } from "./lib/permissions";
function resolvePermissions_acc(
token: LiteAccessToken,
@@ -150,7 +151,7 @@ export function authorizeWebSocket(
// Auto-create the room if it doesn't exist yet
Rooms.getOrCreateRoom(roomId, {
- defaultAccesses: [Permission.Write],
+ defaultAccesses: [Permission.RoomWrite],
});
// Public key auth always grants write access (matches production behavior)
@@ -160,7 +161,7 @@ export function authorizeWebSocket(
ticketData: {
version,
anonymousId: nanoid(),
- scopes: [Permission.Write],
+ scopes: [Permission.RoomWrite],
},
};
}
diff --git a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts
index 4dfd137c5c9..87097a073af 100644
--- a/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts
+++ b/tools/liveblocks-cli/src/dev-server/db/BunSQLiteDriver.ts
@@ -934,9 +934,10 @@ export class BunSQLiteDriver implements IStorageDriver {
get_feed(feedId: string): Feed | undefined {
const row = this.db
- .query(
- "SELECT feed_id, jmetadata, created_at, updated_at FROM feeds WHERE feed_id = ?"
- )
+ .query<
+ FeedRow,
+ [string]
+ >("SELECT feed_id, jmetadata, created_at, updated_at FROM feeds WHERE feed_id = ?")
.get(feedId);
if (row === undefined || row === null) return undefined;
return {
@@ -949,9 +950,10 @@ export class BunSQLiteDriver implements IStorageDriver {
create_feed(feed: Feed): void {
const existing = this.db
- .query, [string]>(
- "SELECT feed_id FROM feeds WHERE feed_id = ?"
- )
+ .query<
+ Pick,
+ [string]
+ >("SELECT feed_id FROM feeds WHERE feed_id = ?")
.get(feed.feedId);
if (existing !== undefined && existing !== null) {
throw new Error(`Feed ${feed.feedId} already exists`);
@@ -970,9 +972,10 @@ export class BunSQLiteDriver implements IStorageDriver {
update_feed_metadata(feedId: string, metadata: Feed["metadata"]): void {
const result = this.db
- .query(
- "UPDATE feeds SET jmetadata = ? WHERE feed_id = ? RETURNING feed_id, jmetadata, created_at, updated_at"
- )
+ .query<
+ FeedRow,
+ [string, string]
+ >("UPDATE feeds SET jmetadata = ? WHERE feed_id = ? RETURNING feed_id, jmetadata, created_at, updated_at")
.get(JSON.stringify(metadata), feedId);
if (result === undefined || result === null) {
throw new Error(`Feed ${feedId} not found`);
@@ -980,9 +983,7 @@ export class BunSQLiteDriver implements IStorageDriver {
}
delete_feed(feedId: string): void {
- this.db
- .query("DELETE FROM feeds WHERE feed_id = ?")
- .run(feedId);
+ this.db.query("DELETE FROM feeds WHERE feed_id = ?").run(feedId);
}
list_feed_messages(
@@ -1074,9 +1075,10 @@ export class BunSQLiteDriver implements IStorageDriver {
timestamp?: number
): FeedMessage {
const existing = this.db
- .query(
- "SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?"
- )
+ .query<
+ FeedMessageRow,
+ [string, string]
+ >("SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?")
.get(feedId, messageId);
if (existing === undefined || existing === null) {
throw new Error(`Feed message ${messageId} not found in feed ${feedId}`);
@@ -1096,9 +1098,7 @@ export class BunSQLiteDriver implements IStorageDriver {
.query<
FeedMessageRow,
[string, number, string, string, number]
- >(
- "UPDATE feed_messages SET jdata = ?, updated_at = ? WHERE feed_id = ? AND message_id = ? AND updated_at <= ? RETURNING feed_id, message_id, jdata, created_at, updated_at"
- )
+ >("UPDATE feed_messages SET jdata = ?, updated_at = ? WHERE feed_id = ? AND message_id = ? AND updated_at <= ? RETURNING feed_id, message_id, jdata, created_at, updated_at")
.get(
JSON.stringify(data),
effectiveTimestamp,
@@ -1108,12 +1108,15 @@ export class BunSQLiteDriver implements IStorageDriver {
);
if (result === undefined || result === null) {
const latest = this.db
- .query(
- "SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?"
- )
+ .query<
+ FeedMessageRow,
+ [string, string]
+ >("SELECT feed_id, message_id, jdata, created_at, updated_at FROM feed_messages WHERE feed_id = ? AND message_id = ?")
.get(feedId, messageId);
if (latest === undefined || latest === null) {
- throw new Error(`Feed message ${messageId} not found in feed ${feedId}`);
+ throw new Error(
+ `Feed message ${messageId} not found in feed ${feedId}`
+ );
}
return {
id: latest.message_id,
@@ -1132,9 +1135,7 @@ export class BunSQLiteDriver implements IStorageDriver {
delete_feed_message(feedId: string, messageId: string): void {
this.db
- .query(
- "DELETE FROM feed_messages WHERE feed_id = ? AND message_id = ?"
- )
+ .query("DELETE FROM feed_messages WHERE feed_id = ? AND message_id = ?")
.run(feedId, messageId);
}
diff --git a/tools/liveblocks-cli/src/dev-server/db/rooms.ts b/tools/liveblocks-cli/src/dev-server/db/rooms.ts
index 5e376763018..1fc48451b4c 100644
--- a/tools/liveblocks-cli/src/dev-server/db/rooms.ts
+++ b/tools/liveblocks-cli/src/dev-server/db/rooms.ts
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-import type { JsonObject, Permission } from "@liveblocks/core";
+import type { JsonObject } from "@liveblocks/core";
import { nanoid, WebsocketCloseCodes } from "@liveblocks/core";
import type { Millis } from "@liveblocks/server";
import { DefaultMap, Room } from "@liveblocks/server";
@@ -24,6 +24,8 @@ import { mkdirSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { dirname, join, resolve } from "path";
+import type { Permission } from "~/dev-server/lib/permissions";
+
import { BunSQLiteDriver } from "./BunSQLiteDriver";
// ---------------------------------------------------------------------------
@@ -229,7 +231,7 @@ function createDbRoom(
id: roomId,
internalId,
organizationId,
- defaultAccesses: defaultAccesses as Permission[],
+ defaultAccesses,
usersAccesses: opts?.usersAccesses ?? {},
groupsAccesses: opts?.groupsAccesses ?? {},
metadata,
diff --git a/tools/liveblocks-cli/src/dev-server/index.ts b/tools/liveblocks-cli/src/dev-server/index.ts
index 7a316745930..014594927b2 100644
--- a/tools/liveblocks-cli/src/dev-server/index.ts
+++ b/tools/liveblocks-cli/src/dev-server/index.ts
@@ -17,6 +17,7 @@
import {
Promise_withResolvers,
+ tryParseJson,
WebsocketCloseCodes as CloseCode,
} from "@liveblocks/core";
import type { Millis, Room, SessionKey, Ticket } from "@liveblocks/server";
@@ -111,6 +112,14 @@ function shellCmd(cmd: string): string[] {
: ["sh", "-c", cmd];
}
+/**
+ * Escapes a string for safe inclusion in a shell command.
+ * Wraps in single quotes, escaping any existing single quotes.
+ */
+function shellEscape(arg: string): string {
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
+}
+
type Options = {
port: string;
host?: string;
@@ -125,15 +134,33 @@ const dev: SubCommand = {
description: "Start the local Liveblocks dev server",
async run(argv) {
- const { options } = parseArgs(argv, {
- port: { type: "string", short: "p", default: DEFAULT_PORT.toString() },
- host: { type: "string" },
- cmd: { type: "string", short: "c" },
- help: { type: "boolean", short: "h", default: false },
- "no-check": { type: "boolean", default: false },
- ci: { type: "boolean", default: false },
- verbose: { type: "boolean", short: "v", default: false },
- });
+ const { options, args } = parseArgs(
+ argv,
+ {
+ port: { type: "string", short: "p", default: DEFAULT_PORT.toString() },
+ host: { type: "string" },
+ cmd: { type: "string", short: "c" },
+ help: { type: "boolean", short: "h", default: false },
+ "no-check": { type: "boolean", default: false },
+ ci: { type: "boolean", default: false },
+ verbose: { type: "boolean", short: "v", default: false },
+ },
+ { allowPositionals: true }
+ );
+
+ if (args.length > 0 && !options.cmd) {
+ console.error(red("Extra arguments are only supported with --cmd (-c)"));
+ process.exit(1);
+ }
+
+ if (args.length > 0 && options.cmd) {
+ const escaped = args.map(shellEscape).join(" ");
+ if (options.cmd.includes("{}")) {
+ options.cmd = options.cmd.replaceAll("{}", escaped);
+ } else {
+ options.cmd += " " + escaped;
+ }
+ }
if (options.help) {
console.log("Usage: liveblocks dev [options]");
@@ -145,6 +172,8 @@ const dev: SubCommand = {
console.log(" --host Host to bind to (default: localhost)");
console.log(" --cmd, -c Run a one-off command against a fresh server instance, then"); // prettier-ignore
console.log(" shut down. Does not affect your local data in .liveblocks/."); // prettier-ignore
+ console.log(" Extra args are appended to the command, or replace {} if"); // prettier-ignore
+ console.log(" present. Use -- before args starting with -."); // prettier-ignore
console.log(" --ci Start a fresh server instance on every boot, ideal for CI"); // prettier-ignore
console.log(" --no-check Skip project setup check on start");
console.log(" --verbose, -v Show verbose output");
@@ -186,6 +215,7 @@ const dev: SubCommand = {
}
let server: Bun.Server;
+ let verbose = false;
function createServer() {
return Bun.serve({
@@ -253,6 +283,17 @@ const dev: SubCommand = {
// Defer all other routing to ZenRouter
// TODO: Maybe port this logging to ZenRouter natively
const route = `${req.method} ${url.pathname}`;
+
+ // In verbose mode, clone the request so we can read its body
+ let reqBody: string | undefined;
+ if (verbose) {
+ try {
+ reqBody = await req.clone().text();
+ } catch {
+ // Ignore - body may not be readable
+ }
+ }
+
const resp = await zen.fetch(req);
const status = resp.status;
const colorStatus =
@@ -264,6 +305,21 @@ const dev: SubCommand = {
console.log(`${colorStatus} ${route}`);
const warnMsg = resp.headers.get("X-LB-Warn") ?? undefined;
warn(warnMsg, !resp.ok);
+
+ if (verbose) {
+ if (reqBody) {
+ const parsed = tryParseJson(reqBody);
+ if (parsed !== undefined) {
+ console.log(dim(` → ${JSON.stringify(parsed)}`));
+ }
+ }
+ const respBody = await resp.clone().text();
+ const parsedResp = tryParseJson(respBody);
+ if (parsedResp !== undefined) {
+ console.log(dim(` ← ${JSON.stringify(parsedResp)}`));
+ }
+ }
+
return resp;
},
@@ -435,7 +491,9 @@ const dev: SubCommand = {
bold("!") +
dim(" crash, ") +
bold("c") +
- dim(" clear");
+ dim(" clear, ") +
+ bold("v") +
+ dim(verbose ? " verbose (on)" : " verbose");
const switchToLogs = (): void => {
if (repaintTimer) {
@@ -684,6 +742,15 @@ const dev: SubCommand = {
originalLog(renderTabBar());
originalLog(logsLegend());
originalLog();
+ } else if (ch === "v") {
+ verbose = !verbose;
+ // Repaint tab bar + legend in-place to reflect new state
+ process.stdout.write("\x1B[s\x1B[H\x1B[2K");
+ originalLog(renderTabBar());
+ process.stdout.write("\x1B[2K");
+ originalLog(logsLegend());
+ process.stdout.write("\x1B[u");
+ console.log(dim(verbose ? "Verbose mode on" : "Verbose mode off"));
} else if (ch === "p") {
if (configIssues.length > 0) {
const prompt = buildFixPrompt(configIssues, baseUrl);
diff --git a/tools/liveblocks-cli/src/dev-server/lib/jwt-lite.ts b/tools/liveblocks-cli/src/dev-server/lib/jwt-lite.ts
index 79745f21443..feec5352207 100644
--- a/tools/liveblocks-cli/src/dev-server/lib/jwt-lite.ts
+++ b/tools/liveblocks-cli/src/dev-server/lib/jwt-lite.ts
@@ -16,7 +16,7 @@
*/
import type { DistributiveOmit } from "@liveblocks/core";
-import { nanoid, Permission, tryParseJson } from "@liveblocks/core";
+import { nanoid, tryParseJson } from "@liveblocks/core";
import type { DecoderType } from "decoders";
import {
array,
@@ -31,6 +31,7 @@ import {
} from "decoders";
import { userInfo } from "./decoders";
+import { Permission } from "./permissions";
const unsignedJwtHeader = object({
alg: constant("none"),
diff --git a/tools/liveblocks-cli/src/dev-server/lib/permissions.ts b/tools/liveblocks-cli/src/dev-server/lib/permissions.ts
new file mode 100644
index 00000000000..bad31899ff5
--- /dev/null
+++ b/tools/liveblocks-cli/src/dev-server/lib/permissions.ts
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) Liveblocks Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * Permission values accepted by the dev server. This must stay in sync with the
+ * backend's canonical Permission definition in shared/common/src/security/client-auth.ts.
+ * NOTE: Do not import from @liveblocks/core — the published version may lag behind.
+ * TODO: Find a way to DRY this up with the backend's Permission definition.
+ */
+export const Permission = {
+ RoomRead: "room:read",
+ RoomWrite: "room:write",
+ CommentsWrite: "comments:write",
+ FeedsWrite: "feeds:write",
+ /** @deprecated Accepted but ignored. Presence is always writable. */
+ RoomPresenceWrite: "room:presence:write",
+ /** @deprecated Accepted but ignored. Read access is implied by room:read. */
+ CommentsRead: "comments:read",
+} as const;
+
+export type Permission = (typeof Permission)[keyof typeof Permission];
diff --git a/tools/liveblocks-cli/src/dev-server/routes/auth.ts b/tools/liveblocks-cli/src/dev-server/routes/auth.ts
index 42827c16dca..789c7f21815 100644
--- a/tools/liveblocks-cli/src/dev-server/routes/auth.ts
+++ b/tools/liveblocks-cli/src/dev-server/routes/auth.ts
@@ -15,14 +15,14 @@
* along with this program. If not, see .
*/
-import { Permission } from "@liveblocks/core";
import { ZenRouter } from "@liveblocks/zenrouter";
import { array, enum_, object, optional, record, string } from "decoders";
import { authorizeSecretKey } from "~/dev-server/lib/auth";
import { userInfo } from "~/dev-server/lib/decoders";
import { createJwtLite } from "~/dev-server/lib/jwt-lite";
-// Valid permission values (from @liveblocks/core Permission enum)
+import { Permission } from "~/dev-server/lib/permissions";
+
const permission = enum_(Permission);
export const zen = new ZenRouter({
diff --git a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts
index 7ca0452a1ee..3688601cc5d 100644
--- a/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts
+++ b/tools/liveblocks-cli/src/dev-server/routes/rest-api.ts
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-import type { JsonObject, Permission, PlainLsonObject } from "@liveblocks/core";
+import type { JsonObject, PlainLsonObject } from "@liveblocks/core";
import { QueryParser } from "@liveblocks/query-parser";
import type { Guid, Logger, YDocId } from "@liveblocks/server";
import {
@@ -42,6 +42,7 @@ import * as Y from "yjs";
import type { DbRoom, RoomFilters } from "~/dev-server/db/rooms";
import * as Rooms from "~/dev-server/db/rooms";
import { authorizeSecretKey } from "~/dev-server/lib/auth";
+import type { Permission } from "~/dev-server/lib/permissions";
import { yDocToJson } from "~/dev-server/lib/ydoc";
import { DUMMY, NOT_IMPLEMENTED } from "~/dev-server/responses";