diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index b9adb16c7..4069cf115 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -36,6 +36,7 @@ await jest.unstable_mockModule('os', () => { const { resolveInstanceUrl, saveInstance, + setActive, getToken, listInstances, DEFAULT_URL, @@ -154,6 +155,61 @@ describe('config.js', () => { expect(getToken('dev')).toBe('cm_dev'); }); + test('getToken("https://...") prefers the ACTIVE instance when several keys share the URL', () => { + // Real config shape on 2026-08-29: `dev` (saved in July, stale token) and + // `default` (fresh login) both at api.commonly.me. `.find` returned `dev` + // and `commonly agent detach` / `agent wake` got HTTP 401 from a token + // the user had already replaced. + saveInstance({ + key: 'dev', url: 'https://api.commonly.me', token: 'cm_stale', + userId: 'u1', username: 'sam', + }); + saveInstance({ + key: 'default', url: 'https://api.commonly.me', token: 'cm_fresh', + userId: 'u1', username: 'sam', + }); + // saveInstance sets active = last saved key ('default') + expect(getToken('https://api.commonly.me')).toBe('cm_fresh'); + }); + + test('prefers an explicitly-active match over a NEWER one', () => { + // Discriminates the `active` branch from the savedAt sort: the active + // key is the OLDER save, so only the active preference can pick it. + // (The test above is satisfied by savedAt alone — sprint-review mutated + // `active` to null and it stayed green.) + saveInstance({ + key: 'old', url: 'https://api.commonly.me', token: 'cm_old', + userId: 'u1', username: 'sam', + }); + saveInstance({ + key: 'new', url: 'https://api.commonly.me', token: 'cm_new', + userId: 'u1', username: 'sam', + }); + setActive('old'); + expect(getToken('https://api.commonly.me')).toBe('cm_old'); + }); + + test('getToken("https://...") falls back to the most recently saved match when none is active', () => { + saveInstance({ + key: 'old', url: 'https://api.commonly.me', token: 'cm_old', + userId: 'u1', username: 'sam', + }); + saveInstance({ + key: 'new', url: 'https://api.commonly.me', token: 'cm_new', + userId: 'u1', username: 'sam', + }); + // Point `active` somewhere that does NOT share the URL. + saveInstance({ + key: 'local', url: 'http://localhost:5000', token: 'cm_local', + userId: 'u1', username: 'sam', + }); + const cfg = JSON.parse(fs.readFileSync(configFile, 'utf8')); + cfg.instances.old.savedAt = '2026-07-10T05:10:00Z'; + cfg.instances.new.savedAt = '2026-08-26T22:40:12Z'; + fs.writeFileSync(configFile, JSON.stringify(cfg)); + expect(getToken('https://api.commonly.me')).toBe('cm_new'); + }); + test('resolveInstanceUrl matches saved URL case-insensitively (and preserves unknown URL case)', () => { saveInstance({ key: 'dev', url: 'https://api-dev.commonly.me', token: 'cm_dev', diff --git a/cli/package.json b/cli/package.json index 98be2ca4c..98248819f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.23", + "version": "0.1.24", "license": "Apache-2.0", "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/lib/config.js b/cli/src/lib/config.js index 71912b6e9..448fe139e 100644 --- a/cli/src/lib/config.js +++ b/cli/src/lib/config.js @@ -67,10 +67,19 @@ export const resolveInstance = (identifier = null) => { const looksLikeUrl = /^https?:\/\//i.test(identifier); if (looksLikeUrl) { const normalized = normalizeUrl(identifier); - const urlEntry = Object.entries(config.instances) - .find(([, v]) => normalizeUrl(v.url) === normalized); - if (urlEntry) { - const [key, value] = urlEntry; + const matches = Object.entries(config.instances) + .filter(([, v]) => normalizeUrl(v.url) === normalized); + if (matches.length > 0) { + // Several keys can share one URL (`dev` and `default` both pointing at + // api.commonly.me after a re-login under a new key). The first entry in + // file order is then whichever was saved FIRST — the stale one — and + // every command that resolves by the token file's instanceUrl + // (detach, wake) got a 401 from a token the user had already replaced. + // Prefer the active instance, then the most recently saved. + const active = matches.find(([key]) => key === config.active); + const [key, value] = active || matches + .slice() + .sort(([, a], [, b]) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')))[0]; return { key, ...value }; } // Unknown URL — still usable for bootstrapping. Preserve the caller's