@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/.github/workflows/npm-build.yml +0 -2
- package/.github/workflows/npm-publish.yml +0 -4
- package/bin/index.js +22 -202
- package/package.json +3 -5
- package/src/commands/auth.js +7 -12
- package/src/commands/files.js +16 -18
- package/src/commands/shell.js +5 -3
- package/src/commons.js +81 -256
- package/src/executor.js +12 -129
- package/src/modules/ErrorModule.js +62 -1
- package/src/modules/ProfileModule.js +154 -38
- package/src/utils.js +0 -91
- package/tests/ErrorModule.test.js +73 -1
- package/tests/ProfileModule.test.js +58 -44
- package/tests/commons.test.js +66 -124
- package/tests/executor.test.js +8 -0
- package/tests/files.test.js +23 -3
- package/tests/shell.test.js +2 -1
- package/tests/utils.test.js +1 -141
- package/src/commands/apps.js +0 -356
- package/src/commands/deploy.js +0 -54
- package/src/commands/init.js +0 -322
- package/src/commands/sites.js +0 -169
- package/src/commands/subdomains.js +0 -95
- package/tests/apps.test.js +0 -194
- package/tests/deploy.test.js +0 -84
- package/tests/sites.test.js +0 -67
- package/tests/subdomains.test.js +0 -90
|
@@ -7,16 +7,21 @@ import {getAuthToken} from "@heyputer/puter.js/src/init.cjs";
|
|
|
7
7
|
import { puter } from "@heyputer/puter.js";
|
|
8
8
|
|
|
9
9
|
// project
|
|
10
|
-
import {
|
|
10
|
+
import { HOME, PROJECT_NAME, getHeaders, reconfigureURLs, setHomePath } from '../commons.js'
|
|
11
11
|
|
|
12
12
|
// builtin
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
-
import crypto from 'node:crypto';
|
|
15
14
|
import { initPuterModule } from './PuterModule.js';
|
|
15
|
+
import { isAuthError } from './ErrorModule.js';
|
|
16
16
|
|
|
17
17
|
// initializations
|
|
18
18
|
const config = new Conf({ projectName: PROJECT_NAME });
|
|
19
19
|
|
|
20
|
+
// Outcomes of a session check against the server.
|
|
21
|
+
const SESSION_OK = 'ok'; // token works; identity refreshed
|
|
22
|
+
const SESSION_INVALID = 'invalid'; // token rejected; the user must log in again
|
|
23
|
+
const SESSION_UNKNOWN = 'unknown'; // unreachable or unexpected reply; assume usable
|
|
24
|
+
|
|
20
25
|
let profileModule;
|
|
21
26
|
|
|
22
27
|
function toApiSubdomain(inputUrl) {
|
|
@@ -36,9 +41,6 @@ function toApiSubdomain(inputUrl) {
|
|
|
36
41
|
|
|
37
42
|
class ProfileModule {
|
|
38
43
|
async checkLogin() {
|
|
39
|
-
if (config.get('auth_token')) {
|
|
40
|
-
this.migrateLegacyConfig();
|
|
41
|
-
}
|
|
42
44
|
if (!config.get('selected_profile')) {
|
|
43
45
|
console.log(chalk.cyan('Please login first (or use CTRL+C to exit):'));
|
|
44
46
|
await this.switchProfileWizard();
|
|
@@ -46,30 +48,106 @@ class ProfileModule {
|
|
|
46
48
|
initPuterModule();
|
|
47
49
|
}
|
|
48
50
|
this.applyProfileToGlobals();
|
|
51
|
+
|
|
52
|
+
if (await this.refreshIdentity() === SESSION_INVALID) {
|
|
53
|
+
console.log(chalk.yellow('Your session has expired or its token is no longer valid.'));
|
|
54
|
+
console.log(chalk.cyan('Please log in again (or use CTRL+C to exit):'));
|
|
55
|
+
this.dropCurrentProfile();
|
|
56
|
+
await this.switchProfileWizard();
|
|
57
|
+
initPuterModule();
|
|
58
|
+
this.applyProfileToGlobals();
|
|
59
|
+
}
|
|
49
60
|
}
|
|
50
|
-
migrateLegacyConfig() {
|
|
51
|
-
const auth_token = config.get('auth_token');
|
|
52
|
-
const username = config.get('username');
|
|
53
|
-
|
|
54
|
-
this.addProfile({
|
|
55
|
-
host: BASE_URL,
|
|
56
|
-
username,
|
|
57
|
-
cwd: `/${username}`,
|
|
58
|
-
token: auth_token,
|
|
59
|
-
uuid: NULL_UUID,
|
|
60
|
-
});
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Forget the selected profile, whose token the server has rejected. Keeping
|
|
64
|
+
* a dead token around only makes the next command fail the same way.
|
|
65
|
+
*/
|
|
66
|
+
dropCurrentProfile() {
|
|
67
|
+
const selected = config.get('selected_profile');
|
|
68
|
+
config.set('profiles', this.getProfiles().filter(p => p.uuid !== selected));
|
|
69
|
+
config.delete('selected_profile');
|
|
63
70
|
config.delete('username');
|
|
71
|
+
config.delete('cwd');
|
|
64
72
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Re-read the identity from the server using the current token.
|
|
77
|
+
*
|
|
78
|
+
* A username is a mutable display label that the user can change at any
|
|
79
|
+
* time; the token is the identity. Nothing may depend on the cached username
|
|
80
|
+
* beyond display, and the cache is refreshed here on every run so it never
|
|
81
|
+
* silently drifts.
|
|
82
|
+
*/
|
|
83
|
+
async refreshIdentity() {
|
|
84
|
+
const profile = this.getCurrentProfile();
|
|
85
|
+
if (!profile?.token) return SESSION_INVALID;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
puter.setAuthToken(profile.token);
|
|
89
|
+
const userInfo = await puter.auth.getUser();
|
|
90
|
+
if (!userInfo?.username) return SESSION_UNKNOWN;
|
|
91
|
+
|
|
92
|
+
if (userInfo.username !== profile.username) {
|
|
93
|
+
this.rehomePaths(profile.username, userInfo.username);
|
|
94
|
+
this.updateProfile(profile.uuid, { username: userInfo.username });
|
|
95
|
+
}
|
|
96
|
+
setHomePath(`/${userInfo.username}`);
|
|
97
|
+
return SESSION_OK;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A rejected token will not start working again, so say so and let
|
|
100
|
+
// the caller re-authenticate.
|
|
101
|
+
if (isAuthError(error)) return SESSION_INVALID;
|
|
102
|
+
|
|
103
|
+
// Offline or a transient API failure. Keep the cached label and
|
|
104
|
+
// carry on rather than forcing a login the user cannot complete.
|
|
105
|
+
return SESSION_UNKNOWN;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Ask the server who a token belongs to.
|
|
112
|
+
* @param {string} token - The auth token to identify
|
|
113
|
+
* @returns {Promise<{username: string, uuid: string}>} The account identity
|
|
114
|
+
*/
|
|
115
|
+
async fetchIdentity(token) {
|
|
116
|
+
puter.setAuthToken(token);
|
|
117
|
+
const userInfo = await puter.auth.getUser();
|
|
118
|
+
if (!userInfo?.username) {
|
|
119
|
+
throw new Error('The server did not return an account for this token.');
|
|
120
|
+
}
|
|
121
|
+
return userInfo;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The selected profile's current working directory.
|
|
126
|
+
* @returns {string} The stored cwd, or the home directory as a fallback
|
|
127
|
+
*/
|
|
128
|
+
getCwd() {
|
|
129
|
+
return this.getCurrentProfile()?.cwd || HOME;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Record the selected profile's current working directory.
|
|
134
|
+
* @param {string} cwd - The new working directory
|
|
135
|
+
*/
|
|
136
|
+
setCwd(cwd) {
|
|
137
|
+
const profile = this.getCurrentProfile();
|
|
138
|
+
if (profile) this.updateProfile(profile.uuid, { cwd });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Merge a patch into a stored profile, matched by its id.
|
|
143
|
+
* @param {string} profileId - The `uuid` of the profile to patch
|
|
144
|
+
* @param {Object} patch - Fields to merge into the profile
|
|
145
|
+
*/
|
|
146
|
+
updateProfile(profileId, patch) {
|
|
147
|
+
const profiles = this.getProfiles().map(p => (
|
|
148
|
+
p.uuid === profileId ? { ...p, ...patch } : p
|
|
149
|
+
));
|
|
150
|
+
config.set('profiles', profiles);
|
|
73
151
|
}
|
|
74
152
|
getProfiles() {
|
|
75
153
|
const profiles = config.get('profiles') ?? [];
|
|
@@ -77,15 +155,18 @@ class ProfileModule {
|
|
|
77
155
|
}
|
|
78
156
|
addProfile(newProfile) {
|
|
79
157
|
const profiles = [
|
|
80
|
-
|
|
158
|
+
// Profiles are keyed by account id, so re-authenticating replaces the
|
|
159
|
+
// existing entry rather than adding a second one for the same account.
|
|
160
|
+
...this.getProfiles().filter(p => !p.transient && p.uuid !== newProfile.uuid),
|
|
81
161
|
newProfile,
|
|
82
162
|
];
|
|
83
163
|
config.set('profiles', profiles);
|
|
84
164
|
}
|
|
85
165
|
selectProfile(profile) {
|
|
86
166
|
config.set('selected_profile', profile.uuid);
|
|
87
|
-
|
|
88
|
-
|
|
167
|
+
if (!profile.cwd) {
|
|
168
|
+
this.updateProfile(profile.uuid, { cwd: `/${profile.username}` });
|
|
169
|
+
}
|
|
89
170
|
this.applyProfileToGlobals(profile);
|
|
90
171
|
}
|
|
91
172
|
getCurrentProfile() {
|
|
@@ -99,6 +180,36 @@ class ProfileModule {
|
|
|
99
180
|
base: profile.host,
|
|
100
181
|
api: toApiSubdomain(profile.host),
|
|
101
182
|
});
|
|
183
|
+
// Provisional home from the cached label, so paths resolve before the
|
|
184
|
+
// network round-trip; refreshIdentity() re-points it at the live value.
|
|
185
|
+
setHomePath(`/${profile.username}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Re-point any stored path that lives under the old home directory at the
|
|
190
|
+
* new one, after the account was renamed.
|
|
191
|
+
*
|
|
192
|
+
* Only the home prefix is rewritten: a cwd under some other user's tree is
|
|
193
|
+
* left alone, since that path did not move.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} oldUsername - The username the paths were written with
|
|
196
|
+
* @param {string} newUsername - The account's current username
|
|
197
|
+
*/
|
|
198
|
+
rehomePaths(oldUsername, newUsername) {
|
|
199
|
+
if (!oldUsername || oldUsername === newUsername) return;
|
|
200
|
+
|
|
201
|
+
const oldHome = `/${oldUsername}`;
|
|
202
|
+
const newHome = `/${newUsername}`;
|
|
203
|
+
const rehome = p => (
|
|
204
|
+
p === oldHome || p?.startsWith(`${oldHome}/`)
|
|
205
|
+
? `${newHome}${p.slice(oldHome.length)}`
|
|
206
|
+
: p
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const profile = this.getCurrentProfile();
|
|
210
|
+
if (profile?.cwd) {
|
|
211
|
+
this.updateProfile(profile.uuid, { cwd: rehome(profile.cwd) });
|
|
212
|
+
}
|
|
102
213
|
}
|
|
103
214
|
getAuthToken() {
|
|
104
215
|
const uuid = config.get('selected_profile');
|
|
@@ -179,13 +290,14 @@ class ProfileModule {
|
|
|
179
290
|
puter.setAuthToken(authToken);
|
|
180
291
|
const userInfo = await puter.auth.getUser();
|
|
181
292
|
|
|
182
|
-
const profileUUID = crypto.randomUUID();
|
|
183
293
|
const profile = {
|
|
184
294
|
host,
|
|
295
|
+
// Display label only; re-resolved from the token on every run.
|
|
185
296
|
username: userInfo.username,
|
|
186
297
|
cwd: `/${userInfo.username}`,
|
|
187
298
|
token: authToken,
|
|
188
|
-
|
|
299
|
+
// The account's own id, as reported by the server.
|
|
300
|
+
uuid: userInfo.uuid,
|
|
189
301
|
};
|
|
190
302
|
|
|
191
303
|
this.addProfile(profile);
|
|
@@ -263,12 +375,12 @@ class ProfileModule {
|
|
|
263
375
|
const otpData = await otpResponse.json();
|
|
264
376
|
|
|
265
377
|
if (otpData.token) {
|
|
266
|
-
this.createProfileFromToken(otpData.token, answers.username, host, spinner, save);
|
|
378
|
+
await this.createProfileFromToken(otpData.token, answers.username, host, spinner, save);
|
|
267
379
|
} else {
|
|
268
380
|
spinner.fail(chalk.red('2FA verification failed.'));
|
|
269
381
|
}
|
|
270
382
|
} else if (data.token) {
|
|
271
|
-
this.createProfileFromToken(data.token, answers.username, host, spinner, save);
|
|
383
|
+
await this.createProfileFromToken(data.token, answers.username, host, spinner, save);
|
|
272
384
|
} else {
|
|
273
385
|
spinner.fail(chalk.red(data.error?.message || 'Login failed. Please check your credentials.'));
|
|
274
386
|
}
|
|
@@ -281,19 +393,23 @@ class ProfileModule {
|
|
|
281
393
|
}
|
|
282
394
|
}
|
|
283
395
|
|
|
284
|
-
createProfileFromToken(token, username, host, spinner, save) {
|
|
285
|
-
|
|
396
|
+
async createProfileFromToken(token, username, host, spinner, save) {
|
|
397
|
+
// The server is the authority on both the account id and the spelling of
|
|
398
|
+
// the username, so ask it rather than trusting what was typed.
|
|
399
|
+
const userInfo = await this.fetchIdentity(token);
|
|
286
400
|
const profile = {
|
|
287
401
|
host,
|
|
288
|
-
|
|
289
|
-
|
|
402
|
+
// Display label only; re-resolved from the token on every run.
|
|
403
|
+
username: userInfo.username,
|
|
404
|
+
cwd: `/${userInfo.username}`,
|
|
290
405
|
token,
|
|
291
|
-
|
|
406
|
+
// The account's own id, as reported by the server.
|
|
407
|
+
uuid: userInfo.uuid,
|
|
292
408
|
};
|
|
293
409
|
|
|
294
410
|
this.addProfile(profile);
|
|
295
411
|
this.selectProfile(profile);
|
|
296
|
-
spinner.succeed(chalk.green(`Successfully logged in as ${username}!`));
|
|
412
|
+
spinner.succeed(chalk.green(`Successfully logged in as ${userInfo.username}!`));
|
|
297
413
|
|
|
298
414
|
// Handle --save option
|
|
299
415
|
this.saveTokenToEnv(token, save);
|
package/src/utils.js
CHANGED
|
@@ -1,24 +1,3 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import yargsParser from 'yargs-parser';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Convert "2024-10-07T15:03:53.000Z" to "10/7/2024, 15:03:53"
|
|
6
|
-
* @param {Date} value date value
|
|
7
|
-
* @returns formatted date string
|
|
8
|
-
*/
|
|
9
|
-
export function formatDate(value) {
|
|
10
|
-
const date = new Date(value);
|
|
11
|
-
return date.toLocaleString("en-US", {
|
|
12
|
-
year: "numeric",
|
|
13
|
-
month: "2-digit",
|
|
14
|
-
day: "2-digit",
|
|
15
|
-
hour: "2-digit",
|
|
16
|
-
minute: "2-digit",
|
|
17
|
-
second: "2-digit",
|
|
18
|
-
hour12: false,
|
|
19
|
-
timeZone: 'UTC'
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
1
|
|
|
23
2
|
/**
|
|
24
3
|
* Format timestamp to date or time
|
|
@@ -52,76 +31,6 @@ export function formatSize(size) {
|
|
|
52
31
|
return `${size.toFixed(1)} ${units[unit]}`;
|
|
53
32
|
}
|
|
54
33
|
|
|
55
|
-
/**
|
|
56
|
-
* Display non null values in formatted table
|
|
57
|
-
* @param {Object} data Object to display
|
|
58
|
-
* @returns null
|
|
59
|
-
*/
|
|
60
|
-
export function displayNonNullValues(data) {
|
|
61
|
-
if (typeof data !== 'object' || data === null) {
|
|
62
|
-
console.error("Invalid input: Input must be a non-null object.");
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
const tableData = [];
|
|
66
|
-
function flattenObject(obj, parentKey = '') {
|
|
67
|
-
for (const key in obj) {
|
|
68
|
-
const value = obj[key];
|
|
69
|
-
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
|
70
|
-
if (value !== null) {
|
|
71
|
-
if (typeof value === 'object') {
|
|
72
|
-
flattenObject(value, newKey);
|
|
73
|
-
} else {
|
|
74
|
-
tableData.push({ key: newKey, value: value });
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
flattenObject(data);
|
|
81
|
-
// Determine max key length for formatting
|
|
82
|
-
const maxKeyLength = tableData.reduce((max, item) => Math.max(max, item.key.length), 0);
|
|
83
|
-
// Format and output the table
|
|
84
|
-
console.log(chalk.cyan('-'.repeat(maxKeyLength*3)));
|
|
85
|
-
console.log(chalk.cyan(`| ${'Key'.padEnd(maxKeyLength)} | Value`));
|
|
86
|
-
console.log(chalk.cyan('-'.repeat(maxKeyLength*3)));
|
|
87
|
-
tableData.forEach(item => {
|
|
88
|
-
const key = item.key.padEnd(maxKeyLength);
|
|
89
|
-
const value = String(item.value);
|
|
90
|
-
console.log(chalk.green(`| ${chalk.dim(key)} | ${value}`));
|
|
91
|
-
});
|
|
92
|
-
console.log(chalk.cyan('-'.repeat(maxKeyLength*3)));
|
|
93
|
-
console.log(chalk.cyan(`You have ${chalk.green(tableData.length)} key/value pair(s).`));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Parse command line arguments including quoted strings
|
|
98
|
-
* @param {string} input Raw command line input
|
|
99
|
-
* @returns {Object} Parsed arguments
|
|
100
|
-
*/
|
|
101
|
-
export function parseArgs(input, options = {}) {
|
|
102
|
-
const result = yargsParser(input, options);
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Checks if a given string is a valid UUID of any version
|
|
108
|
-
* @param {string} uuid - The string to validate.
|
|
109
|
-
* @returns {boolean} - True if the string is a valid UUID, false otherwise.
|
|
110
|
-
*/
|
|
111
|
-
export function isValidAppUuid (uuid) {
|
|
112
|
-
return uuid.startsWith('app-') && is_valid_uuid4(uuid.slice(4));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Checks if a given string is a valid UUID version 4.
|
|
117
|
-
* @param {string} uuid - The string to validate.
|
|
118
|
-
* @returns {boolean} - True if the string is a valid UUID version 4, false otherwise.
|
|
119
|
-
*/
|
|
120
|
-
export function is_valid_uuid4 (uuid) {
|
|
121
|
-
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
122
|
-
return uuidV4Regex.test(uuid);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
34
|
/**
|
|
126
35
|
* Get system editor
|
|
127
36
|
* @returns {string} - System editor
|
|
@@ -4,6 +4,7 @@ vi.spyOn(console, "log").mockImplementation(() => { });
|
|
|
4
4
|
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
5
5
|
|
|
6
6
|
let errors, report, ERROR_BUFFER_LIMIT, showLast;
|
|
7
|
+
let normalizeError, isAuthError, formatError;
|
|
7
8
|
|
|
8
9
|
beforeEach(async () => {
|
|
9
10
|
vi.resetModules();
|
|
@@ -12,6 +13,9 @@ beforeEach(async () => {
|
|
|
12
13
|
report = module.report;
|
|
13
14
|
ERROR_BUFFER_LIMIT = module.ERROR_BUFFER_LIMIT;
|
|
14
15
|
showLast = module.showLast;
|
|
16
|
+
normalizeError = module.normalizeError;
|
|
17
|
+
isAuthError = module.isAuthError;
|
|
18
|
+
formatError = module.formatError;
|
|
15
19
|
});
|
|
16
20
|
|
|
17
21
|
describe("report", () => {
|
|
@@ -39,4 +43,72 @@ describe("showLast", () => {
|
|
|
39
43
|
showLast();
|
|
40
44
|
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("hehe"));
|
|
41
45
|
})
|
|
42
|
-
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('normalizeError', () => {
|
|
49
|
+
it('should turn a plain rejected object into a real Error', () => {
|
|
50
|
+
// The shape puter.js rejects with, which Node renders as "#<Object>".
|
|
51
|
+
const err = normalizeError({ status: 401, message: 'Unauthorized' });
|
|
52
|
+
|
|
53
|
+
expect(err).toBeInstanceOf(Error);
|
|
54
|
+
expect(err.message).toBe('Unauthorized');
|
|
55
|
+
expect(err.status).toBe(401);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should unwrap a nested driver error', () => {
|
|
59
|
+
const err = normalizeError({ error: { code: 'permission_denied', message: 'Nope' } });
|
|
60
|
+
|
|
61
|
+
expect(err.message).toBe('Nope');
|
|
62
|
+
expect(err.code).toBe('permission_denied');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should fall back to the code when there is no message', () => {
|
|
66
|
+
expect(normalizeError({ code: 'subject_does_not_exist' }).message)
|
|
67
|
+
.toBe('subject_does_not_exist');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should never render an object as "#<Object>"', () => {
|
|
71
|
+
const err = normalizeError({ status: 500 });
|
|
72
|
+
|
|
73
|
+
expect(err.message).not.toContain('#<Object>');
|
|
74
|
+
expect(err.message).toBe('{"status":500}');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should pass an Error through untouched', () => {
|
|
78
|
+
const original = new Error('boom');
|
|
79
|
+
expect(normalizeError(original)).toBe(original);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should handle non-object throws', () => {
|
|
83
|
+
expect(normalizeError('bad thing').message).toBe('bad thing');
|
|
84
|
+
expect(normalizeError(undefined).message).toBe('Unknown error');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('isAuthError', () => {
|
|
89
|
+
it('should detect the 401 that puter.js rejects with', () => {
|
|
90
|
+
expect(isAuthError({ status: 401, message: 'Unauthorized' })).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should detect a 403', () => {
|
|
94
|
+
expect(isAuthError({ status: 403, message: 'Forbidden' })).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should detect token failure codes', () => {
|
|
98
|
+
expect(isAuthError({ code: 'token_auth_failed' })).toBe(true);
|
|
99
|
+
expect(isAuthError({ error: { code: 'invalid_token' } })).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should not flag unrelated failures', () => {
|
|
103
|
+
expect(isAuthError({ status: 500, message: 'Server error' })).toBe(false);
|
|
104
|
+
expect(isAuthError({ code: 'subject_does_not_exist' })).toBe(false);
|
|
105
|
+
expect(isAuthError(new Error('ENOTFOUND api.puter.com'))).toBe(false);
|
|
106
|
+
expect(isAuthError(undefined)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('formatError', () => {
|
|
111
|
+
it('should give a readable message for a rejected plain object', () => {
|
|
112
|
+
expect(formatError({ status: 401, message: 'Unauthorized' })).toBe('Unauthorized');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -17,11 +17,12 @@ vi.mock('conf', () => {
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
vi.mock('../src/commons.js', () => ({
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
PROJECT_NAME: 'puter-cli',
|
|
20
|
+
HOME: '~',
|
|
21
|
+
PROJECT_NAME: 'puter-sh',
|
|
23
22
|
getHeaders: vi.fn(() => ({ 'Content-Type': 'application/json' })),
|
|
24
23
|
reconfigureURLs: vi.fn(),
|
|
24
|
+
setHomePath: vi.fn(),
|
|
25
|
+
expandHome: vi.fn((p) => p),
|
|
25
26
|
}));
|
|
26
27
|
|
|
27
28
|
vi.mock('./PuterModule.js', () => ({
|
|
@@ -137,8 +138,12 @@ describe('ProfileModule.selectProfile', () => {
|
|
|
137
138
|
profileModule.selectProfile(profile);
|
|
138
139
|
|
|
139
140
|
expect(mockConfig.set).toHaveBeenCalledWith('selected_profile', 'test-uuid');
|
|
140
|
-
|
|
141
|
-
expect(mockConfig.set).toHaveBeenCalledWith('
|
|
141
|
+
// username and cwd live on the profile now, not at the top level.
|
|
142
|
+
expect(mockConfig.set).not.toHaveBeenCalledWith('username', expect.anything());
|
|
143
|
+
expect(mockConfig.set).not.toHaveBeenCalledWith('cwd', expect.anything());
|
|
144
|
+
expect(mockConfig.set).toHaveBeenCalledWith('profiles', [
|
|
145
|
+
expect.objectContaining({ uuid: 'test-uuid', cwd: '/testuser' }),
|
|
146
|
+
]);
|
|
142
147
|
});
|
|
143
148
|
});
|
|
144
149
|
|
|
@@ -216,59 +221,68 @@ describe('ProfileModule.getAuthToken', () => {
|
|
|
216
221
|
});
|
|
217
222
|
});
|
|
218
223
|
|
|
219
|
-
describe('ProfileModule.
|
|
220
|
-
|
|
224
|
+
describe('ProfileModule.rehomePaths', () => {
|
|
225
|
+
const setup = (cwd, profile) => {
|
|
226
|
+
const stored = { ...profile, cwd };
|
|
221
227
|
mockConfig.get.mockImplementation((key) => {
|
|
222
|
-
if (key === '
|
|
223
|
-
if (key === '
|
|
228
|
+
if (key === 'profiles') return [stored];
|
|
229
|
+
if (key === 'selected_profile') return stored.uuid;
|
|
224
230
|
return undefined;
|
|
225
231
|
});
|
|
226
|
-
|
|
227
232
|
initProfileModule();
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
return getProfileModule();
|
|
234
|
+
};
|
|
230
235
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
it('should re-point the cwd at the new home after a rename', () => {
|
|
237
|
+
const profile = { uuid: 'p1', username: 'oldname', cwd: '/oldname', host: 'https://puter.com' };
|
|
238
|
+
const profileModule = setup('/oldname/Desktop/notes', profile);
|
|
239
|
+
|
|
240
|
+
profileModule.rehomePaths('oldname', 'newname');
|
|
241
|
+
|
|
242
|
+
expect(mockConfig.set).toHaveBeenCalledWith('profiles', [
|
|
243
|
+
expect.objectContaining({ cwd: '/newname/Desktop/notes' }),
|
|
244
|
+
]);
|
|
236
245
|
});
|
|
237
246
|
|
|
238
|
-
it('should
|
|
239
|
-
|
|
247
|
+
it('should re-point a cwd sitting exactly at the old home', () => {
|
|
248
|
+
const profile = { uuid: 'p1', username: 'oldname', cwd: '/oldname', host: 'https://puter.com' };
|
|
249
|
+
const profileModule = setup('/oldname', profile);
|
|
240
250
|
|
|
241
|
-
|
|
242
|
-
const profileModule = getProfileModule();
|
|
243
|
-
const defaultProfile = profileModule.getDefaultProfile();
|
|
251
|
+
profileModule.rehomePaths('oldname', 'newname');
|
|
244
252
|
|
|
245
|
-
expect(
|
|
253
|
+
expect(mockConfig.set).toHaveBeenCalledWith('profiles', [
|
|
254
|
+
expect.objectContaining({ cwd: '/newname' }),
|
|
255
|
+
]);
|
|
246
256
|
});
|
|
247
|
-
});
|
|
248
257
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (key === 'auth_token') return 'legacy-token';
|
|
253
|
-
if (key === 'username') return 'legacyuser';
|
|
254
|
-
if (key === 'profiles') return [];
|
|
255
|
-
return undefined;
|
|
256
|
-
});
|
|
258
|
+
it('should leave a cwd under another user\'s tree alone', () => {
|
|
259
|
+
const profile = { uuid: 'p1', username: 'oldname', cwd: '/oldname', host: 'https://puter.com' };
|
|
260
|
+
const profileModule = setup('/someoneelse/public', profile);
|
|
257
261
|
|
|
258
|
-
|
|
259
|
-
const profileModule = getProfileModule();
|
|
260
|
-
profileModule.migrateLegacyConfig();
|
|
262
|
+
profileModule.rehomePaths('oldname', 'newname');
|
|
261
263
|
|
|
262
264
|
expect(mockConfig.set).toHaveBeenCalledWith('profiles', [
|
|
263
|
-
{
|
|
264
|
-
host: 'https://puter.com',
|
|
265
|
-
username: 'legacyuser',
|
|
266
|
-
cwd: '/legacyuser',
|
|
267
|
-
token: 'legacy-token',
|
|
268
|
-
uuid: '00000000-0000-0000-0000-000000000000',
|
|
269
|
-
},
|
|
265
|
+
expect.objectContaining({ cwd: '/someoneelse/public' }),
|
|
270
266
|
]);
|
|
271
|
-
|
|
272
|
-
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should not rewrite a prefix that only partially matches', () => {
|
|
270
|
+
const profile = { uuid: 'p1', username: 'bob', cwd: '/bob', host: 'https://puter.com' };
|
|
271
|
+
const profileModule = setup('/bobby/files', profile);
|
|
272
|
+
|
|
273
|
+
profileModule.rehomePaths('bob', 'robert');
|
|
274
|
+
|
|
275
|
+
expect(mockConfig.set).toHaveBeenCalledWith('profiles', [
|
|
276
|
+
expect.objectContaining({ cwd: '/bobby/files' }),
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should do nothing when the username is unchanged', () => {
|
|
281
|
+
const profile = { uuid: 'p1', username: 'bob', cwd: '/bob', host: 'https://puter.com' };
|
|
282
|
+
const profileModule = setup('/bob/files', profile);
|
|
283
|
+
|
|
284
|
+
profileModule.rehomePaths('bob', 'bob');
|
|
285
|
+
|
|
286
|
+
expect(mockConfig.set).not.toHaveBeenCalled();
|
|
273
287
|
});
|
|
274
288
|
});
|