puter-cli 1.8.0 → 1.8.2

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.
@@ -15,14 +15,14 @@ jobs:
15
15
  - name: Install pnpm
16
16
  uses: pnpm/action-setup@v4
17
17
  with:
18
- version: 10
18
+ version: 8
19
19
  - name: Use Node.js ${{ matrix.node-version }}
20
20
  uses: actions/setup-node@v4
21
21
  with:
22
22
  node-version: ${{ matrix.node-version }}
23
23
  cache: 'pnpm'
24
24
  - name: Install dependencies
25
- run: pnpm install
25
+ run: pnpm install --frozen-lockfile
26
26
  - name: Run tests & coverage
27
27
  run: pnpm run coverage
28
28
  - name: Upload coverage reports to Codecov
@@ -3,29 +3,48 @@
3
3
 
4
4
  name: Publish package
5
5
 
6
- on: push
6
+ on:
7
+ push:
8
+ tags:
9
+ - 'v*.*.*'
7
10
 
8
11
  jobs:
9
12
  build:
10
13
  runs-on: ubuntu-latest
11
14
  steps:
12
15
  - uses: actions/checkout@v4
13
- - uses: actions/setup-node@v4
16
+ - name: Install pnpm
17
+ uses: pnpm/action-setup@v4
18
+ with:
19
+ version: 8
20
+ - name: Use Node.js 20
21
+ uses: actions/setup-node@v4
14
22
  with:
15
23
  node-version: 20
16
- - run: npm ci
17
- - run: npm test
24
+ cache: 'pnpm'
25
+ - name: Install dependencies
26
+ run: pnpm install --frozen-lockfile
27
+ - name: Run tests
28
+ run: pnpm test
18
29
 
19
30
  publish-npm:
20
31
  needs: build
21
32
  runs-on: ubuntu-latest
22
33
  steps:
23
34
  - uses: actions/checkout@v4
24
- - uses: actions/setup-node@v4
35
+ - name: Install pnpm
36
+ uses: pnpm/action-setup@v4
37
+ with:
38
+ version: 8
39
+ - name: Use Node.js 20
40
+ uses: actions/setup-node@v4
25
41
  with:
26
42
  node-version: 20
27
43
  registry-url: https://registry.npmjs.org/
28
- - run: npm ci
29
- - run: npm publish
44
+ cache: 'pnpm'
45
+ - name: Install dependencies
46
+ run: pnpm install --frozen-lockfile
47
+ - name: Publish to npm
48
+ run: pnpm publish --no-git-checks
30
49
  env:
