From bf9716527ebc5f2e6f4acae5f0c6de1f88e16c6f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:55:21 -0700 Subject: [PATCH 1/2] cli: resolve an instance URL to the active (then newest) entry, not the first saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two config keys can share one URL (dev + default both at api.commonly.me after a re-login). resolveInstance(url) used .find, so the FIRST-saved, stale entry won and every command that resolves by the token file's instanceUrl — detach, the new agent wake — got HTTP 401 from a token the user had already replaced. Prefer the active instance; otherwise the most recently saved. Two tests pin both branches. cli 0.1.24. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B8KUhYa7gxBxrDj71UDYoJ --- cli/__tests__/lib.test.mjs | 38 ++++++++++++++++++++++++++++++++++++++ cli/package.json | 2 +- cli/src/lib/config.js | 17 +++++++++++++---- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index b9adb16c7..411f9e4b2 100644 --- a/cli/__tests__/lib.test.mjs +++ b/cli/__tests__/lib.test.mjs @@ -154,6 +154,44 @@ 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('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 From d4c08cca7fcb3a2133884c90f68d79ce1de6dced Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:10:40 -0700 Subject: [PATCH 2/2] cli: pin the active-instance preference with a test that savedAt cannot satisfy sprint-review mutated the active branch to null and lib.test.mjs stayed green: both new tests reached the answer through savedAt. Added the case where the active key is the OLDER save (setActive back to it), which reds Expected cm_old / Received cm_new under the mutation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B8KUhYa7gxBxrDj71UDYoJ --- cli/__tests__/lib.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cli/__tests__/lib.test.mjs b/cli/__tests__/lib.test.mjs index 411f9e4b2..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, @@ -171,6 +172,23 @@ describe('config.js', () => { 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',