mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 08:12:15 +00:00
Add new puter cli (#3199)
* Add new cli * update package lock * fix readme and license
This commit is contained in:
committed by
GitHub
parent
bd1dcde7b0
commit
723608f4db
Generated
+720
-2971
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Puter Technologies Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `@heyputer/cli`
|
||||
|
||||
> **Beta (0.x).** Deploy static sites and serverless workers to Puter from the
|
||||
> terminal.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
npm install @heyputer/cli
|
||||
```
|
||||
|
||||
Requires Node 18+.
|
||||
|
||||
## Authentication
|
||||
|
||||
Log in once via the browser and your token is stored for later commands. For
|
||||
automation, set `PUTER_AUTH_TOKEN` and the CLI skips login entirely.
|
||||
|
||||
```sh
|
||||
puter login # web browser flow (interactive)
|
||||
echo "$TOKEN" | puter login --with-token # token via stdin
|
||||
puter logout
|
||||
puter whoami
|
||||
```
|
||||
|
||||
## Sites
|
||||
|
||||
Deploy a static directory to a `*.puter.site` subdomain, then list, inspect, or
|
||||
remove your sites. Run with no arguments and the CLI prompts for the directory
|
||||
and subdomain.
|
||||
|
||||
```sh
|
||||
puter site deploy [dir] [subdomain] # both positional, both optional
|
||||
puter site list
|
||||
puter site get <subdomain>
|
||||
puter site delete <subdomain> [-y]
|
||||
```
|
||||
|
||||
## Workers
|
||||
|
||||
Deploy a JavaScript file as a serverless worker served at `<name>.puter.work`,
|
||||
then list, inspect, or remove it.
|
||||
|
||||
```sh
|
||||
puter worker deploy [file] [name]
|
||||
puter worker list
|
||||
puter worker get <name>
|
||||
puter worker delete <name> [-y]
|
||||
```
|
||||
|
||||
## Apps (read-only)
|
||||
|
||||
Browse the apps registered on your account. These commands are read-only.
|
||||
|
||||
```sh
|
||||
puter app list
|
||||
puter app get <name>
|
||||
```
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
import { createRequire } from 'node:module';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { action } from '../src/lib/errors.js';
|
||||
import { loginCommand } from '../src/commands/login.js';
|
||||
import { logoutCommand } from '../src/commands/logout.js';
|
||||
import { whoamiCommand } from '../src/commands/whoami.js';
|
||||
import {
|
||||
siteDeploy,
|
||||
siteList,
|
||||
siteGet,
|
||||
siteDelete,
|
||||
} from '../src/commands/site.js';
|
||||
import {
|
||||
workerDeploy,
|
||||
workerList,
|
||||
workerGet,
|
||||
workerDelete,
|
||||
} from '../src/commands/worker.js';
|
||||
import { appList, appGet } from '../src/commands/app.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
|
||||
// error through action()/fail(), so swallow the strays to avoid a hard crash.
|
||||
// Set PUTER_DEBUG=1 to surface them.
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
if (process.env.PUTER_DEBUG) console.error('unhandledRejection:', reason);
|
||||
});
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('puter')
|
||||
.description('CLI for the Puter platform — deploy sites and workers. (beta)')
|
||||
.version(version, '-v, --version');
|
||||
|
||||
// --- auth ------------------------------------------------------------------
|
||||
|
||||
program
|
||||
.command('login')
|
||||
.description('Log in to Puter (web browser, or --with-token)')
|
||||
.option('--with-token', 'read an auth token from stdin')
|
||||
.action(action(loginCommand));
|
||||
|
||||
program
|
||||
.command('logout')
|
||||
.description('Clear the stored auth token')
|
||||
.action(action(logoutCommand));
|
||||
|
||||
program
|
||||
.command('whoami')
|
||||
.description('Show the current account')
|
||||
.action(action(whoamiCommand));
|
||||
|
||||
// --- site ------------------------------------------------------------------
|
||||
|
||||
const site = program.command('site').description('Manage static sites');
|
||||
|
||||
site
|
||||
.command('deploy')
|
||||
.description('Deploy a static directory to <subdomain>.puter.site')
|
||||
.argument('[dir]', 'directory to deploy')
|
||||
.argument('[subdomain]', 'target subdomain')
|
||||
.action(action(siteDeploy));
|
||||
|
||||
site
|
||||
.command('list')
|
||||
.description('List owned subdomains')
|
||||
.action(action(siteList));
|
||||
|
||||
site
|
||||
.command('get')
|
||||
.description('Show details for one subdomain')
|
||||
.argument('<subdomain>')
|
||||
.action(action(siteGet));
|
||||
|
||||
site
|
||||
.command('delete')
|
||||
.description('Remove a subdomain')
|
||||
.argument('<subdomain>')
|
||||
.option('-y, --yes', 'skip confirmation')
|
||||
.action(action(siteDelete));
|
||||
|
||||
// --- worker ----------------------------------------------------------------
|
||||
|
||||
const worker = program.command('worker').description('Manage serverless workers');
|
||||
|
||||
worker
|
||||
.command('deploy')
|
||||
.description('Deploy or replace a serverless worker')
|
||||
.argument('[file]', "worker's JS file")
|
||||
.argument('[name]', 'worker name')
|
||||
.action(action(workerDeploy));
|
||||
|
||||
worker
|
||||
.command('list')
|
||||
.description('List workers')
|
||||
.action(action(workerList));
|
||||
|
||||
worker
|
||||
.command('get')
|
||||
.description('Show details for one worker')
|
||||
.argument('<name>')
|
||||
.action(action(workerGet));
|
||||
|
||||
worker
|
||||
.command('delete')
|
||||
.description('Delete a worker')
|
||||
.argument('<name>')
|
||||
.option('-y, --yes', 'skip confirmation')
|
||||
.action(action(workerDelete));
|
||||
|
||||
// --- app (read-only, beta) -------------------------------------------------
|
||||
|
||||
const app = program.command('app').description('Inspect apps (read-only)');
|
||||
|
||||
app
|
||||
.command('list')
|
||||
.description('List apps')
|
||||
.action(action(appList));
|
||||
|
||||
app
|
||||
.command('get')
|
||||
.description('Show details for one app')
|
||||
.argument('<name>')
|
||||
.action(action(appGet));
|
||||
|
||||
program.parseAsync(process.argv);
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@heyputer/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI for Puter Platform - manage your sites and workers from the terminal.",
|
||||
"license": "MIT",
|
||||
"author": "Puter Technologies Inc.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"puter": "./bin/puter.js"
|
||||
},
|
||||
"main": "bin/puter.js",
|
||||
"files": [
|
||||
"bin",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node ./bin/puter.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@heyputer/puter.js": "latest",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"conf": "^13.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Read-only for beta (spec §7). An "app" is a registered desktop-OS entry on
|
||||
// top of hosting; defining "app deploy" is deferred.
|
||||
|
||||
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()) ?? [];
|
||||
|
||||
if (apps.length === 0) {
|
||||
ui.info('No apps.');
|
||||
return;
|
||||
}
|
||||
for (const a of apps) {
|
||||
const name = a?.name ?? String(a);
|
||||
ui.out(a?.title ? `${name}\t${ui.dim(a.title)}` : name);
|
||||
}
|
||||
}
|
||||
|
||||
export async function appGet(nameArg) {
|
||||
const puter = await ensureClient();
|
||||
let app;
|
||||
try {
|
||||
app = await appsApi(puter).get(nameArg);
|
||||
} catch (err) {
|
||||
throw new CLIError(`Could not fetch '${nameArg}': ${err.message}`);
|
||||
}
|
||||
if (!app) throw new CLIError(`App '${nameArg}' not found.`);
|
||||
|
||||
ui.out(`name: ${app.name ?? nameArg}`);
|
||||
if (app.title) ui.out(`title: ${app.title}`);
|
||||
if (app.index_url || app.url) ui.out(`url: ${app.index_url ?? app.url}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isInteractive, canPrompt } from '../lib/env.js';
|
||||
import { saveToken } from '../lib/config.js';
|
||||
import { makeClient } from '../lib/client.js';
|
||||
import { interactiveLogin, readStdin } from '../lib/auth.js';
|
||||
import { CLIError } from '../lib/errors.js';
|
||||
import * as ui from '../lib/ui.js';
|
||||
|
||||
const BETA_NOTICE =
|
||||
ui.dim('Note: the Puter CLI is in beta (0.x) — behavior may change.');
|
||||
|
||||
export async function loginCommand(opts) {
|
||||
let token;
|
||||
|
||||
if (opts.withToken) {
|
||||
// Token comes from stdin, never argv. If stdin is a TTY there's nothing
|
||||
// piped in and a read would hang — fail fast with guidance instead.
|
||||
if (canPrompt()) {
|
||||
throw new CLIError(
|
||||
'No token piped in.',
|
||||
{ hint: 'Usage: echo $TOKEN | puter login --with-token' },
|
||||
);
|
||||
}
|
||||
token = await readStdin();
|
||||
if (!token) {
|
||||
throw new CLIError('Empty token received on stdin.');
|
||||
}
|
||||
} else if (isInteractive() && canPrompt()) {
|
||||
token = await interactiveLogin();
|
||||
} else {
|
||||
throw new CLIError(
|
||||
'Cannot log in non-interactively without a token.',
|
||||
{ hint: 'Pipe one in: echo $TOKEN | puter login --with-token' },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the token before persisting it.
|
||||
const puter = makeClient(token);
|
||||
let user;
|
||||
try {
|
||||
user = await puter.auth.getUser();
|
||||
} catch (err) {
|
||||
throw new CLIError(`Token rejected: ${err.message}`);
|
||||
}
|
||||
|
||||
saveToken(token);
|
||||
ui.success(`Logged in as ${ui.bold(user?.username ?? 'unknown')}`);
|
||||
ui.status(BETA_NOTICE);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getStoredToken, clearToken } from '../lib/config.js';
|
||||
import * as ui from '../lib/ui.js';
|
||||
|
||||
export async function logoutCommand() {
|
||||
if (!getStoredToken()) {
|
||||
ui.info('No stored login to clear.');
|
||||
return;
|
||||
}
|
||||
clearToken();
|
||||
ui.success('Logged out.');
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as clack from '@clack/prompts';
|
||||
|
||||
import { isInteractive, SITE_DOMAIN } from '../lib/env.js';
|
||||
import { ensureClient } from '../lib/auth.js';
|
||||
import { randName } from '../lib/client.js';
|
||||
import { walk } from '../lib/fswalk.js';
|
||||
import { CLIError } from '../lib/errors.js';
|
||||
import * as ui from '../lib/ui.js';
|
||||
|
||||
// --- subdomain helpers (spec §5.3): liberal in, strict out ----------------
|
||||
|
||||
function normalizeSubdomain(input) {
|
||||
let s = String(input).trim().toLowerCase();
|
||||
// accept a pasted full host like "my-app.puter.site"
|
||||
const suffix = `.${SITE_DOMAIN}`;
|
||||
if (s.endsWith(suffix)) s = s.slice(0, -suffix.length);
|
||||
return s;
|
||||
}
|
||||
|
||||
function subdomainError(s) {
|
||||
if (!s) return 'Subdomain is required.';
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(s)) {
|
||||
return 'Use only lowercase letters, numbers and hyphens (not at the ends).';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function siteUrl(subdomain) {
|
||||
return `https://${subdomain}.${SITE_DOMAIN}`;
|
||||
}
|
||||
|
||||
function bail(value) {
|
||||
if (clack.isCancel(value)) throw new CLIError('Cancelled.');
|
||||
return value;
|
||||
}
|
||||
|
||||
// OS/junk files the platform ignores (uploading only these yields an empty
|
||||
// batch → EMPTY_UPLOAD), so we strip them before uploading.
|
||||
const IGNORED_NAMES = new Set(['.DS_Store', 'Thumbs.db', '.localized']);
|
||||
|
||||
function isIgnored(relPath) {
|
||||
return IGNORED_NAMES.has(relPath.split('/').pop());
|
||||
}
|
||||
|
||||
// Build a File the SDK's upload() accepts, preserving the file's relative path
|
||||
// so nested directories are recreated. The SDK overwrites .filepath/.fullPath
|
||||
// with the basename but reads .finalPath first, so that's where the rel path
|
||||
// has to go.
|
||||
function toUploadFile(buf, relPath) {
|
||||
const file = new File([buf], relPath.split('/').pop());
|
||||
file.finalPath = relPath;
|
||||
return file;
|
||||
}
|
||||
|
||||
// --- deploy (spec §5.5) ---------------------------------------------------
|
||||
|
||||
export async function siteDeploy(dirArg, subArg, opts) {
|
||||
const interactive = isInteractive();
|
||||
|
||||
// Non-interactive: both positionals are required (spec §5.1).
|
||||
if (!interactive && (!dirArg || !subArg)) {
|
||||
throw new CLIError(
|
||||
'Both a directory and subdomain are required in non-interactive mode.',
|
||||
{ hint: 'Usage: puter site deploy <dir> <subdomain>' },
|
||||
);
|
||||
}
|
||||
|
||||
// 1a. Resolve + validate directory (before auth, so the footgun hint shows
|
||||
// even when not logged in; the dir prompt doesn't need a client).
|
||||
let dirInput = dirArg;
|
||||
if (!dirInput && interactive) {
|
||||
dirInput = bail(
|
||||
await clack.text({ message: 'Directory to deploy', initialValue: '.' }),
|
||||
);
|
||||
}
|
||||
const dir = path.resolve(dirInput);
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
// Positional footgun mitigation (spec §5.4): a lone arg is the directory.
|
||||
throw new CLIError(`No such directory '${dirInput}'.`, {
|
||||
hint: `(To deploy the current folder to that subdomain, run: puter site deploy . ${dirInput})`,
|
||||
});
|
||||
}
|
||||
|
||||
const puter = await ensureClient();
|
||||
|
||||
// 1b. Resolve + validate subdomain (re-prompt interactively).
|
||||
let subdomain;
|
||||
if (subArg) {
|
||||
subdomain = normalizeSubdomain(subArg);
|
||||
const err = subdomainError(subdomain);
|
||||
if (err) throw new CLIError(`Invalid subdomain: ${err}`);
|
||||
} else if (interactive) {
|
||||
const suggested = await randName(puter);
|
||||
subdomain = normalizeSubdomain(
|
||||
bail(
|
||||
await clack.text({
|
||||
message: `Subdomain (.${SITE_DOMAIN})`,
|
||||
initialValue: suggested,
|
||||
validate: (v) => subdomainError(normalizeSubdomain(v)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Collect files, dropping OS junk the platform ignores.
|
||||
const files = walk(dir).filter((f) => !isIgnored(f.rel));
|
||||
|
||||
// Empty-directory guard.
|
||||
if (files.length === 0) {
|
||||
if (interactive) {
|
||||
const go = bail(
|
||||
await clack.confirm({
|
||||
message: `'${dirInput}' has no uploadable files. Deploy anyway?`,
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!go) throw new CLIError('Cancelled.');
|
||||
} else {
|
||||
ui.warn(`'${dirInput}' has no uploadable files — deploying anyway.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Echo resolved values (spec §5.2) then act.
|
||||
ui.status(`Deploying ${ui.bold(dirInput)} → ${ui.bold(siteUrl(subdomain).replace('https://', ''))}`);
|
||||
|
||||
ui.debug('resolved dir:', dir);
|
||||
ui.debug('subdomain:', subdomain);
|
||||
ui.debug(`uploading ${files.length} file(s):`);
|
||||
for (const f of files) ui.debug(' -', f.rel);
|
||||
|
||||
// 3. Atomic, versioned folder (spec §5.6). dedupeName and createMissingParents
|
||||
// conflict: createMissingParents gives mkdir `-p` semantics, so when the
|
||||
// `deployment` folder already exists the server returns it as-is instead of
|
||||
// deduping — clobbering the previous version. So we ensure the parent exists
|
||||
// first (tolerating "already exists"), then create the deployment folder with
|
||||
// dedupe only. Each deploy then gets its own auto-numbered folder
|
||||
// (deployment, deployment (1), ...) and older versions are preserved.
|
||||
try {
|
||||
await puter.fs.mkdir(`~/Sites/${subdomain}`, { createMissingParents: true });
|
||||
} catch (err) {
|
||||
// Parent already exists (the common case after the first deploy) — fine.
|
||||
ui.debug('parent mkdir note:', err?.message ?? JSON.stringify(err));
|
||||
}
|
||||
const folder = await puter.fs.mkdir(`~/Sites/${subdomain}/deployment`, {
|
||||
dedupeName: true,
|
||||
});
|
||||
const targetPath = folder?.path ?? folder;
|
||||
ui.debug('mkdir returned:', JSON.stringify(folder));
|
||||
ui.debug('target path:', targetPath);
|
||||
|
||||
// 4. Upload the whole tree in one batch. Each File carries its relative path
|
||||
// (via finalPath) so nested folders are recreated under targetPath;
|
||||
// createMissingParents builds those intermediate folders server-side.
|
||||
const items = files.map((f) => toUploadFile(fs.readFileSync(f.full), f.rel));
|
||||
const sp = ui.spinner(`Uploading ${items.length} file(s)...`);
|
||||
try {
|
||||
await puter.fs.upload(items, targetPath, {
|
||||
overwrite: true,
|
||||
createMissingParents: true,
|
||||
});
|
||||
sp.stop(`Uploaded ${items.length} file(s).`);
|
||||
} catch (err) {
|
||||
sp.stop('Upload failed.');
|
||||
ui.debug('upload error object:', JSON.stringify(err));
|
||||
throw new CLIError(`Upload failed: ${err.message ?? JSON.stringify(err)}`);
|
||||
}
|
||||
|
||||
// 5. Point the subdomain at the new folder.
|
||||
let existing = null;
|
||||
try {
|
||||
existing = await puter.hosting.get(subdomain);
|
||||
} catch {
|
||||
existing = null; // treat "not found" as creatable
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (interactive) {
|
||||
const go = bail(
|
||||
await clack.confirm({
|
||||
message: `Update existing site '${subdomain}' to this deploy?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!go) throw new CLIError('Cancelled.');
|
||||
}
|
||||
await puter.hosting.update(subdomain, targetPath);
|
||||
} else {
|
||||
try {
|
||||
await puter.hosting.create(subdomain, targetPath);
|
||||
} catch (err) {
|
||||
throw new CLIError(
|
||||
`Could not create subdomain '${subdomain}': ${err.message}`,
|
||||
{ hint: 'It may be taken by another account — try a different name.' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ui.success('Deployed.');
|
||||
ui.out(siteUrl(subdomain));
|
||||
}
|
||||
|
||||
// --- list / get / delete --------------------------------------------------
|
||||
|
||||
export async function siteList() {
|
||||
const puter = await ensureClient();
|
||||
const sites = (await puter.hosting.list()) ?? [];
|
||||
|
||||
if (sites.length === 0) {
|
||||
ui.info('No sites yet. Deploy one with: puter site deploy');
|
||||
return;
|
||||
}
|
||||
for (const s of sites) {
|
||||
const sub = s?.subdomain ?? s?.name ?? String(s);
|
||||
ui.out(`${sub}\t${ui.url(siteUrl(sub))}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function siteGet(subdomainArg) {
|
||||
const puter = await ensureClient();
|
||||
const subdomain = normalizeSubdomain(subdomainArg);
|
||||
|
||||
let site;
|
||||
try {
|
||||
site = await puter.hosting.get(subdomain);
|
||||
} catch (err) {
|
||||
throw new CLIError(`Could not fetch '${subdomain}': ${err.message}`);
|
||||
}
|
||||
if (!site) throw new CLIError(`Site '${subdomain}' not found.`);
|
||||
|
||||
ui.out(`subdomain: ${site.subdomain ?? subdomain}`);
|
||||
ui.out(`url: ${siteUrl(site.subdomain ?? subdomain)}`);
|
||||
const root = site.root_dir ?? site.path ?? site.dir_path;
|
||||
if (root) ui.out(`root: ${typeof root === 'object' ? root.path : root}`);
|
||||
}
|
||||
|
||||
export async function siteDelete(subdomainArg, opts) {
|
||||
const puter = await ensureClient();
|
||||
const subdomain = normalizeSubdomain(subdomainArg);
|
||||
|
||||
if (isInteractive() && !opts.yes) {
|
||||
const go = bail(
|
||||
await clack.confirm({
|
||||
message: `Delete site '${subdomain}'? This removes the subdomain.`,
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!go) throw new CLIError('Cancelled.');
|
||||
}
|
||||
|
||||
try {
|
||||
await puter.hosting.delete(subdomain);
|
||||
} catch (err) {
|
||||
throw new CLIError(`Could not delete '${subdomain}': ${err.message}`);
|
||||
}
|
||||
ui.success(`Deleted '${subdomain}'.`);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ensureClient } from '../lib/auth.js';
|
||||
import { CLIError } from '../lib/errors.js';
|
||||
import * as ui from '../lib/ui.js';
|
||||
|
||||
export async function whoamiCommand() {
|
||||
const puter = await ensureClient();
|
||||
let user;
|
||||
try {
|
||||
user = await puter.auth.getUser();
|
||||
} catch (err) {
|
||||
throw new CLIError(`Could not fetch account: ${err.message}`);
|
||||
}
|
||||
|
||||
ui.out(user?.username ?? '(unknown)');
|
||||
if (user?.email) ui.status(ui.dim(user.email));
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as clack from '@clack/prompts';
|
||||
|
||||
import { isInteractive, WORKER_DOMAIN } from '../lib/env.js';
|
||||
import { ensureClient } from '../lib/auth.js';
|
||||
import { randName } from '../lib/client.js';
|
||||
import { CLIError } from '../lib/errors.js';
|
||||
import * as ui from '../lib/ui.js';
|
||||
|
||||
function bail(value) {
|
||||
if (clack.isCancel(value)) throw new CLIError('Cancelled.');
|
||||
return value;
|
||||
}
|
||||
|
||||
// Workers are served at <name>.puter.work (the SDK lowercases the name).
|
||||
function workerUrl(name) {
|
||||
return `https://${String(name).toLowerCase()}.${WORKER_DOMAIN}`;
|
||||
}
|
||||
|
||||
function nameError(s) {
|
||||
if (!s) return 'Name is required.';
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(s)) {
|
||||
return 'Use only letters, numbers and hyphens (not at the ends).';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- deploy (spec §6): get-first, then branch -----------------------------
|
||||
|
||||
export async function workerDeploy(fileArg, nameArg, opts) {
|
||||
const interactive = isInteractive();
|
||||
|
||||
if (!interactive && (!fileArg || !nameArg)) {
|
||||
throw new CLIError(
|
||||
'Both a file and name are required in non-interactive mode.',
|
||||
{ hint: 'Usage: puter worker deploy <file> <name>' },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve + validate entry file before auth (no cwd-equivalent default).
|
||||
let fileInput = fileArg;
|
||||
if (!fileInput && interactive) {
|
||||
fileInput = bail(
|
||||
await clack.text({
|
||||
message: "Worker's JavaScript file",
|
||||
validate: (v) => (v && v.trim() ? undefined : 'A file is required'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const file = path.resolve(fileInput);
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
throw new CLIError(`No such file '${fileInput}'.`);
|
||||
}
|
||||
|
||||
const puter = await ensureClient();
|
||||
|
||||
// Resolve name.
|
||||
let name = nameArg;
|
||||
if (!name && interactive) {
|
||||
const suggested = await randName(puter);
|
||||
name = bail(
|
||||
await clack.text({
|
||||
message: 'Worker name',
|
||||
initialValue: suggested,
|
||||
validate: nameError,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const nameErr = nameError(name);
|
||||
if (nameErr) throw new CLIError(`Invalid name: ${nameErr}`);
|
||||
|
||||
ui.status(`Deploying ${ui.bold(fileInput)} → worker ${ui.bold(name)}`);
|
||||
|
||||
// 1. Does it already exist?
|
||||
let existing = null;
|
||||
try {
|
||||
existing = await puter.workers.get(name);
|
||||
} catch {
|
||||
existing = null;
|
||||
}
|
||||
|
||||
// 2/3. Overwrite the backing file in place; register only if new.
|
||||
const remotePath = `~/Workers/${name}.js`;
|
||||
const code = fs.readFileSync(file);
|
||||
const sp = ui.spinner('Uploading worker...');
|
||||
let created = null;
|
||||
try {
|
||||
await puter.fs.write(remotePath, code, {
|
||||
overwrite: true,
|
||||
createMissingParents: true,
|
||||
});
|
||||
if (!existing) {
|
||||
created = await puter.workers.create(name, remotePath);
|
||||
sp.stop('Worker created.');
|
||||
} else {
|
||||
sp.stop('Worker file updated.');
|
||||
}
|
||||
} catch (err) {
|
||||
sp.stop('Deploy failed.');
|
||||
throw new CLIError(`Deploy failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Beta caveat (spec §6 / §9): no liveness signal to confirm the deploy.
|
||||
ui.warn(
|
||||
'Beta: there is no readiness signal yet, so an effective deploy cannot be confirmed.',
|
||||
);
|
||||
// Prefer the URL the API returns; fall back to the canonical form.
|
||||
ui.out(created?.url ?? existing?.url ?? workerUrl(name));
|
||||
}
|
||||
|
||||
// --- list / get / delete --------------------------------------------------
|
||||
|
||||
export async function workerList() {
|
||||
const puter = await ensureClient();
|
||||
const workers = (await puter.workers.list()) ?? [];
|
||||
|
||||
if (workers.length === 0) {
|
||||
ui.info('No workers yet. Deploy one with: puter worker deploy');
|
||||
return;
|
||||
}
|
||||
for (const w of workers) {
|
||||
const name = w?.name ?? String(w);
|
||||
ui.out(`${name}\t${ui.url(w?.url ?? workerUrl(name))}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function workerGet(nameArg) {
|
||||
const puter = await ensureClient();
|
||||
let worker;
|
||||
try {
|
||||
worker = await puter.workers.get(nameArg);
|
||||
} catch (err) {
|
||||
throw new CLIError(`Could not fetch '${nameArg}': ${err.message}`);
|
||||
}
|
||||
if (!worker) throw new CLIError(`Worker '${nameArg}' not found.`);
|
||||
|
||||
ui.out(`name: ${worker.name ?? nameArg}`);
|
||||
ui.out(`url: ${worker.url ?? workerUrl(worker.name ?? nameArg)}`);
|
||||
if (worker.file_path || worker.path) {
|
||||
ui.out(`file: ${worker.file_path ?? worker.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function workerDelete(nameArg, opts) {
|
||||
const puter = await ensureClient();
|
||||
|
||||
if (isInteractive() && !opts.yes) {
|
||||
const go = bail(
|
||||
await clack.confirm({
|
||||
message: `Delete worker '${nameArg}'?`,
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!go) throw new CLIError('Cancelled.');
|
||||
}
|
||||
|
||||
// Delete the worker first, then its backing file (the order the platform
|
||||
// wants: unregister before removing the file it points at).
|
||||
try {
|
||||
await puter.workers.delete(nameArg);
|
||||
} catch (err) {
|
||||
throw new CLIError(`Could not delete '${nameArg}': ${err.message}`);
|
||||
}
|
||||
|
||||
const remotePath = `~/Workers/${nameArg}.js`;
|
||||
try {
|
||||
await puter.fs.delete(remotePath);
|
||||
} catch (err) {
|
||||
// Worker is already gone; the file may not exist or have a different name.
|
||||
ui.warn(`Worker deleted, but could not remove ${remotePath}: ${err?.message ?? err}`);
|
||||
}
|
||||
|
||||
ui.success(`Deleted worker '${nameArg}'.`);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Authentication flows + the gate every authenticated command goes through.
|
||||
|
||||
import * as clack from '@clack/prompts';
|
||||
import { isInteractive, canPrompt } from './env.js';
|
||||
import { getToken, saveToken } from './config.js';
|
||||
import { makeClient, getAuthTokenViaBrowser } from './client.js';
|
||||
import { CLIError } from './errors.js';
|
||||
|
||||
function bailIfCancelled(value) {
|
||||
if (clack.isCancel(value)) {
|
||||
throw new CLIError('Cancelled.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Read a token from stdin (the `--with-token` model — keeps it out of argv,
|
||||
// shell history and `ps`).
|
||||
export async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf8').trim();
|
||||
}
|
||||
|
||||
// Interactive login. Web-browser flow only — username/password login is no
|
||||
// longer supported by the platform. Returns a token.
|
||||
export async function interactiveLogin() {
|
||||
try {
|
||||
return await getAuthTokenViaBrowser();
|
||||
} catch (err) {
|
||||
throw new CLIError(`Web login failed: ${err.message}`, {
|
||||
hint: 'You can instead run: echo $TOKEN | puter login --with-token',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The gate: resolve a token (env → stored → inline login) and return a client.
|
||||
export async function ensureClient() {
|
||||
let token = getToken();
|
||||
if (!token) {
|
||||
if (isInteractive() && canPrompt()) {
|
||||
const proceed = bailIfCancelled(
|
||||
await clack.confirm({ message: 'Not logged in — log in now?' }),
|
||||
);
|
||||
if (!proceed) {
|
||||
throw new CLIError('Not authenticated.');
|
||||
}
|
||||
token = await interactiveLogin();
|
||||
saveToken(token);
|
||||
} else {
|
||||
throw new CLIError(
|
||||
'Not authenticated.',
|
||||
{ hint: "Set PUTER_AUTH_TOKEN or run 'puter login'." },
|
||||
);
|
||||
}
|
||||
}
|
||||
return makeClient(token);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Puter.js SDK bootstrap for Node.
|
||||
//
|
||||
// The SDK ships a CommonJS entry (`init.cjs`); we're an ESM package, so it's
|
||||
// loaded via createRequire. `init(token)` returns a configured `puter` object
|
||||
// exposing .auth / .fs / .hosting / .workers, etc.
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let sdk;
|
||||
function loadSdk() {
|
||||
if (sdk) return sdk;
|
||||
try {
|
||||
sdk = require('@heyputer/puter.js/src/init.cjs');
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Could not load the Puter.js SDK (@heyputer/puter.js). ` +
|
||||
`Run \`npm install\` in the CLI directory.\n${err.message}`,
|
||||
);
|
||||
}
|
||||
return sdk;
|
||||
}
|
||||
|
||||
// The SDK's Node XMLHttpRequest shim (xhrshim.js) calls
|
||||
// `resp.headers.get('content-type').includes(...)` with no null-guard, which
|
||||
// throws when a response omits Content-Type — exactly what the signed-URL PUT
|
||||
// uploads (the fast, parallel signed-batch-write path) return. Patch the global
|
||||
// Headers.get so a missing content-type reads as '' instead of null; this keeps
|
||||
// the fast upload path working. Idempotent. Remove once the SDK null-guards it.
|
||||
function patchHeadersContentType() {
|
||||
if (typeof Headers === 'undefined') return;
|
||||
if (Headers.prototype.__puterCliContentTypePatched) return;
|
||||
const original = Headers.prototype.get;
|
||||
Headers.prototype.get = function (name) {
|
||||
const value = original.call(this, name);
|
||||
if (value === null && String(name).toLowerCase() === 'content-type') {
|
||||
return '';
|
||||
}
|
||||
return value;
|
||||
};
|
||||
Headers.prototype.__puterCliContentTypePatched = true;
|
||||
}
|
||||
|
||||
// Build an authenticated client from a token.
|
||||
export function makeClient(token) {
|
||||
const { init } = loadSdk();
|
||||
patchHeadersContentType();
|
||||
return init(token);
|
||||
}
|
||||
|
||||
// Browser-based auth flow (used by interactive `login`). Returns a token.
|
||||
export async function getAuthTokenViaBrowser() {
|
||||
const { getAuthToken } = loadSdk();
|
||||
if (typeof getAuthToken !== 'function') {
|
||||
throw new Error('Web login is not available in this SDK build.');
|
||||
}
|
||||
return getAuthToken();
|
||||
}
|
||||
|
||||
// Best-effort random domain-safe name from the SDK, with a local fallback
|
||||
// so prompts still get a sensible pre-fill if randName is unavailable.
|
||||
const ADJ = ['swift', 'brave', 'lucky', 'calm', 'bright', 'bold', 'sunny', 'cosmic'];
|
||||
const NOUN = ['otter', 'falcon', 'maple', 'comet', 'harbor', 'cedar', 'pixel', 'delta'];
|
||||
|
||||
export async function randName(puter) {
|
||||
try {
|
||||
if (puter && typeof puter.randName === 'function') {
|
||||
const n = puter.randName();
|
||||
return n && typeof n.then === 'function' ? await n : n;
|
||||
}
|
||||
} catch {
|
||||
// fall through to local generator
|
||||
}
|
||||
const a = ADJ[Math.floor(Math.random() * ADJ.length)];
|
||||
const n = NOUN[Math.floor(Math.random() * NOUN.length)];
|
||||
const suffix = Math.floor(Math.random() * 9000) + 1000;
|
||||
return `${a}-${n}-${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Token storage (spec §4.3).
|
||||
//
|
||||
// Isolated accessors so logout/multi-account only ever touch this file.
|
||||
// Stored in a future-proof multi-account shape from day one even though
|
||||
// multi-account itself is deferred:
|
||||
//
|
||||
// { "accounts": { "default": { "token": "..." } }, "active": "default" }
|
||||
//
|
||||
// Plaintext-with-permissions (chmod 0600) is the accepted bar for a deploy
|
||||
// CLI; conf's encryptionKey is obfuscation, not security, so we don't use it.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import Conf from 'conf';
|
||||
|
||||
const config = new Conf({ projectName: 'puter-cli' });
|
||||
|
||||
export const configPath = config.path;
|
||||
|
||||
function lockDown() {
|
||||
// conf doesn't restrict permissions by default — owner read/write only.
|
||||
try {
|
||||
fs.chmodSync(config.path, 0o600);
|
||||
} catch {
|
||||
// best effort (e.g. Windows / file not yet flushed)
|
||||
}
|
||||
}
|
||||
|
||||
function activeProfile() {
|
||||
return config.get('active') || 'default';
|
||||
}
|
||||
|
||||
export function saveToken(token) {
|
||||
config.set('accounts.default.token', token);
|
||||
config.set('active', 'default');
|
||||
lockDown();
|
||||
}
|
||||
|
||||
// The stored token only (no env). Used by `whoami`/`logout`-adjacent display.
|
||||
export function getStoredToken() {
|
||||
return config.get(`accounts.${activeProfile()}.token`);
|
||||
}
|
||||
|
||||
// Full resolution order for authenticated commands (spec §4.2):
|
||||
// 1. PUTER_AUTH_TOKEN 2. stored token
|
||||
export function getToken() {
|
||||
return process.env.PUTER_AUTH_TOKEN ?? getStoredToken();
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
config.delete(`accounts.${activeProfile()}.token`);
|
||||
lockDown();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Environment / interactivity detection (spec §3).
|
||||
//
|
||||
// "Interactive" = we may prompt because a human is attached to both ends.
|
||||
// stream.isTTY is `true` on a terminal and `undefined` otherwise, so we coerce
|
||||
// with Boolean(...) rather than comparing === true.
|
||||
|
||||
export function isInteractive() {
|
||||
if (process.env.CI) return false; // respect the CI convention
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
// Whether stdin specifically can be prompted on (the load-bearing check).
|
||||
export function canPrompt() {
|
||||
if (process.env.CI) return false;
|
||||
return Boolean(process.stdin.isTTY);
|
||||
}
|
||||
|
||||
// Spinners / animations / color are gated on stdout only — skip them when
|
||||
// output is piped to a file even if stdin is still a terminal.
|
||||
export function canAnimate() {
|
||||
return Boolean(process.stdout.isTTY) && !process.env.CI;
|
||||
}
|
||||
|
||||
// Default API + hosting endpoints, overridable for self-hosted instances.
|
||||
export const API_ORIGIN =
|
||||
process.env.PUTER_API_ORIGIN || 'https://api.puter.com';
|
||||
export const SITE_DOMAIN =
|
||||
process.env.PUTER_SITE_DOMAIN || 'puter.site';
|
||||
export const WORKER_DOMAIN =
|
||||
process.env.PUTER_WORKER_DOMAIN || 'puter.work';
|
||||
@@ -0,0 +1,64 @@
|
||||
// Uniform error handling. CLIError carries a user-facing message plus an
|
||||
// optional hint and exit code; anything else is treated as an unexpected
|
||||
// failure (still non-zero exit, so automation notices).
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
export class CLIError extends Error {
|
||||
constructor(message, { hint, code = 1 } = {}) {
|
||||
super(message);
|
||||
this.name = 'CLIError';
|
||||
this.hint = hint;
|
||||
this.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
// Puter SDK rejections are often plain objects like { status, message } or
|
||||
// { error: { message } } rather than Error instances — dig out something
|
||||
// human-readable before falling back to a stringified object.
|
||||
export function messageOf(err) {
|
||||
if (!err) return String(err);
|
||||
if (typeof err === 'string') return err;
|
||||
return (
|
||||
err.message ||
|
||||
err.error?.message ||
|
||||
(typeof err.error === 'string' ? err.error : null) ||
|
||||
(() => {
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return String(err);
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
// The Puter SDK holds an open connection that keeps the event loop alive, so
|
||||
// commands won't exit on their own. Force an exit after flushing both streams
|
||||
// (writing '' invokes the callback once pending output has drained), so piped
|
||||
// output is never truncated.
|
||||
export function flushAndExit(code) {
|
||||
let pending = 2;
|
||||
const done = () => {
|
||||
if (--pending === 0) process.exit(code);
|
||||
};
|
||||
process.stdout.write('', done);
|
||||
process.stderr.write('', done);
|
||||
}
|
||||
|
||||
export function fail(err) {
|
||||
const message = messageOf(err);
|
||||
console.error(chalk.red('Error:') + ' ' + message);
|
||||
if (err instanceof CLIError && err.hint) {
|
||||
console.error(chalk.dim(err.hint));
|
||||
}
|
||||
flushAndExit(err instanceof CLIError ? err.exitCode : 1);
|
||||
}
|
||||
|
||||
// Wrap an async commander action so it always exits cleanly: 0 on success,
|
||||
// non-zero (via fail) on error.
|
||||
export function action(fn) {
|
||||
return (...args) => {
|
||||
Promise.resolve(fn(...args)).then(() => flushAndExit(0), fail);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Recursively list regular files in a local directory.
|
||||
// Returns { full, rel } where `rel` is a POSIX path relative to the root,
|
||||
// suitable for building remote Puter paths.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function walk(root) {
|
||||
const files = [];
|
||||
(function recurse(dir, base) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
const rel = base ? `${base}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
recurse(full, rel);
|
||||
} else if (entry.isFile()) {
|
||||
files.push({ full, rel });
|
||||
}
|
||||
// symlinks / sockets / fifos are skipped
|
||||
}
|
||||
})(root, '');
|
||||
return files;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Output helpers.
|
||||
//
|
||||
// Convention: status, progress, prompts and spinners go to STDERR; actual
|
||||
// data (URLs, JSON, list rows) goes to STDOUT. That way `puter site list`
|
||||
// can be piped without status noise contaminating the data stream.
|
||||
|
||||
import chalk from 'chalk';
|
||||
import * as clack from '@clack/prompts';
|
||||
import { canAnimate } from './env.js';
|
||||
|
||||
// Data → stdout
|
||||
export function out(line = '') {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
// Status → stderr
|
||||
export function status(line = '') {
|
||||
console.error(line);
|
||||
}
|
||||
|
||||
export function success(msg) {
|
||||
console.error(chalk.green('✔') + ' ' + msg);
|
||||
}
|
||||
|
||||
export function warn(msg) {
|
||||
console.error(chalk.yellow('!') + ' ' + msg);
|
||||
}
|
||||
|
||||
export function info(msg) {
|
||||
console.error(chalk.dim(msg));
|
||||
}
|
||||
|
||||
// Verbose diagnostics → stderr, only when PUTER_DEBUG is set.
|
||||
export function debug(...args) {
|
||||
if (process.env.PUTER_DEBUG) {
|
||||
console.error(chalk.magenta('[debug]'), ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function url(u) {
|
||||
return chalk.cyan(u);
|
||||
}
|
||||
|
||||
export function bold(s) {
|
||||
return chalk.bold(s);
|
||||
}
|
||||
|
||||
export function dim(s) {
|
||||
return chalk.dim(s);
|
||||
}
|
||||
|
||||
// Spinner that degrades to a one-line stderr message when output isn't a TTY.
|
||||
export function spinner(startText) {
|
||||
if (canAnimate()) {
|
||||
const s = clack.spinner();
|
||||
s.start(startText);
|
||||
return s;
|
||||
}
|
||||
console.error(startText);
|
||||
return {
|
||||
message() {},
|
||||
stop(text) {
|
||||
if (text) console.error(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user