31
- NODE_AUTH_TOKEN: ${{secrets.npm_token}}
50
+ NODE_AUTH_TOKEN: ${{secrets.npm_token}}
package/CHANGELOG.md CHANGED
@@ -4,8 +4,22 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v1.8.2](https://github.com/HeyPuter/puter-cli/compare/v1.8.1...v1.8.2)
8
+
9
+ - feat: support multiple user profiles and hosts [`#12`](https://github.com/HeyPuter/puter-cli/pull/12)
10
+ - feat: merge profile feature and fix tests [`f234bc2`](https://github.com/HeyPuter/puter-cli/commit/f234bc2704047f39d2899962cd2aaa63df8331ce)
11
+ - ci(publish): trigger publish workflow on version tags [`61138c8`](https://github.com/HeyPuter/puter-cli/commit/61138c828be3856cd60e7561496f963e9f0ca1be)
12
+
13
+ #### [v1.8.1](https://github.com/HeyPuter/puter-cli/compare/v1.8.0...v1.8.1)
14
+
15
+ > 16 April 2025
16
+
17
+ - fix: better version status handling and error resilience [`ab467e8`](https://github.com/HeyPuter/puter-cli/commit/ab467e8e57cb4f82619424b12136724904df0302)
18
+
7
19
  #### [v1.8.0](https://github.com/HeyPuter/puter-cli/compare/v1.7.3...v1.8.0)
8
20
 
21
+ > 2 April 2025
22
+
9
23
  - feat(files): add edit command to modify remote files with local editor [`220b07c`](https://github.com/HeyPuter/puter-cli/commit/220b07c79fa9e9ab2e0e668cfbd7e5260c9746a2)
10
24
 
11
25
  #### [v1.7.3](https://github.com/HeyPuter/puter-cli/compare/v1.7.2...v1.7.3)
package/README.md CHANGED
@@ -135,7 +135,7 @@ P.S. These commands consider the current directory as the base path for every op
135
135
  ```
136
136
  P.S. The `--delete` flag removes files in the remote directory that don't exist locally. The `-r` flag enables recursive synchronization of subdirectories.
137
137
 
138
- **Edit a file**: Edit remote text files using your preferred local text editor.
138
+ - **Edit a file**: Edit remote text files using your preferred local text editor.
139
139
  ```bash
140
140
  puter> edit <file>
141
141
  ```
package/bin/index.js CHANGED
@@ -20,7 +20,9 @@ async function main() {
20
20
  .command('login')
21
21
  .description('Login to Puter account')
22
22
  .option('-s, --save', 'Save authentication token in .env file', '')
23
- .action(login);
23
+ .action(() => {
24
+ startShell('login');
25
+ });
24
26
 
25
27
  program
26
28
  .command('logout')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "puter-cli",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "Command line interface for Puter cloud platform",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -5,109 +5,17 @@ import Conf from 'conf';
5
5
  import ora from 'ora';
6
6
  import fetch from 'node-fetch';
7
7
  import { PROJECT_NAME, API_BASE, getHeaders, BASE_URL } from '../commons.js'
8
+ import { ProfileAPI } from '../modules/ProfileModule.js';
9
+ import { get_context } from '../temporary/context_helpers.js';
8
10
  const config = new Conf({ projectName: PROJECT_NAME });
9
11
 
10
12
  /**
11
13
  * Login user
12
14
  * @returns void
13
15
  */
14
- export async function login(args = {}) {
15
- const answers = await inquirer.prompt([
16
- {
17
- type: 'input',
18
- name: 'username',
19
- message: 'Username:',
20
- validate: input => input.length >= 1 || 'Username is required'
21
- },
22
- {
23
- type: 'password',
24
- name: 'password',
25
- message: 'Password:',
26
- mask: '*',
27
- validate: input => input.length >= 1 || 'Password is required'
28
- }
29
- ]);
30
-
31
- let spinner;
32
- try {
33
- spinner = ora('Logging in to Puter...').start();
34
-
35
- const response = await fetch(`${BASE_URL}/login`, {
36
- method: 'POST',
37
- headers: getHeaders(),
38
- body: JSON.stringify({
39
- username: answers.username,
40
- password: answers.password
41
- })
42
- });
43
-
44
- let data = await response.json();
45
-
46
- while ( data.proceed && data.next_step ) {
47
- if ( data.next_step === 'otp') {
48
- spinner.succeed(chalk.green('2FA is enabled'));
49
- const answers = await inquirer.prompt([
50
- {
51
- type: 'input',
52
- name: 'otp',
53
- message: 'Authenticator Code:',
54
- validate: input => input.length === 6 || 'OTP must be 6 digits'
55
- }
56
- ]);
57
- spinner = ora('Logging in to Puter...').start();
58
- const response = await fetch(`${BASE_URL}/login/otp`, {
59
- method: 'POST',
60
- headers: getHeaders(),
61
- body: JSON.stringify({
62
- token: data.otp_jwt_token,
63
- code: answers.otp,
64
- }),
65
- });
66
- data = await response.json();
67
- continue;
68
- }
69
-
70
- if ( data.next_step === 'complete' ) break;
71
-
72
- spinner.fail(chalk.red(`Unrecognized login step "${data.next_step}"; you might need to update puter-cli.`));
73
- return;
74
- }
75
-
76
- if (data.proceed && data.token) {
77
- config.set('auth_token', data.token);
78
- config.set('username', answers.username);
79
- config.set('cwd', `/${answers.username}`);
80
- if (spinner){
81
- spinner.succeed(chalk.green('Successfully logged in to Puter!'));
82
- }
83
- console.log(chalk.dim(`Token: ${data.token.slice(0, 5)}...${data.token.slice(-5)}`));
84
- // Save token
85
- if (args.save){
86
- const localEnvFile = '.env';
87
- try {
88
- // Check if the file exists, if so then delete it before writing.
89
- if (fs.existsSync(localEnvFile)) {
90
- console.log(chalk.yellow(`File "${localEnvFile}" already exists... Adding token.`));
91
- fs.appendFileSync(localEnvFile, `\nPUTER_API_KEY="${data.token}"`, 'utf8');
92
- } else {
93
- console.log(chalk.cyan(`Saving token to ${chalk.green(localEnvFile)} file.`));
94
- fs.writeFileSync(localEnvFile, `PUTER_API_KEY="${data.token}"`, 'utf8');
95
- }
96
- } catch (error) {
97
- console.error(chalk.red(`Cannot save token to .env file. Error: ${error.message}`));
98
- console.log(chalk.cyan(`PUTER_API_KEY="${data.token}"`));
99
- }
100
- }
101
- } else {
102
- spinner.fail(chalk.red('Login failed. Please check your credentials.'));
103
- }
104
- } catch (error) {
105
- if (spinner) {
106
- spinner.fail(chalk.red('Failed to login'));
107
- } else {
108
- console.error(chalk.red(`Failed to login: ${error.message}`));
109
- }
110
- }
16
+ export async function login(args = {}, context) {
17
+ const profileAPI = context[ProfileAPI];
18
+ await profileAPI.switchProfileWizard();
111
19
  }
112
20
 
113
21
  /**
@@ -164,6 +72,7 @@ export async function getUserInfo() {
164
72
  }
165
73
  } catch (error) {
166
74
  console.error(chalk.red(`Failed to get user info.\nError: ${error.message}`));
75
+ console.log(error);
167
76
  }
168
77
  }
169
78
  export function isAuthenticated() {
@@ -171,11 +80,15 @@ export function isAuthenticated() {
171
80
  }
172
81
 
173
82
  export function getAuthToken() {
174
- return config.get('auth_token');
83
+ const context = get_context();
84
+ const profileAPI = context[ProfileAPI];
85
+ return profileAPI.getAuthToken();
175
86
  }
176
87
 
177
88
  export function getCurrentUserName() {
178
- return config.get('username');
89
+ const context = get_context();
90
+ const profileAPI = context[ProfileAPI];
91
+ return profileAPI.getCurrentProfile()?.username;
179
92
  }
180
93
 
181
94
  export function getCurrentDirectory() {
@@ -588,6 +588,7 @@ export async function pathExists(filePath) {
588
588
  return statResponse.ok;
589
589
  } catch (error){
590
590
  console.error(chalk.red('Failed to check if file exists.'));
591
+ console.error('ERROR', error);
591
592
  return false;
592
593
  }
593
594
  }
@@ -2,9 +2,10 @@ import readline from 'node:readline';
2
2
  import chalk from 'chalk';
3
3
  import Conf from 'conf';
4
4
  import { execCommand, getPrompt } from '../executor.js';
5
- import { getAuthToken, login } from './auth.js';
6
5
  import { PROJECT_NAME } from '../commons.js';
6
+ import SetContextModule from '../modules/SetContextModule.js';
7
7
  import ErrorModule from '../modules/ErrorModule.js';
8
+ import ProfileModule from '../modules/ProfileModule.js';
8
9
  import putility from '@heyputer/putility';
9
10
 
10
11
  const config = new Conf({ projectName: PROJECT_NAME });
@@ -26,16 +27,11 @@ export function updatePrompt(currentPath) {
26
27
  /**
27
28
  * Start the interactive shell
28
29
  */
29
- export async function startShell() {
30
- if (!getAuthToken()) {
31
- console.log(chalk.cyan('Please login first (or use CTRL+C to exit):'));
32
- await login();
33
- console.log(chalk.green(`Now just type: ${chalk.cyan('puter')} to begin.`));
34
- process.exit(0);
35
- }
36
-
30
+ export async function startShell(command) {
37
31
  const modules = [
32
+ SetContextModule,
38
33
  ErrorModule,
34
+ ProfileModule,
39
35
  ];
40
36
 
41
37
  const context = new putility.libs.context.Context({
@@ -44,6 +40,14 @@ export async function startShell() {
44
40
 
45
41
  for ( const module of modules ) module({ context });
46
42
 
43
+ await context.events.emit('check-login', {});
44
+
45
+ // This argument enables the `puter <subcommand>` commands
46
+ if ( command ) {
47
+ await execCommand(context, command);
48
+ process.exit(0);
49
+ }
50
+
47
51
  try {
48
52
  console.log(chalk.green('Welcome to Puter-CLI! Type "help" for available commands.'));
49
53
  rl.setPrompt(getPrompt());
@@ -66,4 +70,4 @@ export async function startShell() {
66
70
  } catch (error) {
67
71
  console.error(chalk.red('Error starting shell:', error));
68
72
  }
69
- }
73
+ }
package/src/commons.js CHANGED
@@ -10,8 +10,14 @@ dotenv.config();
10
10
 
11
11
  export const PROJECT_NAME = 'puter-cli';
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
- export const API_BASE = process.env.PUTER_API_BASE || 'https://api.puter.com';
14
- export const BASE_URL = process.env.PUTER_BASE_URL || 'https://puter.com';
13
+ export let API_BASE = process.env.PUTER_API_BASE || 'https://api.puter.com';
14
+ export let BASE_URL = process.env.PUTER_BASE_URL || 'https://puter.com';
15
+ export const NULL_UUID = '00000000-0000-0000-0000-000000000000';
16
+
17
+ export const reconfigureURLs = ({ api, base }) => {
18
+ API_BASE = api;
19
+ BASE_URL = base;
20
+ };
15
21
 
16
22
  /**
17
23
  * Get headers with the correct Content-Type for multipart form data.
@@ -345,16 +351,46 @@ export async function getVersionFromPackage() {
345
351
  * Get latest package info from npm registery
346
352
  */
347
353
  export async function getLatestVersion(packageName) {
354
+ let currentVersion = 'unknown';
355
+ let latestVersion = null;
356
+ let status = 'offline'; // Default status
357
+
348
358
  try {
349
- const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
350
- let data = await response.json();
351
- const currentVersion = await getVersionFromPackage();
352
- if (data.version !== currentVersion){
353
- return `v${currentVersion} (latest: ${data.version})`;
354
- }
355
- return `v${currentVersion}`;
359
+ // Attempt to get the current version first
360
+ currentVersion = await getVersionFromPackage();
361
+ if (!currentVersion) {
362
+ currentVersion = 'unknown'; // Fallback if local version fetch fails
363
+ }
364
+
365
+ // Attempt to fetch the latest version from npm
366
+ try {
367
+ const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
368
+ if (response.ok) {
369
+ const data = await response.json();
370
+ latestVersion = data.version;
371
+ }
372
+ } catch (fetchError) {
373
+ // Ignore fetch errors
374
+ // console.warn(chalk.yellow(`Could not fetch latest version for ${packageName}: ${fetchError.message}`));
375
+ }
376
+
377
+ // Determine the status based on fetched versions
378
+ if (latestVersion) {
379
+ if (currentVersion !== 'unknown' && latestVersion === currentVersion) {
380
+ status = 'up-to-date';
381
+ } else if (currentVersion !== 'unknown' && latestVersion !== currentVersion) {
382
+ status = `latest: ${latestVersion}`;
383
+ } else {
384
+ // If currentVersion is unknown but we got latest, show latest
385
+ status = `latest: ${latestVersion}`;
386
+ }
387
+ }
388
+ // status remains 'offline'...
389
+
356
390
  } catch (error) {
357
- console.error(`ERROR: ${error.message}`);
358
- return "<Unknown>";
391
+ // Catch errors from getVersionFromPackage or other unexpected issues
392
+ console.error(chalk.red(`Error determining version status: ${error.message}`));
393
+ status = 'error'; // Indicate an error occurred
359
394
  }
360
- }
395
+ return `v${currentVersion} (${status})`;
396
+ }
package/src/executor.js CHANGED
@@ -6,7 +6,7 @@ import { listFiles, makeDirectory, renameFileOrDirectory,
6
6
  removeFileOrDirectory, emptyTrash, changeDirectory, showCwd,
7
7
  getInfo, getDiskUsage, createFile, readFile, uploadFile,
8
8
  downloadFile, copyFile, syncDirectory, editFile } from './commands/files.js';
9
- import { getUserInfo, getUsageInfo } from './commands/auth.js';
9
+ import { getUserInfo, getUsageInfo, login } from './commands/auth.js';
10
10
  import { PROJECT_NAME, API_BASE, getHeaders } from './commons.js';
11
11
  import inquirer from 'inquirer';
12
12
  import { exec } from 'node:child_process';
@@ -34,6 +34,7 @@ const commands = {
34
34
  await import('./commands/auth.js').then(m => m.logout());
35
35
  process.exit(0);
36
36
  },
37
+ login: login,
37
38
  whoami: getUserInfo,
38
39
  stat: getInfo,
39
40
  apps: async (args) => {
@@ -0,0 +1,274 @@
1
+ // external
2
+ import inquirer from 'inquirer';
3
+ import Conf from 'conf';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+
7
+ // project
8
+ import { API_BASE, BASE_URL, NULL_UUID, PROJECT_NAME, getHeaders, reconfigureURLs } from '../commons.js'
9
+ import { getAuthToken, login } from '../commands/auth.js';
10
+
11
+ // builtin
12
+ import fs from 'node:fs';
13
+ import crypto from 'node:crypto';
14
+
15
+ // initializations
16
+ const config = new Conf({ projectName: PROJECT_NAME });
17
+
18
+ export const ProfileAPI = Symbol('ProfileAPI');
19
+
20
+ function toApiSubdomain(inputUrl) {
21
+ const url = new URL(inputUrl);
22
+ const hostParts = url.hostname.split('.');
23
+
24
+ // Insert 'api' before the domain
25
+ hostParts.splice(-2, 0, 'api');
26
+ url.hostname = hostParts.join('.');
27
+
28
+ let output = url.toString();
29
+ if ( output.endsWith('/') ) {
30
+ output = output.slice(0, -1);
31
+ }
32
+ return output;
33
+ }
34
+
35
+ class ProfileModule {
36
+ constructor({ context }) {
37
+ this.context = context;
38
+
39
+ context.events.on('check-login', async () => {
40
+ if ( config.get('auth_token') ) {
41
+ await this.migrateLegacyConfig();
42
+ }
43
+ if ( ! config.get('selected_profile') ) {
44
+ console.log(chalk.cyan('Please login first (or use CTRL+C to exit):'));
45
+ await this.switchProfileWizard();
46
+ console.log(chalk.red('Please run "puter" command again (issue #11)'));
47
+ process.exit(0);
48
+ }
49
+ this.applyProfileToGlobals();
50
+ });
51
+
52
+ }
53
+ migrateLegacyConfig () {
54
+ const auth_token = config.get('auth_token');
55
+ const username = config.get('username');
56
+
57
+ this.addProfile({
58
+ host: BASE_URL,
59
+ username,
60
+ cwd: `/${username}`,
61
+ token: auth_token,
62
+ uuid: NULL_UUID,
63
+ });
64
+
65
+ config.delete('auth_token');
66
+ config.delete('username');
67
+ }
68
+ getDefaultProfile() {
69
+ const auth_token = config.get('auth_token');
70
+ if ( ! auth_token ) return;
71
+ return {
72
+ host: 'puter.com',
73
+ username: config.get('username'),
74
+ token: auth_token,
75
+ };
76
+ }
77
+ getProfiles() {
78
+ const profiles = config.get('profiles') ?? [];
79
+ return profiles;
80
+ }
81
+ addProfile(newProfile) {
82
+ const profiles = [
83
+ ...this.getProfiles().filter(p => ! p.transient),
84
+ newProfile,
85
+ ];
86
+ config.set('profiles', profiles);
87
+ }
88
+ selectProfile(profile) {
89
+ config.set('selected_profile', profile.uuid);
90
+ config.set('username', `${profile.username}`);
91
+ config.set('cwd', `/${profile.username}`);
92
+ this.applyProfileToGlobals(profile);
93
+ }
94
+ getCurrentProfile() {
95
+ const profiles = this.getProfiles();
96
+ const uuid = config.get('selected_profile');
97
+ return profiles.find(p => p.uuid === uuid);
98
+ }
99
+ applyProfileToGlobals(profile) {
100
+ if ( ! profile ) profile = this.getCurrentProfile();
101
+ reconfigureURLs({
102
+ base: profile.host,
103
+ api: toApiSubdomain(profile.host),
104
+ });
105
+ }
106
+ getAuthToken () {
107
+ const uuid = config.get('selected_profile');
108
+ const profiles = this.getProfiles();
109
+ const profile = profiles.find(v => v.uuid === uuid);
110
+ return profile?.token;
111
+ }
112
+
113
+ async switchProfileWizard (args = {}) {
114
+ const profiles = this.getProfiles();
115
+ if ( profiles.length < 1 ) {
116
+ return this.addProfileWizard();
117
+ }
118
+
119
+ console.log('doing this branch');
120
+ const answer = await inquirer.prompt([
121
+ {
122
+ name: 'profile',
123
+ type: 'list',
124
+ message: 'Select a Profile',
125
+ choices: [
126
+ ...profiles.map((v, i) => {
127
+ return {
128
+ name: v.name ?? `${v.username}@${v.host}`,
129
+ value: v,
130
+ };
131
+ }),
132
+ {
133
+ name: 'Create New Profile',
134
+ value: 'new',
135
+ }
136
+ ]
137
+ }
138
+ ]);
139
+
140
+ if ( answer.profile === 'new' ) {
141
+ return await this.addProfileWizard();
142
+ }
143
+
144
+ this.selectProfile(answer.profile);
145
+ }
146
+
147
+ async addProfileWizard (args = {}) {
148
+ const answers = await inquirer.prompt([
149
+ {
150
+ type: 'input',
151
+ name: 'host',
152
+ message: 'Host (leave blank for puter.com):',
153
+ default: 'https://puter.com',
154
+ validate: input => input.length >= 1 || 'Host is required'
155
+ },
156
+ {
157
+ type: 'input',
158
+ name: 'username',
159
+ message: 'Username:',
160
+ validate: input => input.length >= 1 || 'Username is required'
161
+ },
162
+ {
163
+ type: 'password',
164
+ name: 'password',
165
+ message: 'Password:',
166
+ mask: '*',
167
+ validate: input => input.length >= 1 || 'Password is required'
168
+ }
169
+ ]);
170
+
171
+ let spinner;
172
+ try {
173
+ spinner = ora('Logging in to Puter...').start();
174
+
175
+ const response = await fetch(`${answers.host}/login`, {
176
+ method: 'POST',
177
+ headers: getHeaders(),
178
+ body: JSON.stringify({
179
+ username: answers.username,
180
+ password: answers.password
181
+ })
182
+ });
183
+
184
+
185
+ const contentType = response.headers.get('content-type');
186
+ console.log('content type?', '|' + contentType + '|');
187
+
188
+ // TODO: proper content type parsing
189
+ if ( ! contentType.trim().startsWith('application/json') ) {
190
+ throw new Error(await response.text());
191
+ }
192
+
193
+ let data = await response.json();
194
+
195
+ while (data.proceed && data.next_step) {
196
+ if (data.next_step === 'otp') {
197
+ spinner.succeed(chalk.green('2FA is enabled'));
198
+ const answers2FA = await inquirer.prompt([
199
+ {
200
+ type: 'input',
201
+ name: 'otp',
202
+ message: 'Authenticator Code:',
203
+ validate: input => input.length === 6 || 'OTP must be 6 digits'
204
+ }
205
+ ]);
206
+ spinner = ora('Logging in to Puter...').start();
207
+ const response = await fetch(`${answers.host}/login/otp`, {
208
+ method: 'POST',
209
+ headers: getHeaders(),
210
+ body: JSON.stringify({
211
+ token: data.otp_jwt_token,
212
+ code: answers2FA.otp,
213
+ }),
214
+ });
215
+ data = await response.json();
216
+ continue;
217
+ }
218
+
219
+ if (data.next_step === 'complete') break;
220
+
221
+ spinner.fail(chalk.red(`Unrecognized login step "${data.next_step}"; you might need to update puter-cli.`));
222
+ return;
223
+ }
224
+
225
+ if (data.proceed && data.token) {
226
+ const profileUUID = crypto.randomUUID();
227
+ const profile = {
228
+ host: answers.host,
229
+ username: answers.username,
230
+ cwd: `/${answers.username}`,
231
+ token: data.token,
232
+ uuid: profileUUID,
233
+ };
234
+ this.addProfile(profile);
235
+ this.selectProfile(profile);
236
+ if (spinner) {
237
+ spinner.succeed(chalk.green('Successfully logged in to Puter!'));
238
+ }
239
+ console.log(chalk.dim(`Token: ${data.token.slice(0, 5)}...${data.token.slice(-5)}`));
240
+ // Save token
241
+ if (args.save) {
242
+ const localEnvFile = '.env';
243
+ try {
244
+ // Check if the file exists, if so then delete it before writing.
245
+ if (fs.existsSync(localEnvFile)) {
246
+ console.log(chalk.yellow(`File "${localEnvFile}" already exists... Adding token.`));
247
+ fs.appendFileSync(localEnvFile, `\nPUTER_API_KEY="${data.token}"`, 'utf8');
248
+ } else {
249
+ console.log(chalk.cyan(`Saving token to ${chalk.green(localEnvFile)} file.`));
250
+ fs.writeFileSync(localEnvFile, `PUTER_API_KEY="${data.token}"`, 'utf8');
251
+ }
252
+ } catch (error) {
253
+ console.error(chalk.red(`Cannot save token to .env file. Error: ${error.message}`));
254
+ console.log(chalk.cyan(`PUTER_API_KEY="${data.token}"`));
255
+ }
256
+ }
257
+ } else {
258
+ spinner.fail(chalk.red('Login failed. Please check your credentials.'));
259
+ }
260
+ } catch (error) {
261
+ if (spinner) {
262
+ spinner.fail(chalk.red(`Failed to login: ${error.message}`));
263
+ console.log(error);
264
+ } else {
265
+ console.error(chalk.red(`Failed to login: ${error.message}`));
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ export default ({ context }) => {
272
+ const module = new ProfileModule({ context });
273
+ context[ProfileAPI] = module;
274
+ };
@@ -0,0 +1,5 @@
1
+ import { set_context } from "../temporary/context_helpers.js";
2
+
3
+ export default ({ context }) => {
4
+ set_context(context);
5
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Ideally we would always get a context from the caller, but in some places
3
+ * this is not possible without creating a large number of changes
4
+ * (and therefore also a large number of new bugs). This temporary file
5
+ * provides a way to get the context that modules get.
6
+ */
7
+
8
+ let context;
9
+
10
+ // This is called by (list all callers so we can keep track):
11
+ // - auth.js:getAuthToken
12
+ export const get_context = () => {
13
+ return context;
14
+ };
15
+
16
+ // This is called by SetContextModule
17
+ export const set_context = ctx => context = ctx;
@@ -7,6 +7,8 @@ import chalk from 'chalk';
7
7
  import fetch from 'node-fetch';
8
8
  import Conf from 'conf';
9
9
  import { BASE_URL, PROJECT_NAME, API_BASE } from '../src/commons.js';
10
+ import { ProfileAPI } from '../src/modules/ProfileModule.js';
11
+ import * as contextHelpers from '../src/temporary/context_helpers.js';
10
12
 
11
13
  // Mock console to prevent actual logging
12
14
  vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -49,68 +51,38 @@ vi.mock('conf', () => {
49
51
  };
50
52
  });
51
53
 
52
- describe('auth.js', () => {
53
- // let config;
54
+ const mockProfileModule = {
55
+ switchProfileWizard: vi.fn(),
56
+ getAuthToken: vi.fn(),
57
+ getCurrentProfile: vi.fn(),
58
+ };
59
+
60
+ const mockContext = {
61
+ [ProfileAPI]: mockProfileModule,
62
+ };
54
63
 
64
+ vi.spyOn(contextHelpers, 'get_context').mockReturnValue(mockContext);
65
+
66
+
67
+ describe('auth.js', () => {
55
68
  beforeEach(() => {
56
69
  vi.clearAllMocks();
57
- // config = new Conf({ projectName: PROJECT_NAME });
58
70
  });
59
71
 
60
72
  describe('login', () => {
61
73
  it('should login successfully with valid credentials', async () => {
62
- // Mock inquirer response
63
- inquirer.prompt.mockResolvedValue({
64
- username: 'testuser',
65
- password: 'testpass'
66
- });
67
-
68
- // Mock fetch response
69
- fetch.mockResolvedValue({
70
- json: () => Promise.resolve({
71
- proceed: true,
72
- token: 'testtoken'
73
- })
74
- });
75
-
76
- await login();
77
-
78
- // Verify inquirer was called
79
- expect(inquirer.prompt).toHaveBeenCalled();
80
-
81
- // Verify fetch was called with correct parameters
82
- expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/login`, {
83
- method: 'POST',
84
- headers: expect.any(Object),
85
- body: JSON.stringify({
86
- username: 'testuser',
87
- password: 'testpass'
88
- }),
89
- });
90
-
91
- // Verify spinner methods were called
92
- expect(mockSpinner.start).toHaveBeenCalled();
93
- expect(mockSpinner.succeed).toHaveBeenCalled();
74
+ await login({}, mockContext);
75
+ expect(mockProfileModule.switchProfileWizard).toHaveBeenCalled();
94
76
  });
95
77
 
96
78
  it('should fail login with invalid credentials', async () => {
97
- inquirer.prompt.mockResolvedValue({ username: 'testuser', password: 'testpass' });
98
- fetch.mockResolvedValue({
99
- json: vi.fn().mockResolvedValue({ proceed: false }),
100
- ok: true,
101
- });
102
-
103
- await login();
104
- expect(mockSpinner.fail).toHaveBeenCalledWith(chalk.red('Login failed. Please check your credentials.'));
79
+ mockProfileModule.switchProfileWizard.mockRejectedValue(new Error('Invalid credentials'));
80
+ await expect(login({}, mockContext)).rejects.toThrow('Invalid credentials');
81
+ expect(mockProfileModule.switchProfileWizard).toHaveBeenCalled();
105
82
  });
106
83
 
107
84
  it.skip('should handle login error', async () => {
108
- inquirer.prompt.mockResolvedValue({ username: 'testuser', password: 'testpass' });
109
- fetch.mockRejectedValue(new Error('Network error'));
110
-
111
- // await expect(login()).rejects.toThrow('Network error');
112
- expect(mockSpinner.fail).toHaveBeenCalledWith(chalk.red('Failed to login'));
113
- // expect(console.error).toHaveBeenCalledWith(chalk.red('Error: Network error'));
85
+ // This test needs to be updated to reflect the new login flow
114
86
  });
115
87
  });
116
88
 
@@ -121,34 +93,20 @@ describe('auth.js', () => {
121
93
  beforeEach(() => {
122
94
  vi.clearAllMocks();
123
95
  config = new Conf({ projectName: PROJECT_NAME });
124
- // config.clear = vi.fn();
125
96
  });
126
97
 
127
98
  it.skip('should logout successfully', async () => {
128
- // Mock config.get to return a token
129
- config.get = vi.fn().mockReturnValue('testtoken');
130
- await logout();
131
- // Verify config.clear was called
132
- expect(config.clear).toHaveBeenCalled();
133
- expect(mockSpinner.succeed).toHaveBeenCalledWith(chalk.green('Successfully logged out from Puter!'));
99
+ // This test needs to be updated to reflect the new login flow
134
100
  });
135
101
 
136
102
  it('should handle already logged out', async () => {
137
103
  config.get = vi.fn().mockReturnValue(null);
138
-
139
104
  await logout();
140
-
141
105
  expect(mockSpinner.info).toHaveBeenCalledWith(chalk.yellow('Already logged out'));
142
106
  });
143
107
 
144
108
  it.skip('should handle logout error', async () => {
145
- config.get = vi.fn().mockReturnValue('testtoken');
146
- config.clear = vi.fn().mockImplementation(() => { throw new Error('Config error'); });
147
-
148
- await logout();
149
-
150
- expect(mockSpinner.fail).toHaveBeenCalled();
151
- expect(mockSpinner.fail).toHaveBeenCalledWith(chalk.red('Failed to logout'));
109
+ // This test needs to be updated to reflect the new login flow
152
110
  });
153
111
 
154
112
  });
@@ -156,23 +114,16 @@ describe('auth.js', () => {
156
114
 
157
115
  describe('getUserInfo', () => {
158
116
  it('should fetch user info successfully', async () => {
159
- // Mock fetch response
117
+ mockProfileModule.getAuthToken.mockReturnValue('testtoken');
160
118
  fetch.mockResolvedValue({
161
119
  json: () => Promise.resolve({
162
120
  username: 'testuser',
163
- uuid: 'testuuid',
164
- email: 'test@example.com',
165
- email_confirmed: true,
166
- is_temp: false,
167
- human_readable_age: '1 year',
168
- feature_flags: { flag1: true, flag2: false },
169
121
  }),
170
122
  ok: true,
171
123
  });
172
124
 
173
125
  await getUserInfo();
174
126
 
175
- // Verify fetch was called with correct parameters
176
127
  expect(fetch).toHaveBeenCalledWith(`${API_BASE}/whoami`, {
177
128
  method: 'GET',
178
129
  headers: expect.any(Object),
@@ -180,86 +131,40 @@ describe('auth.js', () => {
180
131
  });
181
132
 
182
133
  it('should handle fetch user info error', async () => {
183
- // Mock fetch to throw an error
134
+ mockProfileModule.getAuthToken.mockReturnValue('testtoken');
184
135
  fetch.mockRejectedValue(new Error('Network error'));
185
-
186
136
  await getUserInfo();
187
-
188
- // Verify console.error was called
189
- expect(console.error).toHaveBeenCalledWith(chalk.red('Failed to get user info.\nError: Network error'));
137
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Failed to get user info.'));
190
138
  });
191
139
  });
192
140
 
193
141
 
194
142
  describe('Authentication', () => {
195
- let config;
196
-
197
- beforeEach(() => {
198
- vi.clearAllMocks();
199
- config = new Conf({ projectName: PROJECT_NAME });
200
- });
201
-
202
143
  it('should return false if auth token does not exist', () => {
203
- config.get.mockReturnValue(null);
204
-
144
+ mockProfileModule.getAuthToken.mockReturnValue(null);
205
145
  const result = isAuthenticated();
206
-
207
146
  expect(result).toBe(false);
208
147
  });
209
148
 
210
149
  it('should return null if the auth_token is not defined', () => {
211
- config.get.mockReturnValue(null);
212
-
150
+ mockProfileModule.getAuthToken.mockReturnValue(null);
213
151
  const result = getAuthToken();
214
-
215
- expect(result).toBeUndefined();
152
+ expect(result).toBe(null);
216
153
  });
217
154
 
218
155
  it('should return the current username if it is defined', () => {
219
- config.get.mockReturnValue(null);
220
-
156
+ mockProfileModule.getCurrentProfile.mockReturnValue({ username: 'testuser' });
221
157
  const result = getCurrentUserName();
222
-
223
- expect(result).toBeUndefined();
158
+ expect(result).toBe('testuser');
224
159
  });
225
160
 
226
161
  });
227
162
 
228
- // describe('getCurrentDirectory', () => {
229
- // let config;
230
-
231
- // beforeEach(() => {
232
- // vi.clearAllMocks();
233
- // config = new Conf({ projectName: PROJECT_NAME });
234
- // // config.get = vi.fn().mockReturnValue('testtoken')
235
- // });
236
-
237
- // it('should return the current directory', () => {
238
- // config.get.mockReturnValue('/testuser');
239
-
240
- // const result = getCurrentDirectory();
241
-
242
- // expect(result).toBe('/testuser');
243
- // });
244
- // });
245
-
246
163
  describe('getUsageInfo', () => {
247
164
  it('should fetch usage info successfully', async () => {
165
+ mockProfileModule.getAuthToken.mockReturnValue('testtoken');
248
166
  fetch.mockResolvedValue({
249
- json: vi.fn().mockResolvedValue({
250
- user: [
251
- {
252
- service: { 'driver.interface': 'interface1', 'driver.method': 'method1', 'driver.implementation': 'impl1' },
253
- month: 1,
254
- year: 2023,
255
- monthly_usage: 10,
256
- monthly_limit: 100,
257
- policy: { 'rate-limit': { max: 5, period: 30000 } },
258
- },
259
- ],
260
- apps: { app1: { used: 5, available: 50 } },
261
- usages: [{ name: 'usage1', used: 10, available: 100, refill: 'monthly' }],
262
- }),
167
+ json: vi.fn().mockResolvedValue({}),
263
168
  ok: true,
264
169
  });
265
170
 
@@ -272,11 +177,10 @@ describe('auth.js', () => {
272
177
  });
273
178
 
274
179
  it('should handle fetch usage info error', async () => {
180
+ mockProfileModule.getAuthToken.mockReturnValue('testtoken');
275
181
  fetch.mockRejectedValue(new Error('Network error'));
276
-
277
182
  await getUsageInfo();
278
-
279
- expect(console.error).toHaveBeenCalledWith(chalk.red('Failed to fetch usage information.\nError: Network error'));
183
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch usage information.'));
280
184
  });
281
185
  });
282
186