diff --git a/src/cli/README.md b/src/cli/README.md index 9e9f413a7..acb1115ae 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -1,7 +1,6 @@ # `@heyputer/cli` -> **Beta (0.x).** Deploy static sites and serverless workers to Puter from the -> terminal. +> **Beta (0.x).** Puter CLI — developer tooling from your terminal. ## Install @@ -56,3 +55,32 @@ Browse the apps registered on your account. These commands are read-only. puter app list puter app get ``` + +## Key-value store + +Open an interactive JavaScript shell against one app's key-value store. Takes +an app name or a uid. + +```sh +puter kv connect +``` + +Every `puter.kv` method is bound to that app and available bare, so pasted +docs work either way: + +```console +$ puter kv connect notes +✔ Connected to notes (app-1f2e3d4c…) · 12 keys +kv(notes)> set("greeting", "hi") +true +kv(notes)> get("greeting") +'hi' +kv(notes)> list("gre", true) +[ { key: 'greeting', value: 'hi' } ] +kv(notes)> puter.kv.incr("visits") +1 +``` + +Results are awaited for you — `get("k")` prints the value, not a pending +promise — and `_` holds the last one. `.help` lists the methods, `.clear` +resets the session, `.exit` (or Ctrl-D) quits. Needs a terminal. diff --git a/src/cli/bin/puter.js b/src/cli/bin/puter.js index e26e1ffc0..e6091f6bc 100755 --- a/src/cli/bin/puter.js +++ b/src/cli/bin/puter.js @@ -19,6 +19,7 @@ import { workerDelete, } from '../src/commands/worker.js'; import { appList, appGet } from '../src/commands/app.js'; +import { kvConnect } from '../src/commands/kv.js'; // The Puter.js SDK emits duplicate "stray" rejections for failed API calls in // addition to rejecting the promise we await. We already route the awaited @@ -35,7 +36,7 @@ const program = new Command(); program .name('puter') - .description('CLI for the Puter platform — deploy sites and workers. (beta)') + .description('Puter CLI — developer tooling from your terminal. (beta)') .version(version, '-v, --version'); // --- auth ------------------------------------------------------------------ @@ -129,4 +130,14 @@ app .argument('') .action(action(appGet)); +// --- kv --------------------------------------------------------------------- + +const kv = program.command('kv').description("Explore an app's key-value store"); + +kv + .command('connect') + .description("Open an interactive shell against an app's KV store") + .argument('', 'app name or uid') + .action(action(kvConnect)); + program.parseAsync(process.argv); diff --git a/src/cli/package.json b/src/cli/package.json index 78591e0de..c3a130f68 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -1,7 +1,7 @@ { "name": "@heyputer/cli", - "version": "0.1.2", - "description": "CLI for Puter Platform - manage your sites and workers from the terminal.", + "version": "0.2.0", + "description": "Puter CLI - developer tooling from your terminal.", "license": "MIT", "author": "Puter Technologies Inc.", "type": "module", diff --git a/src/cli/src/commands/app.js b/src/cli/src/commands/app.js index da7dca6f3..0552f5e0a 100644 --- a/src/cli/src/commands/app.js +++ b/src/cli/src/commands/app.js @@ -1,18 +1,11 @@ // Read-only for beta (spec §7). An "app" is a registered desktop-OS entry on // top of hosting; defining "app deploy" is deferred. +import { appsApi } from '../lib/apps.js'; import { ensureClient } from '../lib/auth.js'; import { CLIError } from '../lib/errors.js'; import * as ui from '../lib/ui.js'; -function appsApi(puter) { - const api = puter.apps ?? puter.app; - if (!api || typeof api.list !== 'function') { - throw new CLIError('App commands are not available in this SDK build.'); - } - return api; -} - export async function appList() { const puter = await ensureClient(); const apps = (await appsApi(puter).list()) ?? []; diff --git a/src/cli/src/commands/kv.js b/src/cli/src/commands/kv.js new file mode 100644 index 000000000..54da31f7c --- /dev/null +++ b/src/cli/src/commands/kv.js @@ -0,0 +1,163 @@ +// `puter kv connect ` — an interactive shell against one app's +// key-value store. +// +// The REPL is Node's own (multiline, history, Ctrl-C/Ctrl-D, `_` and +// util.inspect formatting come free); we only add two things on top: +// results are awaited before they're echoed, so `get("x")` prints the value +// rather than `Promise { }`, and failures print one line instead of +// a stack. + +import path from 'node:path'; +import repl from 'node:repl'; + +import { appsApi } from '../lib/apps.js'; +import { ensureClient } from '../lib/auth.js'; +import { configPath } from '../lib/config.js'; +import { isInteractive } from '../lib/env.js'; +import { CLIError, messageOf } from '../lib/errors.js'; +import { bindApp, KV_METHODS } from '../lib/kvbind.js'; +import * as ui from '../lib/ui.js'; + +// How far we count keys on connect. An exact total would mean `includeTotal`, +// which runs a metered count over every key in the store, so we probe one page +// instead and say "100+" when it fills. +const PROBE_LIMIT = 100; + +const APPS_HINT = "Run 'puter app list' to see your apps."; + +// Accept either an app name or the uid itself, so a uid pasted from +// `puter app list` (or the MCP tools) doesn't need a lookup. +async function resolveApp(puter, appArg) { + if (/^app-/.test(appArg)) return { name: appArg, uid: appArg, byUid: true }; + + let app; + try { + app = await appsApi(puter).get(appArg); + } catch (err) { + throw new CLIError(`Could not fetch '${appArg}': ${messageOf(err)}`, { + hint: APPS_HINT, + }); + } + if (!app?.uid) { + throw new CLIError(`App '${appArg}' not found.`, { hint: APPS_HINT }); + } + return { name: app.name ?? appArg, uid: app.uid }; +} + +// One round-trip that both proves the store is reachable and gives us +// something to put in the banner. +async function probeKeys(kv, label) { + let page; + try { + page = await kv.list({ limit: PROBE_LIMIT + 1, fetchUntilFull: true }); + } catch (err) { + throw new CLIError( + `Could not read the key-value store for '${label}': ${messageOf(err)}`, + ); + } + + const items = Array.isArray(page) ? page : (page?.items ?? []); + if (items.length > PROBE_LIMIT) return `${PROBE_LIMIT}+ keys`; + return `${items.length} ${items.length === 1 ? 'key' : 'keys'}`; +} + +const HELP = ` +kv methods — also available as kv.* and puter.kv.* + + get(key) set(key, value, [expireAt]) + list([pattern], [values]) del(key) + incr(key, [amount]) decr(key, [amount]) + add(key, value) remove(key, ...paths) + update(key, pathMap) flush() + expire(key, ttl) expireAt(key, timestamp) + +Results are awaited for you, so \`get("k")\` prints the value. \`_\` holds the +last result. .clear resets the session, .exit (or Ctrl-D) quits. +`.trim(); + +// Resolves when the user leaves the REPL — action() force-exits the process +// as soon as a command's promise settles, so this one has to stay pending for +// the life of the session. +function startRepl({ kv, prompt }) { + return new Promise((resolve) => { + const server = repl.start({ prompt, useGlobal: false }); + + // A failure is echoed as a value rather than thrown at the REPL: SDK + // rejections are plain { message, code } objects, which the default + // writer would dump as an object literal. + const KV_ERROR = Symbol('kvError'); + const asError = (err) => ({ [KV_ERROR]: messageOf(err) }); + + const baseWriter = server.writer; + server.writer = (value) => + value?.[KV_ERROR] !== undefined + ? `${ui.red('Error:')} ${value[KV_ERROR]}` + : baseWriter.call(server, value); + + // Wrapped after start(): passing an `eval` to repl.start() would replace + // the default one rather than layer on it. + const defaultEval = server.eval; + server.eval = (cmd, context, filename, cb) => { + defaultEval.call(server, cmd, context, filename, (err, result) => { + if (err) { + // Recoverable means "incomplete input, keep buffering" — swallowing + // it here would break multiline entirely. + if (err instanceof repl.Recoverable) return cb(err); + return cb(null, asError(err)); + } + Promise.resolve(result).then( + (value) => cb(null, value), + (rejection) => cb(null, asError(rejection)), + ); + }); + }; + + // .clear builds a fresh context, so the bindings have to be reinstalled. + const install = (context) => { + for (const name of KV_METHODS) context[name] = kv[name]; + context.kv = kv; + // A shim, not the real client: an unscoped puter.fs/puter.apps has no + // business in an app-scoped shell. + context.puter = { kv }; + }; + install(server.context); + server.on('reset', install); + + server.defineCommand('help', { + help: 'Show the kv methods', + action() { + this.output.write(`${HELP}\n`); + this.displayPrompt(); + }, + }); + + server.setupHistory( + path.join(path.dirname(configPath), 'kv-history'), + () => {}, // best effort; a read-only config dir shouldn't end the session + ); + + server.on('exit', resolve); + }); +} + +export async function kvConnect(appArg) { + if (!isInteractive()) { + throw new CLIError('`puter kv connect` needs a terminal.', { + hint: 'Run it from an interactive shell.', + }); + } + + const puter = await ensureClient(); + const { name, uid, byUid } = await resolveApp(puter, appArg); + const kv = bindApp(puter.kv, uid); + const keys = await probeKeys(kv, name); + + // A uid is its own name here, so don't print it twice — and keep it out of + // the prompt at full length, where it would dwarf what you type. + const where = byUid ? '' : ` ${ui.dim(`(${uid})`)}`; + ui.success(`Connected to ${ui.bold(name)}${where} · ${keys}`); + ui.info('.help for the kv methods, .exit to quit'); + + const label = byUid ? `${uid.slice(0, 12)}…` : name; + await startRepl({ kv, prompt: `kv(${label})> ` }); +} diff --git a/src/cli/src/lib/apps.js b/src/cli/src/lib/apps.js new file mode 100644 index 000000000..16a7ab8c6 --- /dev/null +++ b/src/cli/src/lib/apps.js @@ -0,0 +1,12 @@ +// Access to the SDK's apps module. Older/partial SDK builds expose it under a +// different name (or not at all), so every caller goes through this guard. + +import { CLIError } from './errors.js'; + +export function appsApi(puter) { + const api = puter.apps ?? puter.app; + if (!api || typeof api.list !== 'function') { + throw new CLIError('App commands are not available in this SDK build.'); + } + return api; +} diff --git a/src/cli/src/lib/kvbind.js b/src/cli/src/lib/kvbind.js new file mode 100644 index 000000000..81c1a3b0b --- /dev/null +++ b/src/cli/src/lib/kvbind.js @@ -0,0 +1,97 @@ +// Binds puter.kv to one app's store. +// +// A user token can address another app's key-value store by passing +// `optConfig: { appUuid }` into the driver call — the backend pins app-scoped +// tokens to their own app but honors the override for a user token, which is +// what the CLI carries. (src/mcp-connector/src/tools.js does the same thing +// for the MCP tools.) +// +// The wrinkle is that optConfig sits in a different argument slot for every +// method — trailing after the paths for remove(), after the optional numeric +// expireAt/ttl for set()/update(), in the amount slot for incr()/decr(), and +// so on. Rather than encode twelve slot positions, we rely on the fact that +// every method already accepts optConfig as a *trailing* argument (see +// src/puter-js/src/modules/kv/lib/args.js), and only special-case the object +// form, where the config belongs inside the options object instead. +// +// These wrappers never print: the REPL echoes results itself, and a log here +// would double every line. + +import { CLIError } from './errors.js'; + +const isPlainObject = (v) => + v !== null && typeof v === 'object' && !Array.isArray(v); + +// `{ appUuid }` in a trailing slot is the SDK's optConfig shorthand — if the +// caller wrote one themselves, they meant it, so we leave the call alone. +const hasAppUuid = (v) => + isPlainObject(v) && Object.prototype.hasOwnProperty.call(v, 'appUuid'); + +// Options-object form: fold the app into the object's own optConfig. A +// caller-supplied appUuid wins over the connected one. +const intoOptions = (options, appUuid) => ({ + ...options, + optConfig: { appUuid, ...(options.optConfig ?? {}) }, +}); + +const appended = (args, appUuid) => + hasAppUuid(args[args.length - 1]) ? args : [...args, { appUuid }]; + +// Methods that take a single options object as an alternative to positional +// arguments. flush() belongs here too: flush({ optConfig }) is its object +// form, and the bare flush() falls through to the trailing-argument path, +// where a lone { appUuid } is read as the optConfig itself. +const objectForm = (fn, appUuid) => (...args) => { + if (args.length === 1 && isPlainObject(args[0])) { + return fn(intoOptions(args[0], appUuid)); + } + return fn(...appended(args, appUuid)); +}; + +// remove()/expire()/expireAt() have no object form — remove() would read the +// object as its key, so never rewrite the first argument. +const trailingOnly = (fn, appUuid) => (...args) => fn(...appended(args, appUuid)); + +const OBJECT_FORM = [ + 'set', 'get', 'del', 'incr', 'decr', 'add', 'update', 'list', 'flush', +]; +const TRAILING_ONLY = ['remove', 'expire', 'expireAt']; + +// Whether the module pre-binds its methods varies by SDK build — in the +// published bundle `get` is a plain class method that reaches for `this`, so +// calling it off the module (as these wrappers do) would throw. Bind it here +// and the difference stops mattering. +function methodOf(kv, name) { + const fn = kv?.[name]; + if (typeof fn !== 'function') { + throw new CLIError(`puter.kv.${name} is not available in this SDK build.`); + } + return fn.bind(kv); +} + +/** + * Wrap the SDK's kv module so every call is scoped to `appUuid`. + * + * @param {object} kv - puter.kv + * @param {string} appUuid - the connected app's uid + * @returns {object} the same method names, app-scoped + */ +export function bindApp(kv, appUuid) { + const bound = {}; + for (const name of OBJECT_FORM) { + bound[name] = objectForm(methodOf(kv, name), appUuid); + } + for (const name of TRAILING_ONLY) { + bound[name] = trailingOnly(methodOf(kv, name), appUuid); + } + // Same invariant the SDK keeps: puter.kv.clear === puter.kv.flush. + bound.clear = bound.flush; + return bound; +} + +// The methods exposed as bare globals in the REPL, in the order `.help` +// lists them. +export const KV_METHODS = [ + 'set', 'get', 'list', 'del', 'incr', 'decr', + 'add', 'remove', 'update', 'flush', 'expire', 'expireAt', +]; diff --git a/src/cli/src/lib/ui.js b/src/cli/src/lib/ui.js index 253ec9bd5..2f6b70108 100644 --- a/src/cli/src/lib/ui.js +++ b/src/cli/src/lib/ui.js @@ -45,6 +45,10 @@ export function bold(s) { return chalk.bold(s); } +export function red(s) { + return chalk.red(s); +} + export function dim(s) { return chalk.dim(s); } diff --git a/src/docs/src/cli.md b/src/docs/src/cli.md index d5c9f9d0a..ed5368d77 100644 --- a/src/docs/src/cli.md +++ b/src/docs/src/cli.md @@ -3,7 +3,7 @@ title: CLI description: Manage your Puter resources directly from your terminal with the Puter CLI. Deploy static sites and serverless workers without leaving your shell. --- -The [Puter CLI](https://www.npmjs.com/package/@heyputer/cli) lets you manage your Puter resources straight from the terminal: deploy static websites, ship serverless workers, and inspect the apps registered to your account, all without leaving your shell. +The [Puter CLI](https://www.npmjs.com/package/@heyputer/cli) lets you manage your Puter resources straight from the terminal: deploy static websites, ship serverless workers, inspect the apps registered to your account, and explore their key-value stores, all without leaving your shell.
The Puter CLI is in beta (0.x). Behavior may change between releases.
@@ -81,6 +81,33 @@ puter app list # list your apps puter app get # show one app's details ``` +## Key-value store + +Open an interactive JavaScript shell against one app's [key-value store](/KV/), so you can read and edit its data directly instead of going through the app. Pass an app name or a uid: + +```sh +puter kv connect my-app +``` + +The CLI resolves the app, checks the store is reachable, and drops you at a prompt: + +```console +$ puter kv connect notes +✔ Connected to notes (app-1f2e3d4c…) · 12 keys +kv(notes)> set("greeting", "hi") +true +kv(notes)> get("greeting") +'hi' +kv(notes)> list("gre", true) +[ { key: 'greeting', value: 'hi' } ] +``` + +Every [`puter.kv`](/KV/) method is bound to the connected app and available bare, as `kv.set(…)`, and as `puter.kv.set(…)`, so examples copied from these docs run as written. Results are awaited for you — `get("k")` prints the value rather than a pending promise — and `_` holds the last result. + +It's a full JavaScript REPL, so multi-line input, variables, and history between sessions all work. `.help` lists the kv methods, `.clear` resets the session, and `.exit` (or Ctrl-D) quits. + +
Writes through puter kv connect go to the connected app's store, not your user-level store — the same data the app itself reads and writes.
+ ## CLI reference ### Global options @@ -184,6 +211,16 @@ Show details for one app. | --- | --- | | `` | The app to inspect. | +### `puter kv connect` + +Open an interactive shell against an app's key-value store. + +| Argument | Description | +| --- | --- | +| `` | The app whose store to connect to, by name or uid (`app-…`). | + +Requires a terminal — in a non-interactive context the command exits with an error rather than hanging. + ## Environment variables | Variable | Description |