@heyputer/shell 2.1.0 → 3.0.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/src/commons.js CHANGED
@@ -8,11 +8,10 @@ import dotenv from 'dotenv';
8
8
 
9
9
  dotenv.config();
10
10
 
11
- export const PROJECT_NAME = 'puter-cli';
11
+ export const PROJECT_NAME = 'puter-sh';
12
12
  // If you haven't defined your own values in .env file, we'll assume you're running Puter on a local instance:
13
13
  export let API_BASE = process.env.PUTER_API_BASE || 'https://api.puter.com';
14
14
  export let BASE_URL = process.env.PUTER_BASE_URL || 'https://puter.com';
15
- export const NULL_UUID = '00000000-0000-0000-0000-000000000000';
16
15
 
17
16
  export const reconfigureURLs = ({ api, base }) => {
18
17
  API_BASE = api;
@@ -39,64 +38,6 @@ export function getHeaders(contentType = 'application/json') {
39
38
  }
40
39
  }
41
40
 
42
- /**
43
- * Generate a random app name
44
- * @returns a random app name or null if it fails
45
- * @see: [randName](https://github.com/HeyPuter/puter/blob/06a67a3b223a6cbd7ec2e16853b6d2304f621a88/src/puter-js/src/index.js#L389)
46
- */
47
- export function generateAppName(separateWith = '-'){
48
- console.log(chalk.cyan('Generating random name...'));
49
- try {
50
- const first_adj = ['helpful','sensible', 'loyal', 'honest', 'clever', 'capable','calm', 'smart', 'genius', 'bright', 'charming', 'creative', 'diligent', 'elegant', 'fancy',
51
- 'colorful', 'avid', 'active', 'gentle', 'happy', 'intelligent', 'jolly', 'kind', 'lively', 'merry', 'nice', 'optimistic', 'polite',
52
- 'quiet', 'relaxed', 'silly', 'victorious', 'witty', 'young', 'zealous', 'strong', 'brave', 'agile', 'bold'];
53
-
54
- const nouns = ['street', 'roof', 'floor', 'tv', 'idea', 'morning', 'game', 'wheel', 'shoe', 'bag', 'clock', 'pencil', 'pen',
55
- 'magnet', 'chair', 'table', 'house', 'dog', 'room', 'book', 'car', 'cat', 'tree',
56
- 'flower', 'bird', 'fish', 'sun', 'moon', 'star', 'cloud', 'rain', 'snow', 'wind', 'mountain',
57
- 'river', 'lake', 'sea', 'ocean', 'island', 'bridge', 'road', 'train', 'plane', 'ship', 'bicycle',
58
- 'horse', 'elephant', 'lion', 'tiger', 'bear', 'zebra', 'giraffe', 'monkey', 'snake', 'rabbit', 'duck',
59
- 'goose', 'penguin', 'frog', 'crab', 'shrimp', 'whale', 'octopus', 'spider', 'ant', 'bee', 'butterfly', 'dragonfly',
60
- 'ladybug', 'snail', 'camel', 'kangaroo', 'koala', 'panda', 'piglet', 'sheep', 'wolf', 'fox', 'deer', 'mouse', 'seal',
61
- 'chicken', 'cow', 'dinosaur', 'puppy', 'kitten', 'circle', 'square', 'garden', 'otter', 'bunny', 'meerkat', 'harp']
62
-
63
- // return a random combination of first_adj + noun + number (between 0 and 9999)
64
- // e.g. clever-idea-123
65
- const appName = first_adj[Math.floor(Math.random() * first_adj.length)] + separateWith + nouns[Math.floor(Math.random() * nouns.length)] + separateWith + Math.floor(Math.random() * 10000);
66
- console.log(chalk.green(`Name: "${appName}"`));
67
- return appName;
68
- } catch (error) {
69
- console.error(`Error: ${error.message}`);
70
- return null;
71
- }
72
- }
73
-
74
- /**
75
- * Display data in a structured format
76
- * @param {Array} data - The data to display
77
- * @param {Object} options - Display options
78
- * @param {Array} options.headers - Headers for the table
79
- * @param {Array} options.columns - Columns to display
80
- * @param {number} options.columnWidth - Width of each column
81
- */
82
- export function displayTable(data, options = {}) {
83
- const { headers = [], columns = [], columnWidth = 20 } = options;
84
-
85
- // Create the header row
86
- const headerRow = headers.map(header => chalk.cyan(header.padEnd(columnWidth))).join(' | ');
87
- console.log(headerRow);
88
- console.log(chalk.dim('-'.repeat(headerRow.length)));
89
-
90
- // Create and display each row of data
91
- data.forEach(item => {
92
- const row = columns.map(col => {
93
- const value = item[col] || 'N/A';
94
- return value.toString().padEnd(columnWidth);
95
- }).join(' | ');
96
- console.log(row);
97
- });
98
- }
99
-
100
41
  /**
101
42
  * Display structured ouput of disk usage informations
102
43
  */
