@heyputer/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Puter Technologies Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # `@heyputer/cli`
2
+
3
+ > **Beta (0.x).** Deploy static sites and serverless workers to Puter from the
4
+ > terminal.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ npm install @heyputer/cli
10
+ ```
11
+
12
+ Requires Node 18+.
13
+
14
+ ## Authentication
15
+
16
+ Log in once via the browser and your token is stored for later commands. For
17
+ automation, set `PUTER_AUTH_TOKEN` and the CLI skips login entirely.
18
+
19
+ ```sh
20
+ puter login # web browser flow (interactive)
21
+ echo "$TOKEN" | puter login --with-token # token via stdin
22
+ puter logout
23
+ puter whoami
24
+ ```
25
+
26
+ ## Sites
27
+
28
+ Deploy a static directory to a `*.puter.site` subdomain, then list, inspect, or
29
+ remove your sites. Run with no arguments and the CLI prompts for the directory
30
+ and subdomain.
31
+
32
+ ```sh
33
+ puter site deploy [dir] [subdomain] # both positional, both optional
34
+ puter site list
35
+ puter site get <subdomain>
36
+ puter site delete <subdomain> [-y]
37
+ ```
38
+
39
+ ## Workers
40
+
41
+ Deploy a JavaScript file as a serverless worker served at `<name>.puter.work`,
42
+ then list, inspect, or remove it.
43
+
44
+ ```sh
45
+ puter worker deploy [file] [name]
46
+ puter worker list
47
+ puter worker get <name>
48
+ puter worker delete <name> [-y]
49
+ ```
50
+
51
+ ## Apps (read-only)
52
+
53
+ Browse the apps registered on your account. These commands are read-only.
54
+
55
+ ```sh
56
+ puter app list
57
+ puter app get <name>
58
+ ```
package/bin/puter.js ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { Command } from 'commander';
4
+
5
+ import { action } from '../src/lib/errors.js';
6
+ import { loginCommand } from '../src/commands/login.js';
7
+ import { logoutCommand } from '../src/commands/logout.js';
8
+ import { whoamiCommand } from '../src/commands/whoami.js';
9
+ import {
10
+ siteDeploy,
11
+ siteList,
12
+ siteGet,
13
+ siteDelete,
14
+ } from '../src/commands/site.js';
15
+ import {
16
+ workerDeploy,
17
+ workerList,
18
+ workerGet,
19
+ workerDelete,
20
+ } from '../src/commands/worker.js';
21
+ import { appList, appGet } from '../src/commands/app.js';
22
+
23
+ // The Puter.js SDK emits duplicate "stray" rejections for failed API calls in
24
+ // addition to rejecting the promise we await. We already route the awaited
25
+ // error through action()/fail(), so swallow the strays to avoid a hard crash.
26
+ // Set PUTER_DEBUG=1 to surface them.
27
+ process.on('unhandledRejection', (reason) => {
28
+ if (process.env.PUTER_DEBUG) console.error('unhandledRejection:', reason);
29
+ });
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const { version } = require('../package.json');
33
+
34
+ const program = new Command();
35
+
36
+ program
37
+ .name('puter')
38
+ .description('CLI for the Puter platform — deploy sites and workers. (beta)')
39
+ .version(version, '-v, --version');
40
+
41
+ // --- auth ------------------------------------------------------------------
42
+
43
+ program
44
+ .command('login')
45
+ .description('Log in to Puter (web browser, or --with-token)')
46
+ .option('--with-token', 'read an auth token from stdin')
47
+ .action(action(loginCommand));
48
+
49
+ program
50
+ .command('logout')
51
+ .description('Clear the stored auth token')
52
+ .action(action(logoutCommand));
53
+
54
+ program
55
+ .command('whoami')
56
+ .description('Show the current account')
57
+ .action(action(whoamiCommand));
58
+
59
+ // --- site ------------------------------------------------------------------
60
+
61
+ const site = program.command('site').description('Manage static sites');
62
+
63
+ site
64
+ .command('deploy')
65
+ .description('Deploy a static directory to <subdomain>.puter.site')
66
+ .argument('[dir]', 'directory to deploy')
67
+ .argument('[subdomain]', 'target subdomain')
68
+ .action(action(siteDeploy));
69
+
70
+ site
71
+ .command('list')
72
+ .description('List owned subdomains')
73
+ .action(action(siteList));
74
+
75
+ site
76
+ .command('get')
77
+ .description('Show details for one subdomain')
78
+ .argument('<subdomain>')
79
+ .action(action(siteGet));
80
+
81
+ site
82
+ .command('delete')
83
+ .description('Remove a subdomain')
84
+ .argument('<subdomain>')
85
+ .option('-y, --yes', 'skip confirmation')
86
+ .action(action(siteDelete));
87
+
88
+ // --- worker ----------------------------------------------------------------
89
+
90
+ const worker = program.command('worker').description('Manage serverless workers');
91
+
92
+ worker
93
+ .command('deploy')
94
+ .description('Deploy or replace a serverless worker')
95
+ .argument('[file]', "worker's JS file")
96
+ .argument('[name]', 'worker name')
97
+ .action(action(workerDeploy));
98
+
99
+ worker
100
+ .command('list')
101
+ .description('List workers')
102
+ .action(action(workerList));
103
+
104
+ worker
105
+ .command('get')
106
+ .description('Show details for one worker')
107
+ .argument('<name>')
108
+ .action(action(workerGet));
109
+
110
+ worker
111
+ .command('delete')
112
+ .description('Delete a worker')
113
+ .argument('<name>')
114
+ .option('-y, --yes', 'skip confirmation')
115
+ .action(action(workerDelete));
116
+
117
+ // --- app (read-only, beta) -------------------------------------------------
118
+
119
+ const app = program.command('app').description('Inspect apps (read-only)');
120
+
121
+ app
122
+ .command('list')
123
+ .description('List apps')
124
+ .action(action(appList));
125
+
126
+ app
127
+ .command('get')
128
+ .description('Show details for one app')
129
+ .argument('<name>')
130
+ .action(action(appGet));
131
+
132
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@heyputer/cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Puter Platform - manage your sites and workers from the terminal.",
5
+ "license": "MIT",
6
+ "author": "Puter Technologies Inc.",
7
+ "type": "module",
8
+ "bin": {
9
+ "puter": "./bin/puter.js"
10
+ },
11
+ "main": "bin/puter.js",
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "scripts": {
21
+ "start": "node ./bin/puter.js",
22
+ "test": "echo \"Error: no test specified\" && exit 1"
23
+ },
24
+ "dependencies": {
25
+ "@clack/prompts": "^0.7.0",
26
+ "@heyputer/puter.js": "latest",
27
+ "chalk": "^5.3.0",
28
+ "commander": "^12.1.0",
29
+ "conf": "^13.0.0"
30
+ }
31
+ }
@@ -0,0 +1,43 @@
1
+ // Read-only for beta (spec §7). An "app" is a registered desktop-OS entry on
2
+ // top of hosting; defining "app deploy" is deferred.
3
+
4
+ import { ensureClient } from '../lib/auth.js';
5
+ import { CLIError } from '../lib/errors.js';
6
+ import * as ui from '../lib/ui.js';
7
+
8
+ function appsApi(puter) {
9
+ const api = puter.apps ?? puter.app;
10
+ if (!api || typeof api.list !== 'function') {
11
+ throw new CLIError('App commands are not available in this SDK build.');
12
+ }
13
+ return api;
14
+ }
15
+
16
+ export async function appList() {
17
+ const puter = await ensureClient();
18
+ const apps = (await appsApi(puter).list()) ?? [];
19
+
20
+ if (apps.length === 0) {
21
+ ui.info('No apps.');
22
+ return;
23
+ }
24
+ for (const a of apps) {
25
+ const name = a?.name ?? String(a);
26
+ ui.out(a?.title ? `${name}\t${ui.dim(a.title)}` : name);
27
+ }
28
+ }
29
+
30
+ export async function appGet(nameArg) {
31
+ const puter = await ensureClient();
32
+ let app;
33
+ try {
34
+ app = await appsApi(puter).get(nameArg);
35
+ } catch (err) {
36
+ throw new CLIError(`Could not fetch '${nameArg}': ${err.message}`);
37
+ }
38
+ if (!app) throw new CLIError(`App '${nameArg}' not found.`);
39
+
40
+ ui.out(`name: ${app.name ?? nameArg}`);
41
+ if (app.title) ui.out(`title: ${app.title}`);
42
+ if (app.index_url || app.url) ui.out(`url: ${app.index_url ?? app.url}`);
43
+ }
@@ -0,0 +1,48 @@
1
+ import { isInteractive, canPrompt } from '../lib/env.js';
2
+ import { saveToken } from '../lib/config.js';
3
+ import { makeClient } from '../lib/client.js';
4
+ import { interactiveLogin, readStdin } from '../lib/auth.js';
5
+ import { CLIError } from '../lib/errors.js';
6
+ import * as ui from '../lib/ui.js';
7
+
8
+ const BETA_NOTICE =
9
+ ui.dim('Note: the Puter CLI is in beta (0.x) — behavior may change.');
10
+
11
+ export async function loginCommand(opts) {
12
+ let token;
13
+
14
+ if (opts.withToken) {
15
+ // Token comes from stdin, never argv. If stdin is a TTY there's nothing
16
+ // piped in and a read would hang — fail fast with guidance instead.
17
+ if (canPrompt()) {
18
+ throw new CLIError(
19
+ 'No token piped in.',
20
+ { hint: 'Usage: echo $TOKEN | puter login --with-token' },
21
+ );
22
+ }
23
+ token = await readStdin();
24
+ if (!token) {
25
+ throw new CLIError('Empty token received on stdin.');
26
+ }
27
+ } else if (isInteractive() && canPrompt()) {
28
+ token = await interactiveLogin();
29
+ } else {
30
+ throw new CLIError(
31
+ 'Cannot log in non-interactively without a token.',
32
+ { hint: 'Pipe one in: echo $TOKEN | puter login --with-token' },
33
+ );
34
+ }
35
+
36
+ // Verify the token before persisting it.
37
+ const puter = makeClient(token);
38
+ let user;
39
+ try {
40
+ user = await puter.auth.getUser();
41
+ } catch (err) {
42
+ throw new CLIError(`Token rejected: ${err.message}`);
43
+ }
44
+
45
+ saveToken(token);
46
+ ui.success(`Logged in as ${ui.bold(user?.username ?? 'unknown')}`);
47
+ ui.status(BETA_NOTICE);
48
+ }
@@ -0,0 +1,11 @@
1
+ import { getStoredToken, clearToken } from '../lib/config.js';
2
+ import * as ui from '../lib/ui.js';
3
+
4
+ export async function logoutCommand() {
5
+ if (!getStoredToken()) {
6
+ ui.info('No stored login to clear.');
7
+ return;
8
+ }
9
+ clearToken();
10
+ ui.success('Logged out.');
11
+ }
@@ -0,0 +1,258 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as clack from '@clack/prompts';
4
+
5
+ import { isInteractive, SITE_DOMAIN } from '../lib/env.js';
6
+ import { ensureClient } from '../lib/auth.js';
7
+ import { randName } from '../lib/client.js';
8
+ import { walk } from '../lib/fswalk.js';
9
+ import { CLIError } from '../lib/errors.js';
10
+ import * as ui from '../lib/ui.js';
11
+
12
+ // --- subdomain helpers (spec §5.3): liberal in, strict out ----------------
13
+
14
+ function normalizeSubdomain(input) {
15
+ let s = String(input).trim().toLowerCase();
16
+ // accept a pasted full host like "my-app.puter.site"
17
+ const suffix = `.${SITE_DOMAIN}`;
18
+ if (s.endsWith(suffix)) s = s.slice(0, -suffix.length);
19
+ return s;
20
+ }
21
+
22
+ function subdomainError(s) {
23
+ if (!s) return 'Subdomain is required.';
24
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(s)) {
25
+ return 'Use only lowercase letters, numbers and hyphens (not at the ends).';
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ function siteUrl(subdomain) {
31
+ return `https://${subdomain}.${SITE_DOMAIN}`;
32
+ }
33
+
34
+ function bail(value) {
35
+ if (clack.isCancel(value)) throw new CLIError('Cancelled.');
36
+ return value;
37
+ }
38
+
39
+ // OS/junk files the platform ignores (uploading only these yields an empty
40
+ // batch → EMPTY_UPLOAD), so we strip them before uploading.
41
+ const IGNORED_NAMES = new Set(['.DS_Store', 'Thumbs.db', '.localized']);
42
+
43
+ function isIgnored(relPath) {
44
+ return IGNORED_NAMES.has(relPath.split('/').pop());
45
+ }
46
+
47
+ // Build a File the SDK's upload() accepts, preserving the file's relative path
48
+ // so nested directories are recreated. The SDK overwrites .filepath/.fullPath
49
+ // with the basename but reads .finalPath first, so that's where the rel path
50
+ // has to go.
51
+ function toUploadFile(buf, relPath) {
52
+ const file = new File([buf], relPath.split('/').pop());
53
+ file.finalPath = relPath;
54
+ return file;
55
+ }
56
+
57
+ // --- deploy (spec §5.5) ---------------------------------------------------
58
+
59
+ export async function siteDeploy(dirArg, subArg, opts) {
60
+ const interactive = isInteractive();
61
+
62
+ // Non-interactive: both positionals are required (spec §5.1).
63
+ if (!interactive && (!dirArg || !subArg)) {
64
+ throw new CLIError(
65
+ 'Both a directory and subdomain are required in non-interactive mode.',
66
+ { hint: 'Usage: puter site deploy <dir> <subdomain>' },
67
+ );
68
+ }
69
+
70
+ // 1a. Resolve + validate directory (before auth, so the footgun hint shows
71
+ // even when not logged in; the dir prompt doesn't need a client).
72
+ let dirInput = dirArg;
73
+ if (!dirInput && interactive) {
74
+ dirInput = bail(
75
+ await clack.text({ message: 'Directory to deploy', initialValue: '.' }),
76
+ );
77
+ }
78
+ const dir = path.resolve(dirInput);
79
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
80
+ // Positional footgun mitigation (spec §5.4): a lone arg is the directory.
81
+ throw new CLIError(`No such directory '${dirInput}'.`, {
82
+ hint: `(To deploy the current folder to that subdomain, run: puter site deploy . ${dirInput})`,
83
+ });
84
+ }
85
+
86
+ const puter = await ensureClient();
87
+
88
+ // 1b. Resolve + validate subdomain (re-prompt interactively).
89
+ let subdomain;
90
+ if (subArg) {
91
+ subdomain = normalizeSubdomain(subArg);
92
+ const err = subdomainError(subdomain);
93
+ if (err) throw new CLIError(`Invalid subdomain: ${err}`);
94
+ } else if (interactive) {
95
+ const suggested = await randName(puter);
96
+ subdomain = normalizeSubdomain(
97
+ bail(
98
+ await clack.text({
99
+ message: `Subdomain (.${SITE_DOMAIN})`,
100
+ initialValue: suggested,
101
+ validate: (v) => subdomainError(normalizeSubdomain(v)),
102
+ }),
103
+ ),
104
+ );
105
+ }
106
+
107
+ // Collect files, dropping OS junk the platform ignores.
108
+ const files = walk(dir).filter((f) => !isIgnored(f.rel));
109
+
110
+ // Empty-directory guard.
111
+ if (files.length === 0) {
112
+ if (interactive) {
113
+ const go = bail(
114
+ await clack.confirm({
115
+ message: `'${dirInput}' has no uploadable files. Deploy anyway?`,
116
+ initialValue: false,
117
+ }),
118
+ );
119
+ if (!go) throw new CLIError('Cancelled.');
120
+ } else {
121
+ ui.warn(`'${dirInput}' has no uploadable files — deploying anyway.`);
122
+ }
123
+ }
124
+
125
+ // 2. Echo resolved values (spec §5.2) then act.
126
+ ui.status(`Deploying ${ui.bold(dirInput)} → ${ui.bold(siteUrl(subdomain).replace('https://', ''))}`);
127
+
128
+ ui.debug('resolved dir:', dir);
129
+ ui.debug('subdomain:', subdomain);
130
+ ui.debug(`uploading ${files.length} file(s):`);
131
+ for (const f of files) ui.debug(' -', f.rel);
132
+
133
+ // 3. Atomic, versioned folder (spec §5.6). dedupeName and createMissingParents
134
+ // conflict: createMissingParents gives mkdir `-p` semantics, so when the
135
+ // `deployment` folder already exists the server returns it as-is instead of
136
+ // deduping — clobbering the previous version. So we ensure the parent exists
137
+ // first (tolerating "already exists"), then create the deployment folder with
138
+ // dedupe only. Each deploy then gets its own auto-numbered folder
139
+ // (deployment, deployment (1), ...) and older versions are preserved.
140
+ try {
141
+ await puter.fs.mkdir(`~/Sites/${subdomain}`, { createMissingParents: true });
142
+ } catch (err) {
143
+ // Parent already exists (the common case after the first deploy) — fine.
144
+ ui.debug('parent mkdir note:', err?.message ?? JSON.stringify(err));
145
+ }
146
+ const folder = await puter.fs.mkdir(`~/Sites/${subdomain}/deployment`, {
147
+ dedupeName: true,
148
+ });
149
+ const targetPath = folder?.path ?? folder;
150
+ ui.debug('mkdir returned:', JSON.stringify(folder));
151
+ ui.debug('target path:', targetPath);
152
+
153
+ // 4. Upload the whole tree in one batch. Each File carries its relative path
154
+ // (via finalPath) so nested folders are recreated under targetPath;
155
+ // createMissingParents builds those intermediate folders server-side.
156
+ const items = files.map((f) => toUploadFile(fs.readFileSync(f.full), f.rel));
157
+ const sp = ui.spinner(`Uploading ${items.length} file(s)...`);
158
+ try {
159
+ await puter.fs.upload(items, targetPath, {
160
+ overwrite: true,
161
+ createMissingParents: true,
162
+ });
163
+ sp.stop(`Uploaded ${items.length} file(s).`);
164
+ } catch (err) {
165
+ sp.stop('Upload failed.');
166
+ ui.debug('upload error object:', JSON.stringify(err));
167
+ throw new CLIError(`Upload failed: ${err.message ?? JSON.stringify(err)}`);
168
+ }
169
+
170
+ // 5. Point the subdomain at the new folder.
171
+ let existing = null;
172
+ try {
173
+ existing = await puter.hosting.get(subdomain);
174
+ } catch {
175
+ existing = null; // treat "not found" as creatable
176
+ }
177
+
178
+ if (existing) {
179
+ if (interactive) {
180
+ const go = bail(
181
+ await clack.confirm({
182
+ message: `Update existing site '${subdomain}' to this deploy?`,
183
+ initialValue: true,
184
+ }),
185
+ );
186
+ if (!go) throw new CLIError('Cancelled.');
187
+ }
188
+ await puter.hosting.update(subdomain, targetPath);
189
+ } else {
190
+ try {
191
+ await puter.hosting.create(subdomain, targetPath);
192
+ } catch (err) {
193
+ throw new CLIError(
194
+ `Could not create subdomain '${subdomain}': ${err.message}`,
195
+ { hint: 'It may be taken by another account — try a different name.' },
196
+ );
197
+ }
198
+ }
199
+
200
+ ui.success('Deployed.');
201
+ ui.out(siteUrl(subdomain));
202
+ }
203
+
204
+ // --- list / get / delete --------------------------------------------------
205
+
206
+ export async function siteList() {
207
+ const puter = await ensureClient();
208
+ const sites = (await puter.hosting.list()) ?? [];
209
+
210
+ if (sites.length === 0) {
211
+ ui.info('No sites yet. Deploy one with: puter site deploy');
212
+ return;
213
+ }
214
+ for (const s of sites) {
215
+ const sub = s?.subdomain ?? s?.name ?? String(s);
216
+ ui.out(`${sub}\t${ui.url(siteUrl(sub))}`);
217
+ }
218
+ }
219
+
220
+ export async function siteGet(subdomainArg) {
221
+ const puter = await ensureClient();
222
+ const subdomain = normalizeSubdomain(subdomainArg);
223
+
224
+ let site;
225
+ try {
226
+ site = await puter.hosting.get(subdomain);
227
+ } catch (err) {
228
+ throw new CLIError(`Could not fetch '${subdomain}': ${err.message}`);
229
+ }
230
+ if (!site) throw new CLIError(`Site '${subdomain}' not found.`);
231
+
232
+ ui.out(`subdomain: ${site.subdomain ?? subdomain}`);
233
+ ui.out(`url: ${siteUrl(site.subdomain ?? subdomain)}`);
234
+ const root = site.root_dir ?? site.path ?? site.dir_path;
235
+ if (root) ui.out(`root: ${typeof root === 'object' ? root.path : root}`);
236
+ }
237
+
238
+ export async function siteDelete(subdomainArg, opts) {
239
+ const puter = await ensureClient();
240
+ const subdomain = normalizeSubdomain(subdomainArg);
241
+
242
+ if (isInteractive() && !opts.yes) {
243
+ const go = bail(
244
+ await clack.confirm({
245
+ message: `Delete site '${subdomain}'? This removes the subdomain.`,
246
+ initialValue: false,
247
+ }),
248
+ );
249
+ if (!go) throw new CLIError('Cancelled.');
250
+ }
251
+
252
+ try {
253
+ await puter.hosting.delete(subdomain);
254
+ } catch (err) {
255
+ throw new CLIError(`Could not delete '${subdomain}': ${err.message}`);
256
+ }
257
+ ui.success(`Deleted '${subdomain}'.`);
258
+ }
@@ -0,0 +1,16 @@
1
+ import { ensureClient } from '../lib/auth.js';
2
+ import { CLIError } from '../lib/errors.js';
3
+ import * as ui from '../lib/ui.js';
4
+
5
+ export async function whoamiCommand() {
6
+ const puter = await ensureClient();
7
+ let user;
8
+ try {
9
+ user = await puter.auth.getUser();
10
+ } catch (err) {
11
+ throw new CLIError(`Could not fetch account: ${err.message}`);
12
+ }
13
+
14
+ ui.out(user?.username ?? '(unknown)');
15
+ if (user?.email) ui.status(ui.dim(user.email));
16
+ }
@@ -0,0 +1,175 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as clack from '@clack/prompts';
4
+
5
+ import { isInteractive, WORKER_DOMAIN } from '../lib/env.js';
6
+ import { ensureClient } from '../lib/auth.js';
7
+ import { randName } from '../lib/client.js';
8
+ import { CLIError } from '../lib/errors.js';
9
+ import * as ui from '../lib/ui.js';
10
+
11
+ function bail(value) {
12
+ if (clack.isCancel(value)) throw new CLIError('Cancelled.');
13
+ return value;
14
+ }
15
+
16
+ // Workers are served at <name>.puter.work (the SDK lowercases the name).
17
+ function workerUrl(name) {
18
+ return `https://${String(name).toLowerCase()}.${WORKER_DOMAIN}`;
19
+ }
20
+
21
+ function nameError(s) {
22
+ if (!s) return 'Name is required.';
23
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(s)) {
24
+ return 'Use only letters, numbers and hyphens (not at the ends).';
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ // --- deploy (spec §6): get-first, then branch -----------------------------
30
+
31
+ export async function workerDeploy(fileArg, nameArg, opts) {
32
+ const interactive = isInteractive();
33
+
34
+ if (!interactive && (!fileArg || !nameArg)) {
35
+ throw new CLIError(
36
+ 'Both a file and name are required in non-interactive mode.',
37
+ { hint: 'Usage: puter worker deploy <file> <name>' },
38
+ );
39
+ }
40
+
41
+ // Resolve + validate entry file before auth (no cwd-equivalent default).
42
+ let fileInput = fileArg;
43
+ if (!fileInput && interactive) {
44
+ fileInput = bail(
45
+ await clack.text({
46
+ message: "Worker's JavaScript file",
47
+ validate: (v) => (v && v.trim() ? undefined : 'A file is required'),
48
+ }),
49
+ );
50
+ }
51
+ const file = path.resolve(fileInput);
52
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
53
+ throw new CLIError(`No such file '${fileInput}'.`);
54
+ }
55
+
56
+ const puter = await ensureClient();
57
+
58
+ // Resolve name.
59
+ let name = nameArg;
60
+ if (!name && interactive) {
61
+ const suggested = await randName(puter);
62
+ name = bail(
63
+ await clack.text({
64
+ message: 'Worker name',
65
+ initialValue: suggested,
66
+ validate: nameError,
67
+ }),
68
+ );
69
+ }
70
+ const nameErr = nameError(name);
71
+ if (nameErr) throw new CLIError(`Invalid name: ${nameErr}`);
72
+
73
+ ui.status(`Deploying ${ui.bold(fileInput)} → worker ${ui.bold(name)}`);
74
+
75
+ // 1. Does it already exist?
76
+ let existing = null;
77
+ try {
78
+ existing = await puter.workers.get(name);
79
+ } catch {
80
+ existing = null;
81
+ }
82
+
83
+ // 2/3. Overwrite the backing file in place; register only if new.
84
+ const remotePath = `~/Workers/${name}.js`;
85
+ const code = fs.readFileSync(file);
86
+ const sp = ui.spinner('Uploading worker...');
87
+ let created = null;
88
+ try {
89
+ await puter.fs.write(remotePath, code, {
90
+ overwrite: true,
91
+ createMissingParents: true,
92
+ });
93
+ if (!existing) {
94
+ created = await puter.workers.create(name, remotePath);
95
+ sp.stop('Worker created.');
96
+ } else {
97
+ sp.stop('Worker file updated.');
98
+ }
99
+ } catch (err) {
100
+ sp.stop('Deploy failed.');
101
+ throw new CLIError(`Deploy failed: ${err.message}`);
102
+ }
103
+
104
+ // Beta caveat (spec §6 / §9): no liveness signal to confirm the deploy.
105
+ ui.warn(
106
+ 'Beta: there is no readiness signal yet, so an effective deploy cannot be confirmed.',
107
+ );
108
+ // Prefer the URL the API returns; fall back to the canonical form.
109
+ ui.out(created?.url ?? existing?.url ?? workerUrl(name));
110
+ }
111
+
112
+ // --- list / get / delete --------------------------------------------------
113
+
114
+ export async function workerList() {
115
+ const puter = await ensureClient();
116
+ const workers = (await puter.workers.list()) ?? [];
117
+
118
+ if (workers.length === 0) {
119
+ ui.info('No workers yet. Deploy one with: puter worker deploy');
120
+ return;
121
+ }
122
+ for (const w of workers) {
123
+ const name = w?.name ?? String(w);
124
+ ui.out(`${name}\t${ui.url(w?.url ?? workerUrl(name))}`);
125
+ }
126
+ }
127
+
128
+ export async function workerGet(nameArg) {
129
+ const puter = await ensureClient();
130
+ let worker;
131
+ try {
132
+ worker = await puter.workers.get(nameArg);
133
+ } catch (err) {
134
+ throw new CLIError(`Could not fetch '${nameArg}': ${err.message}`);
135
+ }
136
+ if (!worker) throw new CLIError(`Worker '${nameArg}' not found.`);
137
+
138
+ ui.out(`name: ${worker.name ?? nameArg}`);
139
+ ui.out(`url: ${worker.url ?? workerUrl(worker.name ?? nameArg)}`);
140
+ if (worker.file_path || worker.path) {
141
+ ui.out(`file: ${worker.file_path ?? worker.path}`);
142
+ }
143
+ }
144
+
145
+ export async function workerDelete(nameArg, opts) {
146
+ const puter = await ensureClient();
147
+
148
+ if (isInteractive() && !opts.yes) {
149
+ const go = bail(
150
+ await clack.confirm({
151
+ message: `Delete worker '${nameArg}'?`,
152
+ initialValue: false,
153
+ }),
154
+ );
155
+ if (!go) throw new CLIError('Cancelled.');
156
+ }
157
+
158
+ // Delete the worker first, then its backing file (the order the platform
159
+ // wants: unregister before removing the file it points at).
160
+ try {
161
+ await puter.workers.delete(nameArg);
162
+ } catch (err) {
163
+ throw new CLIError(`Could not delete '${nameArg}': ${err.message}`);
164
+ }
165
+
166
+ const remotePath = `~/Workers/${nameArg}.js`;
167
+ try {
168
+ await puter.fs.delete(remotePath);
169
+ } catch (err) {
170
+ // Worker is already gone; the file may not exist or have a different name.
171
+ ui.warn(`Worker deleted, but could not remove ${remotePath}: ${err?.message ?? err}`);
172
+ }
173
+
174
+ ui.success(`Deleted worker '${nameArg}'.`);
175
+ }
@@ -0,0 +1,57 @@
1
+ // Authentication flows + the gate every authenticated command goes through.
2
+
3
+ import * as clack from '@clack/prompts';
4
+ import { isInteractive, canPrompt } from './env.js';
5
+ import { getToken, saveToken } from './config.js';
6
+ import { makeClient, getAuthTokenViaBrowser } from './client.js';
7
+ import { CLIError } from './errors.js';
8
+
9
+ function bailIfCancelled(value) {
10
+ if (clack.isCancel(value)) {
11
+ throw new CLIError('Cancelled.');
12
+ }
13
+ return value;
14
+ }
15
+
16
+ // Read a token from stdin (the `--with-token` model — keeps it out of argv,
17
+ // shell history and `ps`).
18
+ export async function readStdin() {
19
+ const chunks = [];
20
+ for await (const chunk of process.stdin) chunks.push(chunk);
21
+ return Buffer.concat(chunks).toString('utf8').trim();
22
+ }
23
+
24
+ // Interactive login. Web-browser flow only — username/password login is no
25
+ // longer supported by the platform. Returns a token.
26
+ export async function interactiveLogin() {
27
+ try {
28
+ return await getAuthTokenViaBrowser();
29
+ } catch (err) {
30
+ throw new CLIError(`Web login failed: ${err.message}`, {
31
+ hint: 'You can instead run: echo $TOKEN | puter login --with-token',
32
+ });
33
+ }
34
+ }
35
+
36
+ // The gate: resolve a token (env → stored → inline login) and return a client.
37
+ export async function ensureClient() {
38
+ let token = getToken();
39
+ if (!token) {
40
+ if (isInteractive() && canPrompt()) {
41
+ const proceed = bailIfCancelled(
42
+ await clack.confirm({ message: 'Not logged in — log in now?' }),
43
+ );
44
+ if (!proceed) {
45
+ throw new CLIError('Not authenticated.');
46
+ }
47
+ token = await interactiveLogin();
48
+ saveToken(token);
49
+ } else {
50
+ throw new CLIError(
51
+ 'Not authenticated.',
52
+ { hint: "Set PUTER_AUTH_TOKEN or run 'puter login'." },
53
+ );
54
+ }
55
+ }
56
+ return makeClient(token);
57
+ }
@@ -0,0 +1,79 @@
1
+ // Puter.js SDK bootstrap for Node.
2
+ //
3
+ // The SDK ships a CommonJS entry (`init.cjs`); we're an ESM package, so it's
4
+ // loaded via createRequire. `init(token)` returns a configured `puter` object
5
+ // exposing .auth / .fs / .hosting / .workers, etc.
6
+
7
+ import { createRequire } from 'node:module';
8
+
9
+ const require = createRequire(import.meta.url);
10
+
11
+ let sdk;
12
+ function loadSdk() {
13
+ if (sdk) return sdk;
14
+ try {
15
+ sdk = require('@heyputer/puter.js/src/init.cjs');
16
+ } catch (err) {
17
+ throw new Error(
18
+ `Could not load the Puter.js SDK (@heyputer/puter.js). ` +
19
+ `Run \`npm install\` in the CLI directory.\n${err.message}`,
20
+ );
21
+ }
22
+ return sdk;
23
+ }
24
+
25
+ // The SDK's Node XMLHttpRequest shim (xhrshim.js) calls
26
+ // `resp.headers.get('content-type').includes(...)` with no null-guard, which
27
+ // throws when a response omits Content-Type — exactly what the signed-URL PUT
28
+ // uploads (the fast, parallel signed-batch-write path) return. Patch the global
29
+ // Headers.get so a missing content-type reads as '' instead of null; this keeps
30
+ // the fast upload path working. Idempotent. Remove once the SDK null-guards it.
31
+ function patchHeadersContentType() {
32
+ if (typeof Headers === 'undefined') return;
33
+ if (Headers.prototype.__puterCliContentTypePatched) return;
34
+ const original = Headers.prototype.get;
35
+ Headers.prototype.get = function (name) {
36
+ const value = original.call(this, name);
37
+ if (value === null && String(name).toLowerCase() === 'content-type') {
38
+ return '';
39
+ }
40
+ return value;
41
+ };
42
+ Headers.prototype.__puterCliContentTypePatched = true;
43
+ }
44
+
45
+ // Build an authenticated client from a token.
46
+ export function makeClient(token) {
47
+ const { init } = loadSdk();
48
+ patchHeadersContentType();
49
+ return init(token);
50
+ }
51
+
52
+ // Browser-based auth flow (used by interactive `login`). Returns a token.
53
+ export async function getAuthTokenViaBrowser() {
54
+ const { getAuthToken } = loadSdk();
55
+ if (typeof getAuthToken !== 'function') {
56
+ throw new Error('Web login is not available in this SDK build.');
57
+ }
58
+ return getAuthToken();
59
+ }
60
+
61
+ // Best-effort random domain-safe name from the SDK, with a local fallback
62
+ // so prompts still get a sensible pre-fill if randName is unavailable.
63
+ const ADJ = ['swift', 'brave', 'lucky', 'calm', 'bright', 'bold', 'sunny', 'cosmic'];
64
+ const NOUN = ['otter', 'falcon', 'maple', 'comet', 'harbor', 'cedar', 'pixel', 'delta'];
65
+
66
+ export async function randName(puter) {
67
+ try {
68
+ if (puter && typeof puter.randName === 'function') {
69
+ const n = puter.randName();
70
+ return n && typeof n.then === 'function' ? await n : n;
71
+ }
72
+ } catch {
73
+ // fall through to local generator
74
+ }
75
+ const a = ADJ[Math.floor(Math.random() * ADJ.length)];
76
+ const n = NOUN[Math.floor(Math.random() * NOUN.length)];
77
+ const suffix = Math.floor(Math.random() * 9000) + 1000;
78
+ return `${a}-${n}-${suffix}`;
79
+ }
@@ -0,0 +1,52 @@
1
+ // Token storage (spec §4.3).
2
+ //
3
+ // Isolated accessors so logout/multi-account only ever touch this file.
4
+ // Stored in a future-proof multi-account shape from day one even though
5
+ // multi-account itself is deferred:
6
+ //
7
+ // { "accounts": { "default": { "token": "..." } }, "active": "default" }
8
+ //
9
+ // Plaintext-with-permissions (chmod 0600) is the accepted bar for a deploy
10
+ // CLI; conf's encryptionKey is obfuscation, not security, so we don't use it.
11
+
12
+ import fs from 'node:fs';
13
+ import Conf from 'conf';
14
+
15
+ const config = new Conf({ projectName: 'puter-cli' });
16
+
17
+ export const configPath = config.path;
18
+
19
+ function lockDown() {
20
+ // conf doesn't restrict permissions by default — owner read/write only.
21
+ try {
22
+ fs.chmodSync(config.path, 0o600);
23
+ } catch {
24
+ // best effort (e.g. Windows / file not yet flushed)
25
+ }
26
+ }
27
+
28
+ function activeProfile() {
29
+ return config.get('active') || 'default';
30
+ }
31
+
32
+ export function saveToken(token) {
33
+ config.set('accounts.default.token', token);
34
+ config.set('active', 'default');
35
+ lockDown();
36
+ }
37
+
38
+ // The stored token only (no env). Used by `whoami`/`logout`-adjacent display.
39
+ export function getStoredToken() {
40
+ return config.get(`accounts.${activeProfile()}.token`);
41
+ }
42
+
43
+ // Full resolution order for authenticated commands (spec §4.2):
44
+ // 1. PUTER_AUTH_TOKEN 2. stored token
45
+ export function getToken() {
46
+ return process.env.PUTER_AUTH_TOKEN ?? getStoredToken();
47
+ }
48
+
49
+ export function clearToken() {
50
+ config.delete(`accounts.${activeProfile()}.token`);
51
+ lockDown();
52
+ }
package/src/lib/env.js ADDED
@@ -0,0 +1,30 @@
1
+ // Environment / interactivity detection (spec §3).
2
+ //
3
+ // "Interactive" = we may prompt because a human is attached to both ends.
4
+ // stream.isTTY is `true` on a terminal and `undefined` otherwise, so we coerce
5
+ // with Boolean(...) rather than comparing === true.
6
+
7
+ export function isInteractive() {
8
+ if (process.env.CI) return false; // respect the CI convention
9
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
10
+ }
11
+
12
+ // Whether stdin specifically can be prompted on (the load-bearing check).
13
+ export function canPrompt() {
14
+ if (process.env.CI) return false;
15
+ return Boolean(process.stdin.isTTY);
16
+ }
17
+
18
+ // Spinners / animations / color are gated on stdout only — skip them when
19
+ // output is piped to a file even if stdin is still a terminal.
20
+ export function canAnimate() {
21
+ return Boolean(process.stdout.isTTY) && !process.env.CI;
22
+ }
23
+
24
+ // Default API + hosting endpoints, overridable for self-hosted instances.
25
+ export const API_ORIGIN =
26
+ process.env.PUTER_API_ORIGIN || 'https://api.puter.com';
27
+ export const SITE_DOMAIN =
28
+ process.env.PUTER_SITE_DOMAIN || 'puter.site';
29
+ export const WORKER_DOMAIN =
30
+ process.env.PUTER_WORKER_DOMAIN || 'puter.work';
@@ -0,0 +1,64 @@
1
+ // Uniform error handling. CLIError carries a user-facing message plus an
2
+ // optional hint and exit code; anything else is treated as an unexpected
3
+ // failure (still non-zero exit, so automation notices).
4
+
5
+ import chalk from 'chalk';
6
+
7
+ export class CLIError extends Error {
8
+ constructor(message, { hint, code = 1 } = {}) {
9
+ super(message);
10
+ this.name = 'CLIError';
11
+ this.hint = hint;
12
+ this.exitCode = code;
13
+ }
14
+ }
15
+
16
+ // Puter SDK rejections are often plain objects like { status, message } or
17
+ // { error: { message } } rather than Error instances — dig out something
18
+ // human-readable before falling back to a stringified object.
19
+ export function messageOf(err) {
20
+ if (!err) return String(err);
21
+ if (typeof err === 'string') return err;
22
+ return (
23
+ err.message ||
24
+ err.error?.message ||
25
+ (typeof err.error === 'string' ? err.error : null) ||
26
+ (() => {
27
+ try {
28
+ return JSON.stringify(err);
29
+ } catch {
30
+ return String(err);
31
+ }
32
+ })()
33
+ );
34
+ }
35
+
36
+ // The Puter SDK holds an open connection that keeps the event loop alive, so
37
+ // commands won't exit on their own. Force an exit after flushing both streams
38
+ // (writing '' invokes the callback once pending output has drained), so piped
39
+ // output is never truncated.
40
+ export function flushAndExit(code) {
41
+ let pending = 2;
42
+ const done = () => {
43
+ if (--pending === 0) process.exit(code);
44
+ };
45
+ process.stdout.write('', done);
46
+ process.stderr.write('', done);
47
+ }
48
+
49
+ export function fail(err) {
50
+ const message = messageOf(err);
51
+ console.error(chalk.red('Error:') + ' ' + message);
52
+ if (err instanceof CLIError && err.hint) {
53
+ console.error(chalk.dim(err.hint));
54
+ }
55
+ flushAndExit(err instanceof CLIError ? err.exitCode : 1);
56
+ }
57
+
58
+ // Wrap an async commander action so it always exits cleanly: 0 on success,
59
+ // non-zero (via fail) on error.
60
+ export function action(fn) {
61
+ return (...args) => {
62
+ Promise.resolve(fn(...args)).then(() => flushAndExit(0), fail);
63
+ };
64
+ }
@@ -0,0 +1,23 @@
1
+ // Recursively list regular files in a local directory.
2
+ // Returns { full, rel } where `rel` is a POSIX path relative to the root,
3
+ // suitable for building remote Puter paths.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export function walk(root) {
9
+ const files = [];
10
+ (function recurse(dir, base) {
11
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
12
+ const full = path.join(dir, entry.name);
13
+ const rel = base ? `${base}/${entry.name}` : entry.name;
14
+ if (entry.isDirectory()) {
15
+ recurse(full, rel);
16
+ } else if (entry.isFile()) {
17
+ files.push({ full, rel });
18
+ }
19
+ // symlinks / sockets / fifos are skipped
20
+ }
21
+ })(root, '');
22
+ return files;
23
+ }
package/src/lib/ui.js ADDED
@@ -0,0 +1,66 @@
1
+ // Output helpers.
2
+ //
3
+ // Convention: status, progress, prompts and spinners go to STDERR; actual
4
+ // data (URLs, JSON, list rows) goes to STDOUT. That way `puter site list`
5
+ // can be piped without status noise contaminating the data stream.
6
+
7
+ import chalk from 'chalk';
8
+ import * as clack from '@clack/prompts';
9
+ import { canAnimate } from './env.js';
10
+
11
+ // Data → stdout
12
+ export function out(line = '') {
13
+ console.log(line);
14
+ }
15
+
16
+ // Status → stderr
17
+ export function status(line = '') {
18
+ console.error(line);
19
+ }
20
+
21
+ export function success(msg) {
22
+ console.error(chalk.green('✔') + ' ' + msg);
23
+ }
24
+
25
+ export function warn(msg) {
26
+ console.error(chalk.yellow('!') + ' ' + msg);
27
+ }
28
+
29
+ export function info(msg) {
30
+ console.error(chalk.dim(msg));
31
+ }
32
+
33
+ // Verbose diagnostics → stderr, only when PUTER_DEBUG is set.
34
+ export function debug(...args) {
35
+ if (process.env.PUTER_DEBUG) {
36
+ console.error(chalk.magenta('[debug]'), ...args);
37
+ }
38
+ }
39
+
40
+ export function url(u) {
41
+ return chalk.cyan(u);
42
+ }
43
+
44
+ export function bold(s) {
45
+ return chalk.bold(s);
46
+ }
47
+
48
+ export function dim(s) {
49
+ return chalk.dim(s);
50
+ }
51
+
52
+ // Spinner that degrades to a one-line stderr message when output isn't a TTY.
53
+ export function spinner(startText) {
54
+ if (canAnimate()) {
55
+ const s = clack.spinner();
56
+ s.start(startText);
57
+ return s;
58
+ }
59
+ console.error(startText);
60
+ return {
61
+ message() {},
62
+ stop(text) {
63
+ if (text) console.error(text);
64
+ },
65
+ };
66
+ }