mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 23:46:51 +00:00
[PUT-1478] Add KV commands to CLI (#3579)
* 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:
co-authored by
Copilot Autofix powered by AI
parent
f15d835eeb
commit
9e25ce1401
+30
-2
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,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()) ?? [];
|
||||
|
||||
@@ -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})> ` });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user