[PUT-1478] Add KV commands to CLI (#3579)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* Add KV commands to CLI

* docs

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Reynaldi Chernando
2026-08-15 11:43:30 +07:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent f15d835eeb
commit 9e25ce1401
9 changed files with 359 additions and 14 deletions
+30 -2
View File
@@ -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 <name>
```
## 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 <app>
```
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.
+12 -1
View File
@@ -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('<name>')
.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>', 'app name or uid')
.action(action(kvConnect));
program.parseAsync(process.argv);
+2 -2
View File
@@ -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",
+1 -8
View File
@@ -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()) ?? [];
+163
View File
@@ -0,0 +1,163 @@
// `puter kv connect <app>` — 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 { <pending> }`, 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})> ` });
}
+12
View File
@@ -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;
}
+97
View File
@@ -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',
];
+4
View File
@@ -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);
}
+38 -1
View File
@@ -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.
<div class="info">The Puter CLI is in beta (0.x). Behavior may change between releases.</div>
@@ -81,6 +81,33 @@ puter app list # list your apps
puter app get <name> # 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.
<div class="info">Writes through <code>puter kv connect</code> go to the connected app's store, not your user-level store — the same data the app itself reads and writes.</div>
## CLI reference
### Global options
@@ -184,6 +211,16 @@ Show details for one app.
| --- | --- |
| `<name>` | The app to inspect. |
### `puter kv connect`
Open an interactive shell against an app's key-value store.
| Argument | Description |
| --- | --- |
| `<app>` | 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 |