@@ -114,225 +55,109 @@ export function showDiskSpaceUsage(data) {
114
55
  }
115
56
 
116
57
  /**
117
- * Resolve a relative path to an absolute path
58
+ * The home anchor as typed by the user.
59
+ */
60
+ export const HOME = '~';
61
+
62
+ /**
63
+ * The concrete home directory ("/<username>") for the active profile.
64
+ *
65
+ * Resolved from the token at login and refreshed on every run -- never read
66
+ * from a cached username -- so it always names the account's current home.
67
+ * Null until a profile is selected.
68
+ */
69
+ export let HOME_PATH = null;
70
+
71
+ /**
72
+ * Point the home anchor at the current account's home directory.
73
+ * @param {string} homePath - The concrete home path, e.g. "/alice"
74
+ */
75
+ export const setHomePath = (homePath) => {
76
+ HOME_PATH = homePath;
77
+ };
78
+
79
+ /**
80
+ * Expand a leading "~" to the resolved home directory. Paths that are not
81
+ * home-anchored are returned untouched, as are all paths when no profile is
82
+ * active yet (nothing to expand against).
83
+ * @param {string} p - The path to expand
84
+ * @returns {string} The expanded path
85
+ */
86
+ export function expandHome(p) {
87
+ if (!HOME_PATH || typeof p !== 'string') return p;
88
+ if (p === HOME) return HOME_PATH;
89
+ if (p.startsWith('~/')) return `${HOME_PATH}${p.slice(1)}`;
90
+ return p;
91
+ }
92
+
93
+ /**
94
+ * Check whether a path is already fully-qualified (root- or home-anchored) and
95
+ * therefore must NOT be resolved against the current working directory.
96
+ * @param {string} p - The path to test
97
+ * @returns {boolean} True if the path is absolute or home-anchored
98
+ */
99
+ export function isAbsolutePath(p) {
100
+ if (typeof p !== 'string') return false;
101
+ return p === HOME || p.startsWith('~/') || p.startsWith('/');
102
+ }
103
+
104
+ /**
105
+ * Resolve a path against the current working directory.
106
+ *
107
+ * If `relativePath` is itself absolute ("/...") or home-anchored ("~", "~/..."),
108
+ * it replaces `currentPath` entirely instead of being appended to it.
109
+ *
118
110
  * @param {string} currentPath - The current working directory
119
- * @param {string} relativePath - The relative path to resolve
120
- * @returns {string} The resolved absolute path
111
+ * @param {string} relativePath - The path to resolve
112
+ * @returns {string} The resolved path, preserving a "~" root if present
121
113
  */
