puter-cli 1.8.5 → 2.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.
@@ -3,57 +3,54 @@ import inquirer from 'inquirer';
3
3
  import Conf from 'conf';
4
4
  import chalk from 'chalk';
5
5
  import ora from 'ora';
6
+ import {getAuthToken} from "@heyputer/puter.js/src/init.cjs";
7
+ import { puter } from "@heyputer/puter.js";
6
8
 
7
9
  // 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
+ import { BASE_URL, NULL_UUID, PROJECT_NAME, getHeaders, reconfigureURLs } from '../commons.js'
10
11
 
11
12
  // builtin
12
13
  import fs from 'node:fs';
13
14
  import crypto from 'node:crypto';
15
+ import { initPuterModule } from './PuterModule.js';
14
16
 
15
17
  // initializations
16
18
  const config = new Conf({ projectName: PROJECT_NAME });
17
19
 
18
- export const ProfileAPI = Symbol('ProfileAPI');
20
+ let profileModule;
19
21
 
20
22
  function toApiSubdomain(inputUrl) {
21
23
  const url = new URL(inputUrl);
22
24
  const hostParts = url.hostname.split('.');
23
-
25
+
24
26
  // Insert 'api' before the domain
25
27
  hostParts.splice(-2, 0, 'api');
26
28
  url.hostname = hostParts.join('.');
27
29
 
28
30
  let output = url.toString();
29
- if ( output.endsWith('/') ) {
31
+ if (output.endsWith('/')) {
30
32
  output = output.slice(0, -1);
31
33
  }
32
34
  return output;
33
35
  }
34
36
 
35
37
  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
-
38
+ async checkLogin() {
39
+ if (config.get('auth_token')) {
40
+ this.migrateLegacyConfig();
41
+ }
42
+ if (!config.get('selected_profile')) {
43
+ console.log(chalk.cyan('Please login first (or use CTRL+C to exit):'));
44
+ await this.switchProfileWizard();
45
+ // re init with new authToken
46
+ initPuterModule();
47
+ }
48
+ this.applyProfileToGlobals();
52
49
  }
53
- migrateLegacyConfig () {
50
+ migrateLegacyConfig() {
54
51
  const auth_token = config.get('auth_token');
55
52
  const username = config.get('username');
56
-
53
+
57
54
  this.addProfile({
58
55
  host: BASE_URL,
59
56
  username,
@@ -61,13 +58,13 @@ class ProfileModule {
61
58
  token: auth_token,
62
59
  uuid: NULL_UUID,
63
60
  });
64
-
61
+
65
62
  config.delete('auth_token');
66
63
  config.delete('username');
67
64
  }
68
65
  getDefaultProfile() {
69
66
  const auth_token = config.get('auth_token');
70
- if ( ! auth_token ) return;
67
+ if (!auth_token) return;
71
68
  return {
72
69
  host: 'puter.com',
73
70
  username: config.get('username'),
@@ -80,7 +77,7 @@ class ProfileModule {
80
77
  }
81
78
  addProfile(newProfile) {
82
79
  const profiles = [
83
- ...this.getProfiles().filter(p => ! p.transient),
80
+ ...this.getProfiles().filter(p => !p.transient),
84
81
  newProfile,
85
82
  ];
86
83
  config.set('profiles', profiles);
@@ -97,26 +94,25 @@ class ProfileModule {
97
94
  return profiles.find(p => p.uuid === uuid);
98
95
  }
99
96
  applyProfileToGlobals(profile) {
100
- if ( ! profile ) profile = this.getCurrentProfile();
97
+ if (!profile) profile = this.getCurrentProfile();
101
98
  reconfigureURLs({
102
99
  base: profile.host,
103
100
  api: toApiSubdomain(profile.host),
104
101
  });
105
102
  }
106
- getAuthToken () {
103
+ getAuthToken() {
107
104
  const uuid = config.get('selected_profile');
108
105
  const profiles = this.getProfiles();
109
106
  const profile = profiles.find(v => v.uuid === uuid);
110
107
  return profile?.token;
111
108
  }
112
-
113
- async switchProfileWizard (args = {}) {
109
+
110
+ async switchProfileWizard(args = {}) {
114
111
  const profiles = this.getProfiles();
115
- if ( profiles.length < 1 ) {
116
- return this.addProfileWizard();
112
+ if (profiles.length < 1) {
113
+ return this.addProfileWizard(args);
117
114
  }
118
-
119
- // console.log('doing this branch');
115
+
120
116
  const answer = await inquirer.prompt([
121
117
  {
122
118
  name: 'profile',
@@ -136,23 +132,81 @@ class ProfileModule {
136
132
  ]
137
133
  }
138
134
  ]);
139
-
140
- if ( answer.profile === 'new' ) {
141
- return await this.addProfileWizard();
135
+
136
+ if (answer.profile === 'new') {
137
+ return await this.addProfileWizard(args);
142
138
  }
143
-
139
+
144
140
  this.selectProfile(answer.profile);
145
141
  }
146
142
 
147
- async addProfileWizard (args = {}) {
143
+ async addProfileWizard(args = {}) {
144
+ const host = args.host || 'https://puter.com';
145
+
146
+ if (args.withCredentials) {
147
+ return await this.credentialLogin({ ...args, host });
148
+ }
149
+
150
+ // Browser-based login (default)
151
+ return await this.browserLogin({ ...args, host });
152
+ }
153
+
154
+ async browserLogin(args) {
155
+ const { host, save } = args;
156
+ const TIMEOUT_MS = 60000; // 1 minute timeout
157
+ let spinner;
158
+
159
+ try {
160
+ spinner = ora('Opening browser for login...').start();
161
+
162
+ const timeoutPromise = new Promise((_, reject) => {
163
+ setTimeout(() => reject(new Error('Login timed out after 60 seconds')), TIMEOUT_MS);
164
+ });
165
+
166
+ const authToken = await Promise.race([
167
+ getAuthToken(),
168
+ timeoutPromise
169
+ ]);
170
+
171
+ if (!authToken) {
172
+ spinner.fail(chalk.red('Login failed or was cancelled.'));
173
+ return;
174
+ }
175
+
176
+ spinner.text = 'Fetching user info...';
177
+
178
+ // Set token and fetch user info
179
+ puter.setAuthToken(authToken);
180
+ const userInfo = await puter.auth.getUser();
181
+
182
+ const profileUUID = crypto.randomUUID();
183
+ const profile = {
184
+ host,
185
+ username: userInfo.username,
186
+ cwd: `/${userInfo.username}`,
187
+ token: authToken,
188
+ uuid: profileUUID,
189
+ };
190
+
191
+ this.addProfile(profile);
192
+ this.selectProfile(profile);
193
+ spinner.succeed(chalk.green(`Successfully logged in as ${userInfo.username}!`));
194
+
195
+ // Handle --save option
196
+ this.saveTokenToEnv(authToken, save);
197
+ } catch (error) {
198
+ if (spinner) {
199
+ spinner.fail(chalk.red(`Failed to login: ${error.message}`));
200
+ } else {
201
+ console.error(chalk.red(`Failed to login: ${error.message}`));
202
+ }
203
+ }
204
+ }
205
+
206
+ async credentialLogin(args) {
207
+ const { host, save } = args;
208
+
148
209
  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
210
  {
157
211
  type: 'input',
158
212
  name: 'username',
@@ -172,103 +226,109 @@ class ProfileModule {
172
226
  try {
173
227
  spinner = ora('Logging in to Puter...').start();
174
228
 
175
- const response = await fetch(`${answers.host}/login`, {
229
+ const apiHost = toApiSubdomain(host);
230
+ const response = await fetch(`${apiHost}/login`, {
176
231
  method: 'POST',
177
232
  headers: getHeaders(),
178
233
  body: JSON.stringify({
179
234
  username: answers.username,
180
- password: answers.password
181
- })
235
+ password: answers.password,
236
+ }),
182
237
  });
183
238
 
239
+ const data = await response.json();
184
240
 
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
- }
241
+ if (data.proceed && data.next_step === 'otp') {
242
+ // Handle 2FA
243
+ spinner.stop();
244
+ const otpAnswer = await inquirer.prompt([
245
+ {
246
+ type: 'input',
247
+ name: 'otp',
248
+ message: 'Enter your 2FA code:',
249
+ validate: input => input.length >= 1 || '2FA code is required'
250
+ }
251
+ ]);
218
252
 
219
- if (data.next_step === 'complete') break;
253
+ spinner = ora('Verifying 2FA code...').start();
254
+ const otpResponse = await fetch(`${apiHost}/login/otp`, {
255
+ method: 'POST',
256
+ headers: getHeaders(),
257
+ body: JSON.stringify({
258
+ token: data.otp_jwt_token,
259
+ code: otpAnswer.otp,
260
+ }),
261
+ });
220
262
 
221
- spinner.fail(chalk.red(`Unrecognized login step "${data.next_step}"; you might need to update puter-cli.`));
222
- return;
223
- }
263
+ const otpData = await otpResponse.json();
224
264
 
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 append the api key to the EOF.
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
- }
265
+ if (otpData.token) {
266
+ this.createProfileFromToken(otpData.token, answers.username, host, spinner, save);
267
+ } else {
268
+ spinner.fail(chalk.red('2FA verification failed.'));
256
269
  }
270
+ } else if (data.token) {
271
+ this.createProfileFromToken(data.token, answers.username, host, spinner, save);
257
272
  } else {
258
- spinner.fail(chalk.red('Login failed. Please check your credentials.'));
273
+ spinner.fail(chalk.red(data.error?.message || 'Login failed. Please check your credentials.'));
259
274
  }
260
275
  } catch (error) {
261
276
  if (spinner) {
262
277
  spinner.fail(chalk.red(`Failed to login: ${error.message}`));
263
- console.log(error);
264
278
  } else {
265
279
  console.error(chalk.red(`Failed to login: ${error.message}`));
266
280
  }
267
281
  }
268
282
  }
283
+
284
+ createProfileFromToken(token, username, host, spinner, save) {
285
+ const profileUUID = crypto.randomUUID();
286
+ const profile = {
287
+ host,
288
+ username,
289
+ cwd: `/${username}`,
290
+ token,
291
+ uuid: profileUUID,
292
+ };
293
+
294
+ this.addProfile(profile);
295
+ this.selectProfile(profile);
296
+ spinner.succeed(chalk.green(`Successfully logged in as ${username}!`));
297
+
298
+ // Handle --save option
299
+ this.saveTokenToEnv(token, save);
300
+ }
301
+
302
+ saveTokenToEnv(token, save) {
303
+ if (!save) return;
304
+
305
+ const localEnvFile = '.env';
306
+ try {
307
+ if (fs.existsSync(localEnvFile)) {
308
+ console.log(chalk.yellow(`File "${localEnvFile}" already exists... Adding token.`));
309
+ fs.appendFileSync(localEnvFile, `\nPUTER_API_KEY="${token}"`, 'utf8');
310
+ } else {
311
+ console.log(chalk.cyan(`Saving token to ${chalk.green(localEnvFile)} file.`));
312
+ fs.writeFileSync(localEnvFile, `PUTER_API_KEY="${token}"`, 'utf8');
313
+ }
314
+ } catch (error) {
315
+ console.error(chalk.red(`Cannot save token to .env file. Error: ${error.message}`));
316
+ console.log(chalk.cyan(`PUTER_API_KEY="${token}"`));
317
+ }
318
+ }
269
319
  }
270
320
 
271
- export default ({ context }) => {
272
- const module = new ProfileModule({ context });
273
- context[ProfileAPI] = module;
274
- };
321
+ export const initProfileModule = () => {
322
+ profileModule = new ProfileModule();
323
+ }
324
+
325
+ /**
326
+ * Get ProfileModule object
327
+ * @returns {ProfileModule} ProfileModule - ProfileModule Object.
328
+ */
329
+ export const getProfileModule = () => {
330
+ if (!profileModule) {
331
+ throw new Error("Call initprofileModule() first");
332
+ }
333
+ return profileModule;
334
+ }
@@ -0,0 +1,30 @@
1
+ import Conf from "conf";
2
+ import { PROJECT_NAME } from "../commons.js";
3
+ import { puter } from "@heyputer/puter.js";
4
+
5
+ const config = new Conf({ projectName: PROJECT_NAME });
6
+
7
+ let puterModule;
8
+
9
+ export const initPuterModule = () => {
10
+ const uuid = config.get("selected_profile");
11
+ const profiles = config.get("profiles") ?? [];
12
+ const profile = profiles.find((v) => v.uuid === uuid);
13
+ const authToken = profile?.token;
14
+
15
+ if (authToken) {
16
+ puter.setAuthToken(authToken);
17
+ puterModule = puter;
18
+ }
19
+ };
20
+
21
+ /**
22
+ * Get Puter object
23
+ * @returns {puter} puter - Puter Object.
24
+ */
25
+ export const getPuter = () => {
26
+ if (!puterModule) {
27
+ throw new Error("Call initPuterModule() first");
28
+ }
29
+ return puterModule;
30
+ };
@@ -0,0 +1,42 @@
1
+ import { it, describe, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.spyOn(console, "log").mockImplementation(() => { });
4
+ vi.spyOn(console, "error").mockImplementation(() => { });
5
+
6
+ let errors, report, ERROR_BUFFER_LIMIT, showLast;
7
+
8
+ beforeEach(async () => {
9
+ vi.resetModules();
10
+ const module = await import("../src/modules/ErrorModule");
11
+ errors = module.errors;
12
+ report = module.report;
13
+ ERROR_BUFFER_LIMIT = module.ERROR_BUFFER_LIMIT;
14
+ showLast = module.showLast;
15
+ });
16
+
17
+ describe("report", () => {
18
+ it("should be able to report error", () => {
19
+ report("hehe")
20
+ expect(errors).toHaveLength(1)
21
+ })
22
+
23
+ it("should not exceed error buffer limit", () => {
24
+ for (let i = 0; i < 100; i++) {
25
+ report(`error ${i}`)
26
+ }
27
+ expect(errors.length).lessThanOrEqual(ERROR_BUFFER_LIMIT);
28
+ })
29
+ })
30
+
31
+ describe("showLast", () => {
32
+ it("should not log error if no error exists", () => {
33
+ showLast();
34
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining("No errors to report"));
35
+ })
36
+
37
+ it("should log error if error exists", () => {
38
+ report("hehe")
39
+ showLast();
40
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining("hehe"));
41
+ })
42
+ })