mirror of
https://github.com/trycua/computer.git
synced 2026-01-28 16:39:44 -06:00
added cua CLI
This commit is contained in:
34
libs/typescript/cua-cli/.gitignore
vendored
Normal file
34
libs/typescript/cua-cli/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
106
libs/typescript/cua-cli/CLAUDE.md
Normal file
106
libs/typescript/cua-cli/CLAUDE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
56
libs/typescript/cua-cli/README.md
Normal file
56
libs/typescript/cua-cli/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# CUA CLI (Bun)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun link # register package globally
|
||||
bun link cua-cli # install the global binary `cua`
|
||||
```
|
||||
|
||||
If you want to run without linking:
|
||||
|
||||
```bash
|
||||
bun run ./index.ts -- --help
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- **Auth**
|
||||
- `cua auth login` – opens browser to authorize; stores API key locally
|
||||
- `cua auth login --api-key sk-...` – stores provided key directly
|
||||
- `cua auth pull` – writes/updates `.env` with `CUA_API_KEY`
|
||||
- `cua auth logout` – clears stored API key
|
||||
|
||||
- **VMs**
|
||||
- `cua vm list`
|
||||
- `cua vm start NAME`
|
||||
- `cua vm stop NAME`
|
||||
- `cua vm restart NAME`
|
||||
|
||||
## Auth Flow (Dynamic Callback Port)
|
||||
|
||||
- CLI starts a small local HTTP server using `Bun.serve({ port: 0 })` which picks an available port.
|
||||
- Browser is opened to `https://cua.ai/cli-auth?callback_url=http://127.0.0.1:<port>/callback`.
|
||||
- After you click "Authorize CLI", the browser redirects to the local server with `?token=...`.
|
||||
- The CLI saves the API key in `~/.config/cua/cli.sqlite`.
|
||||
|
||||
> Note: If the browser cannot be opened automatically, copy/paste the printed URL.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `index.ts` – entry point (shebang + start CLI)
|
||||
- `src/cli.ts` – yargs bootstrapping
|
||||
- `src/commands/auth.ts` – auth/login/pull/logout commands
|
||||
- `src/commands/vm.ts` – vm list/start/stop/restart commands
|
||||
- `src/auth.ts` – browser flow + local callback server (dynamic port)
|
||||
- `src/http.ts` – HTTP helper
|
||||
- `src/storage.ts` – SQLite-backed key-value storage
|
||||
- `src/config.ts` – constants and paths
|
||||
- `src/util.ts` – table printing, .env writer
|
||||
|
||||
## Notes
|
||||
|
||||
- Stored API key lives at `~/.config/cua/cli.sqlite` under `kv(api_key)`.
|
||||
- Public API base: `https://api.cua.ai`.
|
||||
- Authorization header: `Authorization: Bearer <api_key>`.
|
||||
63
libs/typescript/cua-cli/bun.lock
Normal file
63
libs/typescript/cua-cli/bun.lock
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "cua-cli",
|
||||
"dependencies": {
|
||||
"yargs": "^18.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/yargs": "^17.0.33",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.9.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-MKNwXh3seSK8WurXF7erHPJ2AONmMwkI7zAMrXZDPIru8jRqkk6rGDBVbw4mLwfqA+ZZliiDPg05JQ3uW66tKQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
|
||||
|
||||
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
}
|
||||
}
|
||||
7
libs/typescript/cua-cli/index.ts
Executable file
7
libs/typescript/cua-cli/index.ts
Executable file
@@ -0,0 +1,7 @@
|
||||
#! /usr/bin/env bun
|
||||
import { runCli } from "./src/cli";
|
||||
|
||||
runCli().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
19
libs/typescript/cua-cli/package.json
Normal file
19
libs/typescript/cua-cli/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cua-cli",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/yargs": "^17.0.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"bin": {
|
||||
"cua": "./index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"yargs": "^18.0.0"
|
||||
}
|
||||
}
|
||||
54
libs/typescript/cua-cli/src/auth.ts
Normal file
54
libs/typescript/cua-cli/src/auth.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { AUTH_PAGE, CALLBACK_HOST } from "./config";
|
||||
import { setApiKey, getApiKey } from "./storage";
|
||||
import { openInBrowser } from "./util";
|
||||
|
||||
const c = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
underline: "\x1b[4m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
};
|
||||
|
||||
// openInBrowser is imported from util
|
||||
|
||||
export async function loginViaBrowser(): Promise<string> {
|
||||
let resolveToken!: (v: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => { resolveToken = resolve; });
|
||||
|
||||
// dynamic port (0) -> OS chooses available port
|
||||
const server = Bun.serve({
|
||||
hostname: CALLBACK_HOST,
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname !== "/callback") return new Response("Not found", { status: 404 });
|
||||
const token = u.searchParams.get("token");
|
||||
if (!token) return new Response("Missing token", { status: 400 });
|
||||
resolveToken(token);
|
||||
queueMicrotask(() => server.stop());
|
||||
return new Response("CLI authorized. You can close this window.", { status: 200, headers: { "content-type": "text/plain" } });
|
||||
},
|
||||
});
|
||||
|
||||
const callbackURL = `http://${CALLBACK_HOST}:${server.port}/callback`;
|
||||
const url = `${AUTH_PAGE}?callback_url=${encodeURIComponent(callbackURL)}`;
|
||||
console.log(`${c.cyan}${c.bold}Opening your default browser to authorize the CLI...${c.reset}`);
|
||||
console.log(`${c.dim}If the browser does not open automatically, copy/paste this URL:${c.reset}`);
|
||||
console.log(`${c.yellow}${c.underline}${url}${c.reset}`);
|
||||
await openInBrowser(url);
|
||||
|
||||
const timeout = new Promise<string>((_, reject) => setTimeout(() => reject(new Error("Timed out waiting for authorization")), 2 * 60 * 1000));
|
||||
try { return await Promise.race([tokenPromise, timeout]); }
|
||||
finally { try { server.stop(); } catch {} }
|
||||
}
|
||||
|
||||
export async function ensureApiKeyInteractive(): Promise<string> {
|
||||
const existing = getApiKey();
|
||||
if (existing) return existing;
|
||||
const token = await loginViaBrowser();
|
||||
setApiKey(token);
|
||||
return token;
|
||||
}
|
||||
11
libs/typescript/cua-cli/src/cli.ts
Normal file
11
libs/typescript/cua-cli/src/cli.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import yargs from "yargs";
|
||||
import { hideBin } from "yargs/helpers";
|
||||
import { registerAuthCommands } from "./commands/auth";
|
||||
import { registerVmCommands } from "./commands/vm";
|
||||
|
||||
export async function runCli() {
|
||||
let argv = yargs(hideBin(process.argv)).scriptName("cua");
|
||||
argv = registerAuthCommands(argv);
|
||||
argv = registerVmCommands(argv);
|
||||
await argv.demandCommand(1).strict().help().parseAsync();
|
||||
}
|
||||
49
libs/typescript/cua-cli/src/commands/auth.ts
Normal file
49
libs/typescript/cua-cli/src/commands/auth.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { setApiKey, clearApiKey } from "../storage";
|
||||
import { ensureApiKeyInteractive, loginViaBrowser } from "../auth";
|
||||
import { writeEnvFile } from "../util";
|
||||
import type { Argv } from "yargs";
|
||||
|
||||
export function registerAuthCommands(y: Argv) {
|
||||
return y.command(
|
||||
"auth",
|
||||
"Auth commands",
|
||||
(ya) =>
|
||||
ya
|
||||
.command(
|
||||
"login",
|
||||
"Open browser to authorize and store API key",
|
||||
(y) => y.option("api-key", { type: "string", describe: "API key to store directly" }),
|
||||
async (argv: Record<string, unknown>) => {
|
||||
if (argv["api-key"]) {
|
||||
setApiKey(String(argv["api-key"]));
|
||||
console.log("API key saved");
|
||||
return;
|
||||
}
|
||||
console.log("Opening browser for CLI auth...");
|
||||
const token = await loginViaBrowser();
|
||||
setApiKey(token);
|
||||
console.log("API key saved");
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"pull",
|
||||
"Create or update .env with CUA_API_KEY (login if needed)",
|
||||
() => {},
|
||||
async (_argv: Record<string, unknown>) => {
|
||||
const token = await ensureApiKeyInteractive();
|
||||
const out = await writeEnvFile(process.cwd(), token);
|
||||
console.log(`Wrote ${out}`);
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"logout",
|
||||
"Remove stored API key",
|
||||
() => {},
|
||||
async (_argv: Record<string, unknown>) => {
|
||||
clearApiKey();
|
||||
console.log("Logged out");
|
||||
}
|
||||
)
|
||||
.demandCommand(1, "Specify an auth subcommand")
|
||||
);
|
||||
}
|
||||
96
libs/typescript/cua-cli/src/commands/vm.ts
Normal file
96
libs/typescript/cua-cli/src/commands/vm.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Argv } from "yargs";
|
||||
import { ensureApiKeyInteractive } from "../auth";
|
||||
import { http } from "../http";
|
||||
import { printVmList, openInBrowser } from "../util";
|
||||
import type { VmItem } from "../util";
|
||||
import { clearApiKey } from "../storage";
|
||||
|
||||
export function registerVmCommands(y: Argv) {
|
||||
return y.command(
|
||||
"vm",
|
||||
"VM commands",
|
||||
(yv) =>
|
||||
yv
|
||||
.command(
|
||||
"list",
|
||||
"List VMs",
|
||||
() => {},
|
||||
async (_argv: Record<string, unknown>) => {
|
||||
const token = await ensureApiKeyInteractive();
|
||||
const res = await http("/v1/vms", { token });
|
||||
if (res.status === 401) {
|
||||
clearApiKey();
|
||||
console.error("Unauthorized. Try 'cua auth login' again.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.error(`Request failed: ${res.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = (await res.json()) as VmItem[];
|
||||
printVmList(data);
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"start <name>",
|
||||
"Start a VM",
|
||||
(y) => y.positional("name", { type: "string", describe: "VM name" }),
|
||||
async (argv: Record<string, unknown>) => {
|
||||
const token = await ensureApiKeyInteractive();
|
||||
const name = String((argv as any).name);
|
||||
const res = await http(`/v1/vms/${encodeURIComponent(name)}/start`, { token, method: "POST" });
|
||||
if (res.status === 204) { console.log("Start accepted"); return; }
|
||||
if (res.status === 404) { console.error("VM not found"); process.exit(1); }
|
||||
if (res.status === 401) { clearApiKey(); console.error("Unauthorized. Try 'cua auth login' again."); process.exit(1); }
|
||||
console.error(`Unexpected status: ${res.status}`); process.exit(1);
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"stop <name>",
|
||||
"Stop a VM",
|
||||
(y) => y.positional("name", { type: "string", describe: "VM name" }),
|
||||
async (argv: Record<string, unknown>) => {
|
||||
const token = await ensureApiKeyInteractive();
|
||||
const name = String((argv as any).name);
|
||||
const res = await http(`/v1/vms/${encodeURIComponent(name)}/stop`, { token, method: "POST" });
|
||||
if (res.status === 202) { const body = (await res.json().catch(() => ({}))) as { status?: string }; console.log(body.status ?? "stopping"); return; }
|
||||
if (res.status === 404) { console.error("VM not found"); process.exit(1); }
|
||||
if (res.status === 401) { clearApiKey(); console.error("Unauthorized. Try 'cua auth login' again."); process.exit(1); }
|
||||
console.error(`Unexpected status: ${res.status}`); process.exit(1);
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"restart <name>",
|
||||
"Restart a VM",
|
||||
(y) => y.positional("name", { type: "string", describe: "VM name" }),
|
||||
async (argv: Record<string, unknown>) => {
|
||||
const token = await ensureApiKeyInteractive();
|
||||
const name = String((argv as any).name);
|
||||
const res = await http(`/v1/vms/${encodeURIComponent(name)}/restart`, { token, method: "POST" });
|
||||
if (res.status === 202) { const body = (await res.json().catch(() => ({}))) as { status?: string }; console.log(body.status ?? "restarting"); return; }
|
||||
if (res.status === 404) { console.error("VM not found"); process.exit(1); }
|
||||
if (res.status === 401) { clearApiKey(); console.error("Unauthorized. Try 'cua auth login' again."); process.exit(1); }
|
||||
console.error(`Unexpected status: ${res.status}`); process.exit(1);
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"vnc <name>",
|
||||
"Open NoVNC for a VM in your browser",
|
||||
(y) => y.positional("name", { type: "string", describe: "VM name" }),
|
||||
async (argv: Record<string, unknown>) => {
|
||||
const token = await ensureApiKeyInteractive();
|
||||
const name = String((argv as any).name);
|
||||
const listRes = await http("/v1/vms", { token });
|
||||
if (listRes.status === 401) { clearApiKey(); console.error("Unauthorized. Try 'cua auth login' again."); process.exit(1); }
|
||||
if (!listRes.ok) { console.error(`Request failed: ${listRes.status}`); process.exit(1); }
|
||||
const vms = (await listRes.json()) as VmItem[];
|
||||
const vm = vms.find(v => v.name === name);
|
||||
if (!vm) { console.error("VM not found"); process.exit(1); }
|
||||
const url = `https://${vm.name}.containers.cloud.trycua.com/vnc.html?autoconnect=true&password=${encodeURIComponent(vm.password)}`;
|
||||
console.log(`Opening NoVNC: ${url}`);
|
||||
await openInBrowser(url);
|
||||
}
|
||||
)
|
||||
.demandCommand(1, "Specify a vm subcommand")
|
||||
);
|
||||
}
|
||||
16
libs/typescript/cua-cli/src/config.ts
Normal file
16
libs/typescript/cua-cli/src/config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const API_BASE = "https://api.cua.ai";
|
||||
export const AUTH_PAGE = "https://cua.ai/cli-auth";
|
||||
export const CALLBACK_HOST = "127.0.0.1";
|
||||
|
||||
export function getConfigDir(): string {
|
||||
const home = Bun.env.HOME || Bun.env.USERPROFILE || ".";
|
||||
const dir = `${home}/.cua/config`;
|
||||
try {
|
||||
Bun.spawnSync(["mkdir", "-p", dir]);
|
||||
} catch {}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function getDbPath(): string {
|
||||
return `${getConfigDir()}/cli.sqlite`;
|
||||
}
|
||||
12
libs/typescript/cua-cli/src/http.ts
Normal file
12
libs/typescript/cua-cli/src/http.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export async function http(path: string, opts: { method?: string; token: string; body?: any }): Promise<Response> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${opts.token}` };
|
||||
if (opts.body) headers["content-type"] = "application/json";
|
||||
return fetch(url, {
|
||||
method: opts.method || "GET",
|
||||
headers,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
}
|
||||
26
libs/typescript/cua-cli/src/storage.ts
Normal file
26
libs/typescript/cua-cli/src/storage.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { getDbPath } from "./config";
|
||||
|
||||
function getDb(): Database {
|
||||
const db = new Database(getDbPath());
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec("CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);");
|
||||
return db;
|
||||
}
|
||||
|
||||
export function setApiKey(token: string) {
|
||||
const db = getDb();
|
||||
const stmt = db.query("INSERT INTO kv (k, v) VALUES ('api_key', ?) ON CONFLICT(k) DO UPDATE SET v=excluded.v");
|
||||
stmt.run(token);
|
||||
}
|
||||
|
||||
export function getApiKey(): string | null {
|
||||
const db = getDb();
|
||||
const row = db.query("SELECT v FROM kv WHERE k='api_key'").get() as { v: string } | undefined;
|
||||
return row?.v ?? null;
|
||||
}
|
||||
|
||||
export function clearApiKey() {
|
||||
const db = getDb();
|
||||
db.query("DELETE FROM kv WHERE k='api_key'").run();
|
||||
}
|
||||
32
libs/typescript/cua-cli/src/util.ts
Normal file
32
libs/typescript/cua-cli/src/util.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export async function writeEnvFile(cwd: string, key: string) {
|
||||
const path = `${cwd}/.env`;
|
||||
let content = "";
|
||||
try { content = await Bun.file(path).text(); } catch {}
|
||||
const lines = content.split(/\r?\n/).filter(Boolean);
|
||||
const idx = lines.findIndex((l) => l.startsWith("CUA_API_KEY="));
|
||||
if (idx >= 0) lines[idx] = `CUA_API_KEY=${key}`; else lines.push(`CUA_API_KEY=${key}`);
|
||||
await Bun.write(path, lines.join("\n") + "\n");
|
||||
return path;
|
||||
}
|
||||
|
||||
export type VmStatus = "pending" | "running" | "stopped" | "terminated" | "failed";
|
||||
export type VmItem = { name: string; password: string; status: VmStatus };
|
||||
|
||||
export function printVmList(items: VmItem[]) {
|
||||
const rows: string[][] = [["NAME", "STATUS", "PASSWORD"], ...items.map(v => [v.name, String(v.status), v.password])];
|
||||
const widths: number[] = [0, 0, 0];
|
||||
for (const r of rows) for (let i = 0; i < 3; i++) widths[i] = Math.max(widths[i] ?? 0, (r[i] ?? "").length);
|
||||
for (const r of rows) console.log(r.map((c, i) => (c ?? "").padEnd(widths[i] ?? 0)).join(" "));
|
||||
if (items.length === 0) console.log("No VMs found");
|
||||
}
|
||||
|
||||
export async function openInBrowser(url: string) {
|
||||
const platform = process.platform;
|
||||
let cmd: string;
|
||||
let args: string[] = [];
|
||||
if (platform === "darwin") { cmd = "open"; args = [url]; }
|
||||
else if (platform === "win32") { cmd = "cmd"; args = ["/c", "start", "", url]; }
|
||||
else { cmd = "xdg-open"; args = [url]; }
|
||||
try { await Bun.spawn({ cmd: [cmd, ...args] }).exited; }
|
||||
catch { console.error(`Failed to open browser. Please visit: ${url}`); }
|
||||
}
|
||||
29
libs/typescript/cua-cli/tsconfig.json
Normal file
29
libs/typescript/cua-cli/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user