puter-cli 1.8.1 → 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.
- package/.github/workflows/npm-build.yml +2 -2
- package/.github/workflows/npm-publish.yml +27 -8
- package/CHANGELOG.md +8 -0
- package/bin/index.js +3 -1
- package/package.json +1 -1
- package/src/commands/auth.js +12 -99
- package/src/commands/files.js +1 -0
- package/src/commands/shell.js +14 -10
- package/src/commons.js +8 -2
- package/src/executor.js +2 -1
- package/src/modules/ProfileModule.js +274 -0
- package/src/modules/SetContextModule.js +5 -0
- package/src/temporary/context_helpers.js +17 -0
- package/tests/login.test.js +35 -131
|
@@ -15,14 +15,14 @@ jobs:
|
|
|
15
15
|
- name: Install pnpm
|
|
16
16
|
uses: pnpm/action-setup@v4
|
|
17
17
|
with:
|
|
18
|
-
version:
|
|
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:
|
|
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
|
-
-
|
|
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
|
-
|
|
17
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
29
|
-
-
|
|
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,16 @@ 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
|
+
|
|
7
13
|
#### [v1.8.1](https://github.com/HeyPuter/puter-cli/compare/v1.8.0...v1.8.1)
|
|
8
14
|
|
|
15
|
+
> 16 April 2025
|
|
16
|
+
|
|
9
17
|
- fix: better version status handling and error resilience [`ab467e8`](https://github.com/HeyPuter/puter-cli/commit/ab467e8e57cb4f82619424b12136724904df0302)
|
|
10
18
|
|
|
11
19
|
#### [v1.8.0](https://github.com/HeyPuter/puter-cli/compare/v1.7.3...v1.8.0)
|
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(
|
|
23
|
+
.action(() => {
|
|
24
|
+
startShell('login');
|
|
25
|
+
});
|
|
24
26
|
|
|
25
27
|
program
|
|
26
28
|
.command('logout')
|
package/package.json
CHANGED
package/src/commands/auth.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
83
|
+
const context = get_context();
|
|
84
|
+
const profileAPI = context[ProfileAPI];
|
|
85
|
+
return profileAPI.getAuthToken();
|
|
175
86
|
}
|
|
176
87
|
|
|
177
88
|
export function getCurrentUserName() {
|
|
178
|
-
|
|
89
|
+
const context = get_context();
|
|
90
|
+
const profileAPI = context[ProfileAPI];
|
|
91
|
+
return profileAPI.getCurrentProfile()?.username;
|
|
179
92
|
}
|
|
180
93
|
|
|
181
94
|
export function getCurrentDirectory() {
|
package/src/commands/files.js
CHANGED
package/src/commands/shell.js
CHANGED
|
@@ -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
|
|
14
|
-
export
|
|
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.
|
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,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;
|
package/tests/login.test.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|