Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
431 changes: 218 additions & 213 deletions playground/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tigrisdata/agent-shell": "^0.1.3",
"@tigrisdata/agent-shell": "^0.3.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0"
},
Expand Down
61 changes: 61 additions & 0 deletions playground/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createAuth0Client } from "@auth0/auth0-spa-js";
import type { LoginFn, Organization } from "@tigrisdata/agent-shell/repl";

const AUTH0_DOMAIN = "auth.storage.tigrisdata.io";
const AUTH0_CLIENT_ID = "FKXunmhaaBZOYXjNYLIU8Fi2jIqpT7DR";
const AUTH0_AUDIENCE = "https://tigris-os-api";
const CLAIMS_NAMESPACE = "https://tigris";

/**
* Browser login using Auth0 SPA SDK (Authorization Code + PKCE).
* Opens a popup for authentication.
*/
export const browserLogin: LoginFn = async (io) => {
io.write("Logging in to Tigris...\n");

const auth0 = await createAuth0Client({
domain: AUTH0_DOMAIN,
clientId: AUTH0_CLIENT_ID,
authorizationParams: {
audience: AUTH0_AUDIENCE,
scope: "openid profile email offline_access",
redirect_uri: window.location.origin,
},
});

await auth0.loginWithPopup({
authorizationParams: {
audience: AUTH0_AUDIENCE,
scope: "openid profile email offline_access",
},
});

const accessToken = await auth0.getTokenSilently({
authorizationParams: {
audience: AUTH0_AUDIENCE,
},
});

const user = await auth0.getUser();
const email = user?.email ?? user?.name ?? "unknown";

io.write(`Logged in as ${email}\n`);

// Fetch organizations from userinfo
const userInfoResponse = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

let organizations: Organization[] = [];
if (userInfoResponse.ok) {
const data = (await userInfoResponse.json()) as Record<string, unknown>;
const claims = data[CLAIMS_NAMESPACE] as { ns?: Organization[] } | undefined;
organizations = claims?.ns ?? [];
}

return {
accessToken,
email,
organizations,
};
};
11 changes: 0 additions & 11 deletions playground/src/credentials.ts

This file was deleted.

226 changes: 79 additions & 147 deletions playground/src/shell-loop.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,88 @@
import type { TigrisConfig } from "@tigrisdata/agent-shell";
import { TigrisShell } from "@tigrisdata/agent-shell";
import type { ReplIO } from "@tigrisdata/agent-shell/repl";
import { ReplSession } from "@tigrisdata/agent-shell/repl";
import type { Terminal } from "@xterm/xterm";
import { getCredentials, setCredentials } from "./credentials.js";
import { browserLogin } from "./auth.js";

const PROMPT = "\x1b[32m$ \x1b[0m";
const YELLOW = "\x1b[33m";
const RED = "\x1b[31m";
const GREEN = "\x1b[32m";
const DIM = "\x1b[2m";
const RESET = "\x1b[0m";