122
114
  export function resolvePath(currentPath, relativePath) {
123
- // Normalize the current path (remove trailing slashes)
124
- currentPath = currentPath.replace(/\/+$/, '');
115
+ // A fully-qualified path re-roots the resolution rather than extending it.
116
+ if (isAbsolutePath(relativePath)) {
117
+ currentPath = relativePath.startsWith('~') ? HOME : '/';
118
+ relativePath = relativePath.replace(/^~/, '');
119
+ }
120
+
121
+ // Track whether we are anchored at home so "~" survives normalization.
122
+ const atHome = currentPath === HOME || currentPath.startsWith('~/');
123
+ const root = atHome ? HOME : '';
125
124
 
126
- // Split the relative path into parts
127
- const parts = relativePath.split('/').filter(p => p); // Remove empty parts
125
+ // Strip the root and any trailing slashes, leaving bare segments.
126
+ let parts = currentPath
127
+ .replace(/^~/, '')
128
+ .split('/')
129
+ .filter(p => p);
128
130
 
129
- // Handle each part of the relative path
130
- for (const part of parts) {
131
+ for (const part of relativePath.split('/').filter(p => p)) {
131
132
  if (part === '..') {
132
- // Move one level up
133
- const currentParts = currentPath.split('/').filter(p => p);
134
- if (currentParts.length > 0) {
135
- currentParts.pop(); // Remove the last part
136
- }
137
- currentPath = '/' + currentParts.join('/');
133
+ // Clamp at the root: "~/.." stays at home, "/.." stays at "/".
134
+ parts.pop();
138
135
  } else if (part === '.') {
139
- // Stay in the current directory (no change)
140
136
  continue;
141
137
  } else {
142
- // Move into a subdirectory
143
- currentPath += `/${part}`;
138
+ parts.push(part);
144
139
  }
145
140
  }
146
141
 
147
- // Normalize the final path (remove duplicate slashes)
148
- currentPath = currentPath.replace(/\/+/g, '/');
149
-
150
- // Ensure the path ends with a slash if it's the root
151
- if (currentPath === '') {
152
- currentPath = '/';
153
- }
154
-
155
- return currentPath;
142
+ const joined = parts.join('/');
143
+ // Expand "~" so callers and the API always see a concrete "/<username>" path.
144
+ if (!joined) return expandHome(root || '/');
145
+ return expandHome(`${root}/${joined}`);
156
146
  }
157
147
 
158
148
  /**
159
- * Resolve a remote path to an absolute path, handling both absolute and relative paths.
149
+ * Resolve a remote path to a fully-qualified path.
160
150
  * @param {string} currentPath - The current working directory.
161
151
  * @param {string} remotePath - The remote path to resolve.
162
- * @returns {string} The resolved absolute path.
152
+ * @returns {string} The resolved path.
163
153
  */
164
154
  export function resolveRemotePath(currentPath, remotePath) {
165
- if (remotePath.startsWith('/')) {
166
- return remotePath;
155
+ if (isAbsolutePath(remotePath)) {
156
+ return expandHome(remotePath);
167
157
  }
168
158
  return resolvePath(currentPath, remotePath);
169
159
  }
170
160
 
171
- /**
172
- * Checks if a given string is a valid app name.
173
- * The name must:
174
- * - Not be '.' or '..'
175
- * - Not contain path separators ('/' or '\\')
176
- * - Not contain wildcard characters ('*')
177
- * - (Optional) Contain only allowed characters (letters, numbers, spaces, underscores, hyphens)
178
- *
179
- * @param {string} name - The app name to validate.
180
- * @returns {boolean} - Returns true if valid, false otherwise.
181
- */
182
- export function isValidAppName(name) {
183
- // Ensure the name is a non-empty string
184
- if (typeof name !== 'string' || name.trim().length === 0) {
185
- return false;
186
- }
187
-
188
- // Trim whitespace from both ends
189
- const trimmedName = name.trim();
190
-
191
- // Reject reserved names
192
- if (trimmedName === '.' || trimmedName === '..') {
193
- return false;
194
- }
195
-
196
- // Regex patterns for invalid characters
197
- const invalidPattern = /[\/\\*]/; // Disallow /, \, and *
198
-
199
- if (invalidPattern.test(trimmedName)) {
200
- return false;
201
- }
202
-
203
- // Optional: Define allowed characters pattern
204
- // Uncomment the following lines if you want to enforce allowed characters
205
- /*
206
- const allowedPattern = /^[A-Za-z0-9 _-]+$/;
207
- if (!allowedPattern.test(trimmedName)) {
208
- return false;
209
- }
210
- */
211
-
212
- // All checks passed
213
- return true;
214
- }
215
-
216
- /**
217
- * Generate the default home page for a new web application
218
- * @param {string} appName The name of the web application
219
- * @returns HTML template of the app
220
- */
221
- export function getDefaultHomePage(appName, jsFiles = [], cssFiles= []) {
222
- const defaultIndexContent = `<!DOCTYPE html>
223
- <html lang="en">
224
- <head>
225
- <meta charset="UTF-8">
226
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
227
- <title>${appName}</title>
228
- ${cssFiles.map(css => `<link href="${css}" rel="stylesheet">`).join('\n ')}
229
- <style>
230
- body {
231
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
232
- line-height: 1.6;
233
- max-width: 800px;
234
- margin: 0 auto;
235
- padding: 20px;
236
- background: #f9fafb;
237
- color: #1f2937;
238
- }
239
- .container {
240
- background: white;
241
- padding: 2rem;
242
- border-radius: 8px;
243
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
244
- }
245
- h1 {
246
- color: #2563eb;
247
- margin-bottom: 1rem;
248
- }
249
- .code-block {
250
- background: #f1f5f9;
251
- padding: 1rem;
252
- border-radius: 4px;
253
- font-family: monospace;
254
- overflow-x: auto;
255
- }
256
- .tip {
257
- background: #dbeafe;
258
- border-left: 4px solid #2563eb;
259
- padding: 1rem;
260
- margin: 1rem 0;
261
- }
262
- .links {
263
- display: flex;
264
- gap: 1rem;
265
- margin-top: 2rem;
266
- }
267
- .links a {
268
- color: #2563eb;
269
- text-decoration: none;
270
- }
271
- .links a:hover {
272
- text-decoration: underline;
273
- }
274
- .footer {
275
- text-align: center;
276
- margin-top: 50px;
277
- color: var(--color-grey);
278
- font-size: 0.9rem;
279
- }
280
- </style>
281
- </head>
282
- <body>
283
- <div class="container">
284
- <h1>🚀 Welcome to ${appName}!</h1>
285
-
286
- <p>This is your new website powered by Puter. You can start customizing it right away!</p>
287
-
288
- <div class="tip">
289
- <strong>Quick Tip:</strong> Replace this content with your own by editing the <code>index.html</code> file.
290
- </div>
291
-
292
- <h2>🌟 Getting Started</h2>
293
-
294
- <p>Here's a simple example using Puter.js:</p>
295
-
296
- <div class="code-block">
297
- &lt;script src="https://js.puter.com/v2/">&lt;/script>
298
- &lt;script>
299
- // Create a new file in the cloud
300
- puter.fs.write('hello.txt', 'Hello, Puter!')
301
- .then(file => console.log(\`File created at: \${file.path}\`));
302
- &lt;/script>
303
- </div>
304
-
305
- <h2>💡 Key Features</h2>
306
- <ul>
307
- <li>Cloud Storage</li>
308
- <li>AI Services (GPT-4, DALL-E)</li>
309
- <li>Static Website Hosting</li>
310
- <li>Key-Value Store</li>
311
- <li>Authentication</li>
312
- </ul>
313
-
314
- <div class="links">
315
- <a href="https://docs.puter.com" target="_blank">📚 Documentation</a>
316
- <a href="https://discord.gg/puter" target="_blank">💬 Discord Community</a>
317
- <a href="https://github.com/HeyPuter" target="_blank">👩‍💻 GitHub</a>
318
- </div>
319
- </div>
320
-
321
- <footer class="footer">
322
- &copy; 2025 ${appName}. All rights reserved.
323
- </footer>
324
-
325
- <div id="${(jsFiles.length && jsFiles.some(f => f.includes('react'))) ? 'root' : 'app'}"></div>
326
- ${jsFiles.map(js =>
327
- `<script ${js.endsWith('app.js') ? 'type="text/babel"' : ''} src="${js}"></script>`
328
- ).join('\n ')}
329
- </body>
330
- </html>`;
331
-
332
- return defaultIndexContent;
333
- }
334
-
335
-
336
161
  /**
337
162
  * Read latest package from package file
338
163
  */
package/src/executor.js CHANGED
@@ -1,21 +1,17 @@
1
1
  import chalk from 'chalk';
2
2
  import Conf from 'conf';
3
- import { listApps, appInfo, createApp, updateApp, deleteApp } from './commands/apps.js';
4
- import { listSites, createSite, deleteSite, infoSite } from './commands/sites.js';
5
3
  import {
6
4
  listFiles, makeDirectory, renameFileOrDirectory,
7
5
  removeFileOrDirectory, emptyTrash, changeDirectory, showCwd,
8
6
  getInfo, getDiskUsage, createFile, readFile, uploadFile,
9
7
  downloadFile, copyFile, syncDirectory, editFile
10
8
  } from './commands/files.js';
11
- import { getUserInfo, getUsageInfo, login } from './commands/auth.js';
12
- import { deploy } from './commands/deploy.js';
13
- import { PROJECT_NAME, API_BASE, getHeaders } from './commons.js';
14
- import inquirer from 'inquirer';
9
+ import { getUserInfo, getUsageInfo, login, getCurrentDirectory } from './commands/auth.js';
10
+ import { PROJECT_NAME, API_BASE, HOME, expandHome, getHeaders } from './commons.js';
15
11
  import { exec } from 'node:child_process';
16
- import { parseArgs, getSystemEditor } from './utils.js';
12
+ import { getSystemEditor } from './utils.js';
17
13
  import { rl } from './commands/shell.js';
18
- import { showLast } from './modules/ErrorModule.js'
14
+ import { showLast, report, formatError, isAuthError } from './modules/ErrorModule.js'
19
15
 
20
16
  const config = new Conf({ projectName: PROJECT_NAME });
21
17
 
@@ -27,7 +23,7 @@ const commandHistory = [];
27
23
  * @returns The current prompt
28
24
  */
29
25
  export function getPrompt() {
30
- return chalk.cyan(`puter@${config.get('cwd').slice(1)}> `);
26
+ return chalk.cyan(`puter@${getCurrentDirectory().slice(1)}> `);
31
27
  }
32
28
 
33
29
  const commands = {
@@ -40,12 +36,6 @@ const commands = {
40
36
  login: login,
41
37
  whoami: getUserInfo,
42
38
  stat: getInfo,
43
- apps: async (args) => {
44
- await listApps({
45
- statsPeriod: args[0] || 'all'
46
- });
47
- },
48
- app: appInfo,
49
39
  history: async (args) => {
50
40
  const lineNumber = parseInt(args[0]);
51
41
 
@@ -67,64 +57,6 @@ const commands = {
67
57
  }
68
58
  },
69
59
  'last-error': showLast,
70
- 'app:create': async (rawArgs) => {
71
- try {
72
- const args = parseArgs(rawArgs.join(' '));
73
- // Consider using explicit argument definition if necessary
74
- // const args = parseArgs(rawArgs.join(' '), {string: ['description', 'url'],
75
- // alias: { d: 'description', u: 'url', },
76
- // });
77
-
78
- // NOTE: Keep the check for now at the function level, move the check here so in the future we'll use the function for non-interactive command mode.
79
- await createApp({
80
- name: args._[0],
81
- directory: args._[1] || '',
82
- description: args.description || '',
83
- url: args.url || 'https://dev-center.puter.com/coming-soon.html'
84
- });
85
- } catch (error) {
86
- console.error(chalk.red(error.message));
87
- }
88
- },
89
- 'app:update': async (args) => {
90
- if (args.length < 1) {
91
- console.log(chalk.red('Usage: app:update <name> <remote_dir>'));
92
- return;
93
- }
94
- await updateApp(args);
95
- },
96
- 'app:delete': async (rawArgs) => {
97
- const args = parseArgs(rawArgs.join(' '), {
98
- string: ['_'],
99
- boolean: ['f'],
100
- configuration: {
101
- 'populate--': true
102
- }
103
- });
104
- if (args._.length < 1) {
105
- console.log(chalk.red('You must specify the app name:'));
106
- console.log(chalk.yellow('Example: app:delete <name>'));
107
- return;
108
- }
109
- const name = args._[0];
110
- const force = !!args.f;
111
-
112
- if (!force) {
113
- const { confirm } = await inquirer.prompt([
114
- {
115
- type: 'confirm',
116
- name: 'confirm',
117
- message: chalk.yellow(`Are you sure you want to delete "${name}"?`),
118
- default: false
119
- }
120
- ]);
121
- if (!confirm) {
122
- console.log(chalk.yellow('Operation cancelled.'));
123
- return false;
124
- }
125
- }
126
- await deleteApp(name);
127
- },
128
60
  ls: listFiles,
129
61
  cd: async (args) => {
130
62
  await changeDirectory(args);
@@ -144,11 +76,6 @@ const commands = {
144
76
  pull: downloadFile,
145
77
  update: syncDirectory,
146
78
  edit: editFile,
147
- sites: listSites,
148
- site: infoSite,
149
- 'site:delete': deleteSite,
150
- 'site:create': createSite,
151
- 'site:deploy': deploy,
152
79
  };
153
80
 
154
81
  /**
@@ -190,7 +117,13 @@ export async function execCommand(input) {
190
117
  try {
191
118
  await commands[cmd](args);
192
119
  } catch (error) {
193
- console.error(chalk.red(`Error executing command: ${error.message}`));
120
+ report(error);
121
+ if (isAuthError(error)) {
122
+ console.error(chalk.red('Your session has expired or its token is no longer valid.'));
123
+ console.error(chalk.cyan('Type "login" to sign in again.'));
124
+ } else {
125
+ console.error(chalk.red(`Error executing command: ${formatError(error)}`));
126
+ }
194
127
  }
195
128
  return;
196
129
  }
@@ -238,32 +171,6 @@ function showHelp(command) {
238
171
  ${chalk.cyan('usage')}
239
172
  Show usage information.
240
173
  `,
241
- apps: `
242
- ${chalk.cyan('apps [period]')}
243
- List all your apps.
244
- period: today, yesterday, 7d, 30d, this_month, last_month
245
- Example: apps today
246
- `,
247
- app: `
248
- ${chalk.cyan('app <app_name>')}
249
- Get application information.
250
- Example: app myapp
251
- `,
252
- 'app:create': `
253
- ${chalk.cyan('app:create <name> <remote_dir>')}
254
- Create a new app.
255
- Example: app:create myapp https://myapp.puter.site
256
- `,
257
- 'app:update': `
258
- ${chalk.cyan('app:update <name> [dir]')}
259
- Update an app.
260
- Example: app:update myapp .
261
- `,
262
- 'app:delete': `
263
- ${chalk.cyan('app:delete <name>')}
264
- Delete an app.
265
- Example: app:delete myapp
266
- `,
267
174
  ls: `
268
175
  ${chalk.cyan('ls [dir]')}
269
176
  List files and directories.
@@ -334,30 +241,6 @@ function showHelp(command) {
334
241
 
335
242
  System editor: ${chalk.green(getSystemEditor())}
336
243
  `,
337
- sites: `
338
- ${chalk.cyan('sites')}
339
- List sites and subdomains.
340
- `,
341
- site: `
342
- ${chalk.cyan('site <site_uid>')}
343
- Get site information by UID.
344
- Example: site sd-123456
345
- `,
346
- 'site:delete': `
347
- ${chalk.cyan('site:delete <uid>')}
348
- Delete a site by UID.
349
- Example: site:delete sd-123456
350
- `,
351
- 'site:create': `
352
- ${chalk.cyan('site:create <app_name> [<dir>] [--subdomain=<name>]')}
353
- Create a static website from directory.
354
- Example: site:create mywebsite /path/to/dir --subdomain=mywebsite
355
- `,
356
- 'site:deploy': `
357
- ${chalk.cyan('site:deploy [<remote_dir>] [--subdomain=<subdomain>]')}
358
- Deploy a local web project to Puter.
359
- Example: site:deploy ./my-app --subdomain my-app
360
- `,
361
244
  '!': `
362
245
  ${chalk.cyan('!<command>')}
363
246
  Execute a command on the host machine.
@@ -17,4 +17,65 @@ export const showLast = () => {
17
17
  } else {
18
18
  console.log('No errors to report');
19
19
  }
20
- }
20
+ }
21
+
22
+ /**
23
+ * The Puter API rejects with plain objects rather than Error instances, e.g.
24
+ * `{ status: 401, message: 'Unauthorized' }`. Node renders such a value as the
25
+ * useless "#<Object>", so normalize everything to a real Error before it is
26
+ * shown or rethrown.
27
+ *
28
+ * @param {*} error - Any thrown or rejected value
29
+ * @returns {Error} An Error carrying the original `status`/`code` when present
30
+ */
31
+ export const normalizeError = (error) => {
32
+ if (error instanceof Error) return error;
33
+
34
+ if (error && typeof error === 'object') {
35
+ // Puter nests driver failures one level down as `{ error: { code, message } }`.
36
+ const detail = error.error && typeof error.error === 'object' ? error.error : error;
37
+ const message = detail.message || detail.code || JSON.stringify(error);
38
+ const normalized = new Error(message);
39
+ if (detail.code !== undefined) normalized.code = detail.code;
40
+ if (error.status !== undefined) normalized.status = error.status;
41
+ return normalized;
42
+ }
43
+
44
+ return new Error(String(error ?? 'Unknown error'));
45
+ };
46
+
47
+ // Server codes that mean "this token will not work again", as opposed to a
48
+ // transient failure worth retrying.
49
+ const AUTH_ERROR_CODES = [
50
+ 'token_auth_failed',
51
+ 'token_unsupported',
52
+ 'invalid_token',
53
+ 'auth_failed',
54
+ ];
55
+
56
+ /**
57
+ * Whether an error means the stored token is no longer usable and the user has
58
+ * to log in again.
59
+ *
60
+ * @param {*} error - Any thrown or rejected value
61
+ * @returns {boolean} True if the session is invalid
62
+ */
63
+ export const isAuthError = (error) => {
64
+ const status = error?.status ?? error?.response?.status;
65
+ if (status === 401 || status === 403) return true;
66
+
67
+ const code = String(error?.code ?? error?.error?.code ?? '').toLowerCase();
68
+ if (AUTH_ERROR_CODES.includes(code)) return true;
69
+
70
+ const message = String(error?.message ?? '').toLowerCase();
71
+ return AUTH_ERROR_CODES.some(c => message.includes(c))
72
+ || message.includes('unauthorized')
73
+ || message.includes('invalid token');
74
+ };
75
+
76
+ /**
77
+ * A single-line, human-readable description of any thrown value.
78
+ * @param {*} error - Any thrown or rejected value
79
+ * @returns {string} The message to display
80
+ */
81
+ export const formatError = (error) => normalizeError(error).message;