@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.
@@ -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 { BASE_URL, NULL_UUID, PROJECT_NAME, getHeaders, reconfigureURLs } from '../commons.js'
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
- config.delete('auth_token');
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
- getDefaultProfile() {
66
- const auth_token = config.get('auth_token');
67
- if (!auth_token) return;
68
- return {
69
- host: 'puter.com',
70
- username: config.get('username'),
71
- token: auth_token,
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
- ...this.getProfiles().filter(p => !p.transient),
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
- config.set('username', `${profile.username}`);
88
- config.set('cwd', `/${profile.username}`);
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
- uuid: profileUUID,
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
- const profileUUID = crypto.randomUUID();
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
- username,
289
- cwd: `/${username}`,
402
+ // Display label only; re-resolved from the token on every run.
403
+ username: userInfo.username,
404
+ cwd: `/${userInfo.username}`,
290
405
  token,
291
- uuid: profileUUID,
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
- BASE_URL: 'https://puter.com',
21
- NULL_UUID: '00000000-0000-0000-0000-000000000000',
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
- expect(mockConfig.set).toHaveBeenCalledWith('username', 'testuser');
141
- expect(mockConfig.set).toHaveBeenCalledWith('cwd', '/testuser');
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.getDefaultProfile', () => {
220
- it('should return default profile if auth_token exists', () => {
224
+ describe('ProfileModule.rehomePaths', () => {
225
+ const setup = (cwd, profile) => {
226
+ const stored = { ...profile, cwd };
221
227
  mockConfig.get.mockImplementation((key) => {
222
- if (key === 'auth_token') return 'legacy-token';
223
- if (key === 'username') return 'legacyuser';
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
- const profileModule = getProfileModule();
229
- const defaultProfile = profileModule.getDefaultProfile();
233
+ return getProfileModule();
234
+ };
230
235
 
231
- expect(defaultProfile).toEqual({
232
- host: 'puter.com',
233
- username: 'legacyuser',
234
- token: 'legacy-token',
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 return undefined if no auth_token exists', () => {
239
- mockConfig.get.mockReturnValue(undefined);
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
- initProfileModule();
242
- const profileModule = getProfileModule();
243
- const defaultProfile = profileModule.getDefaultProfile();
251
+ profileModule.rehomePaths('oldname', 'newname');
244
252
 
245
- expect(defaultProfile).toBeUndefined();
253
+ expect(mockConfig.set).toHaveBeenCalledWith('profiles', [
254
+ expect.objectContaining({ cwd: '/newname' }),
255
+ ]);
246
256
  });
247
- });
248
257
 
249
- describe('ProfileModule.migrateLegacyConfig', () => {
250
- it('should migrate legacy config to profile format', () => {
251
- mockConfig.get.mockImplementation((key) => {
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
- initProfileModule();
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
- expect(mockConfig.delete).toHaveBeenCalledWith('auth_token');
272
- expect(mockConfig.delete).toHaveBeenCalledWith('username');
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
  });