/**
* Thin xterm.js adapter over ReplSession.
* Handles keyboard input, history, and line editing.
* Delegates all command execution to the shared REPL layer.
*/
export class ShellLoop {
private currentLine = "";
private cursorPos = 0;
private history: string[] = [];
private historyIndex = -1;
private shell: TigrisShell | null = null;
private terminal: Terminal;
private session: ReplSession;
private busy = false;
private cwd: string | undefined;
private pendingPromptResolve: ((value: string) => void) | null = null;
private pendingPromptText = "";

constructor(terminal: Terminal) {
this.terminal = terminal;
this.session = new ReplSession({ loginFn: browserLogin });
}

start() {
this.prompt();
this.terminal.onData((data) => this.handleInput(data));
}

private get io(): ReplIO {
return {
write: (text: string) => {
this.terminal.write(text.replace(/\r?\n/g, "\r\n"));
},
prompt: (message: string) => {
this.terminal.write(message.replace(/\r?\n/g, "\r\n"));
// Store only the last line for redrawing (strip leading newlines)
const lines = message.split(/\r?\n/);
this.pendingPromptText = lines[lines.length - 1] ?? "";
return new Promise<string>((resolve) => {
this.pendingPromptResolve = resolve;
});
},
};
}

private prompt() {
this.terminal.write(`\r\n${PROMPT}`);
this.currentLine = "";
this.cursorPos = 0;
}

private async handleInput(data: string) {
// Block input while a command is executing
if (this.busy) return;
if (this.busy && !this.pendingPromptResolve) return;

if (data === "\r") {
this.terminal.write("\r\n");
const line = this.currentLine.trim();
this.currentLine = "";
this.cursorPos = 0;

// If a prompt is waiting for input, resolve it
if (this.pendingPromptResolve) {
const resolve = this.pendingPromptResolve;
this.pendingPromptResolve = null;
this.pendingPromptText = "";
resolve(line);
return;
}

if (line) {
this.history.push(line);
this.historyIndex = this.history.length;
this.busy = true;
await this.execute(line);

if (line === "clear") {
this.terminal.clear();
} else {
await this.session.handle(line, this.io);
}

this.busy = false;
}

Expand All @@ -66,34 +101,47 @@ export class ShellLoop {
}

if (data === "\x03") {
this.terminal.write("^C");
this.terminal.write("^C\r\n");
if (this.pendingPromptResolve) {
const resolve = this.pendingPromptResolve;
this.pendingPromptResolve = null;
this.pendingPromptText = "";
resolve("");
// Don't prompt here — the session flow will resume and prompt when done
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.
this.prompt();
return;
}

if (data === "\x0c") {
this.terminal.clear();
this.terminal.write(PROMPT + this.currentLine);
const prefix = this.pendingPromptResolve ? this.pendingPromptText : PROMPT;
this.terminal.write(prefix + this.currentLine);
return;
}

if (data === "\x1b[A") {
if (this.historyIndex > 0) {
this.historyIndex--;
this.setLine(this.history[this.historyIndex] ?? "");
// Arrow keys only when not in prompt mode
Comment thread
cursor[bot] marked this conversation as resolved.
if (!this.pendingPromptResolve) {
if (data === "\x1b[A") {
if (this.historyIndex > 0) {
this.historyIndex--;
this.setLine(this.history[this.historyIndex] ?? "");
}
return;
}
return;
}
if (data === "\x1b[B") {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.setLine(this.history[this.historyIndex] ?? "");
} else {
this.historyIndex = this.history.length;
this.setLine("");
if (data === "\x1b[B") {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.setLine(this.history[this.historyIndex] ?? "");
} else {
this.historyIndex = this.history.length;
this.setLine("");
}
return;
}
return;
}

if (data === "\x1b[C") {
if (this.cursorPos < this.currentLine.length) {
this.cursorPos++;
Expand Down Expand Up @@ -122,127 +170,11 @@ export class ShellLoop {
}

private redrawLine() {
this.terminal.write(`\r\x1b[K${PROMPT}${this.currentLine}`);
const prefix = this.pendingPromptResolve ? this.pendingPromptText : PROMPT;
this.terminal.write(`\r\x1b[K${prefix}${this.currentLine}`);
const back = this.currentLine.length - this.cursorPos;
if (back > 0) {
this.terminal.write(`\x1b[${back}D`);
}
}

private async execute(command: string) {
if (command === "clear") {
this.terminal.clear();
return;
}

const firstToken = command.split(/\s+/)[0];

if (firstToken === "configure") {
this.handleConfigure(command);
return;
}

if (!this.shell) {
this.writeOutput(`${RED}Not configured. Run 'configure' first.${RESET}\r\n`);
return;
}

if (command === "flush") {
await this.handleFlush();
return;
}

try {
const result = await this.shell.engine.exec(command, {
...(this.cwd !== undefined && { cwd: this.cwd }),
});

// Track cwd changes (e.g. cd) across exec calls
if (result.env?.PWD) {
this.cwd = result.env.PWD;
}

if (result.stdout) {
this.writeOutput(result.stdout);
}
if (result.stderr) {
this.writeOutput(`${RED}${result.stderr}${RESET}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.writeOutput(`${RED}Error: ${message}${RESET}\r\n`);
}
}

private handleConfigure(command: string) {
const args = command.split(/\s+/);
let bucket: string | undefined;
let accessKeyId: string | undefined;
let secretAccessKey: string | undefined;

for (let i = 1; i < args.length; i++) {
if (args[i] === "--bucket" && args[i + 1]) {
bucket = args[i + 1];
i++;
} else if (args[i] === "--key" && args[i + 1]) {
accessKeyId = args[i + 1];
i++;
} else if (args[i] === "--secret" && args[i + 1]) {
secretAccessKey = args[i + 1];
i++;
}
}

if (!bucket || !accessKeyId || !secretAccessKey) {
const creds = getCredentials();
if (creds) {
this.writeOutput(`${GREEN}Connected to bucket: ${creds.bucket}${RESET}\r\n`);
this.writeOutput(`${DIM}Access key: ${creds.accessKeyId.slice(0, 8)}...${RESET}\r\n`);
} else {
this.writeOutput(
"Usage: configure --bucket <name> --key <accessKeyId> --secret <secretAccessKey>\r\n",
);
}
return;
}

const config: TigrisConfig = { bucket, accessKeyId, secretAccessKey };
setCredentials(config);

this.shell = new TigrisShell(config, {
env: { BUCKET: bucket },
});
this.cwd = undefined;

this.writeOutput(`${GREEN}Connected to bucket: ${bucket}${RESET}\r\n`);
this.writeOutput("\r\n");
this.writeOutput(
`${YELLOW}WARNING: Credentials are stored in browser memory only.${RESET}\r\n`,
);
this.writeOutput(`${YELLOW}They will be lost when you close or refresh this tab.${RESET}\r\n`);
this.writeOutput("\r\n");
this.writeOutput(`${DIM}Commands available: flush, presign, snapshot, fork${RESET}\r\n`);
this.writeOutput(`${DIM}Use $BUCKET in commands, e.g.: snapshot $BUCKET --list${RESET}\r\n`);
}

private async handleFlush() {
if (!this.shell) {
this.writeOutput(`${RED}Not configured. Run 'configure' first.${RESET}\r\n`);
return;
}

try {
await this.shell.flush();
const creds = getCredentials();
this.writeOutput(`${GREEN}Flushed to bucket: ${creds?.bucket}${RESET}\r\n`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.writeOutput(`${RED}Flush failed: ${message}${RESET}\r\n`);
}
}

private writeOutput(text: string) {
const lines = text.replace(/\r?\n/g, "\r\n");
this.terminal.write(lines);
}
}
9 changes: 2 additions & 7 deletions playground/src/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,7 @@ export function showWelcome(terminal: Terminal) {
);
terminal.writeln("");
terminal.writeln(`${DIM}Connect to Tigris:${RESET}`);
terminal.writeln(
`${DIM} configure --bucket <name> --key <accessKeyId> --secret <secretAccessKey>${RESET}`,
);
terminal.writeln(`${DIM} configure --key <id> --secret <key> Set credentials${RESET}`);
terminal.writeln("");
terminal.writeln(
`${DIM}After configuring, all bash commands are available. Files are stored${RESET}`,
);
terminal.writeln(`${DIM}in memory until you run flush.${RESET}`);
terminal.writeln(`${DIM}Type 'help' for all commands.${RESET}`);
}
Loading