From 9ac0a939083a5f5eaa501790711ff98eb49ae32d Mon Sep 17 00:00:00 2001 From: engineer-bot Date: Mon, 27 Jul 2026 15:21:42 +0000 Subject: [PATCH 1/3] fix(config): unpin installs frozen on the retired beta-api host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readGlobal materialises DEFAULT_API into the object persist() writes back, so whatever the default was at a user's first login is frozen into config.json permanently. beta-api.insta.insforge.dev was the default in v0.0.3..v0.0.16 and went NXDOMAIN on 2026-07-27, leaving those installs hard-broken with no escape except INSTA_API_URL or hand-editing the file — upgrading the binary cannot fix them, and every fresh install looks fine. Treat a retired host as absent on read so it falls through to DEFAULT_API. Resolving on read fixes every invocation immediately; the file heals itself the next time any command persists. A persisted localhost or self-hosted URL is a deliberate choice and still wins, as does INSTA_API_URL. Co-Authored-By: Claude Opus 5 --- src/config.ts | 17 ++++++++- test/config-api-url.test.ts | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 test/config-api-url.test.ts diff --git a/src/config.ts b/src/config.ts index a7edf1d..c90ae25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,13 +23,28 @@ export type ProjectConfig = { projectId: string; orgId: string; branch: string } // Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below. const DEFAULT_API = 'https://api.instacloud.com' +// Hosts that used to be DEFAULT_API and no longer resolve. `readGlobal` materialises the +// default into the object `persist()` writes back, so whatever DEFAULT_API was at a user's +// first login is frozen into their config.json forever — upgrading the binary can't move it. +// beta-api was the default in v0.0.3..v0.0.16 and went NXDOMAIN on 2026-07-27, so those +// installs are hard-broken until the value is replaced. Retired hosts only: a persisted +// localhost or self-hosted URL is a deliberate choice and must keep winning. +const RETIRED_API_URLS = new Set(['https://beta-api.insta.insforge.dev']) + +const isRetired = (url: string): boolean => RETIRED_API_URLS.has(url.replace(/\/+$/, '')) + export async function readGlobal(): Promise { // INSTA_API_URL overrides the persisted apiUrl, not just the default — otherwise the // env var is silently ignored as soon as any login has written a config file. const envApi = process.env.INSTA_API_URL try { const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig - return { ...parsed, apiUrl: envApi ?? parsed.apiUrl ?? DEFAULT_API } + // A retired host is treated as absent, so it falls through to DEFAULT_API. Resolving on + // read (rather than rewriting the file here) fixes every invocation immediately, and the + // file itself heals the next time any command persists the config. + const persisted = + typeof parsed.apiUrl === 'string' && parsed.apiUrl && !isRetired(parsed.apiUrl) ? parsed.apiUrl : undefined + return { ...parsed, apiUrl: envApi ?? persisted ?? DEFAULT_API } } catch { return { apiUrl: envApi ?? DEFAULT_API } } diff --git a/test/config-api-url.test.ts b/test/config-api-url.test.ts new file mode 100644 index 0000000..b5d8268 --- /dev/null +++ b/test/config-api-url.test.ts @@ -0,0 +1,72 @@ +// readGlobal freezes whatever DEFAULT_API was in force at first login into ~/.insta/config.json +// (persist() writes the resolved object straight back), so installs from v0.0.3..v0.0.16 carry +// the retired beta-api host permanently and upgrading the binary does not move them. These cover +// the retirement path and, just as importantly, the values that must NOT be rewritten. +import { test, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const RETIRED = 'https://beta-api.insta.insforge.dev' +const DEFAULT_API = 'https://api.instacloud.com' + +/** Point HOME at a fresh dir, optionally seeding ~/.insta/config.json. */ +function seedHome(global: Record | null): string { + const home = mkdtempSync(join(tmpdir(), 'insta-home-')) + process.env.HOME = home + if (global) { + mkdirSync(join(home, '.insta'), { recursive: true }) + writeFileSync(join(home, '.insta', 'config.json'), JSON.stringify(global)) + } + return home +} + +/** GLOBAL_FILE is resolved at module load, so HOME must be set before each import. */ +const load = () => { vi.resetModules(); return import('../src/config.js') } + +const readRaw = (home: string) => + JSON.parse(readFileSync(join(home, '.insta', 'config.json'), 'utf8')) as { apiUrl: string } + +beforeEach(() => { delete process.env.INSTA_API_URL }) +afterEach(() => { delete process.env.INSTA_API_URL; vi.resetModules() }) + +test('a persisted retired host is replaced by the current default', async () => { + seedHome({ apiUrl: RETIRED, accessToken: 't' }) + expect((await (await load()).readGlobal()).apiUrl).toBe(DEFAULT_API) +}) + +test('replacing the host keeps the rest of the config intact', async () => { + seedHome({ apiUrl: RETIRED, accessToken: 't', autoUpdate: false }) + expect(await (await load()).readGlobal()).toMatchObject({ + apiUrl: DEFAULT_API, accessToken: 't', autoUpdate: false, + }) +}) + +test('a trailing slash does not smuggle the retired host through', async () => { + seedHome({ apiUrl: `${RETIRED}/` }) + expect((await (await load()).readGlobal()).apiUrl).toBe(DEFAULT_API) +}) + +test('the healed value reaches disk the next time any command persists', async () => { + const home = seedHome({ apiUrl: RETIRED, accessToken: 't' }) + expect(readRaw(home).apiUrl).toBe(RETIRED) // precondition: the stale value really is on disk + const { readGlobal, writeGlobal } = await load() + await writeGlobal(await readGlobal()) // what upgrade.ts and ApiClient.persist() both do + expect(readRaw(home).apiUrl).toBe(DEFAULT_API) +}) + +test('a deliberate self-hosted or localhost apiUrl still wins', async () => { + seedHome({ apiUrl: 'http://localhost:8080' }) + expect((await (await load()).readGlobal()).apiUrl).toBe('http://localhost:8080') +}) + +test('INSTA_API_URL beats both the retired host and the default', async () => { + seedHome({ apiUrl: RETIRED }) + process.env.INSTA_API_URL = 'https://api.example.test' + expect((await (await load()).readGlobal()).apiUrl).toBe('https://api.example.test') +}) + +test('with no config file at all the default is used', async () => { + seedHome(null) + expect((await (await load()).readGlobal()).apiUrl).toBe(DEFAULT_API) +}) From d2efa0d60d7d5d0a9746a02f26a04b245eb59fdb Mon Sep 17 00:00:00 2001 From: engineer-bot Date: Mon, 27 Jul 2026 15:27:27 +0000 Subject: [PATCH 2/3] fix(config): drop the retired host's session with it, and correct the backend comment The retired host was a different deployment, not a rebrand: beta-api resolved to insta-beta-api-lb in us-west-1, while DEFAULT_API is insta-platform-prod-alb in us-east-2. So a session minted there is not valid at the new host, and repointing the URL alone would have traded 'fetch failed' for an opaque 401 that reads as a bug in this migration. Drop accessToken/refreshToken/user alongside the host and print one stderr line pointing at 'insta login'. stderr, not stdout, so --json output stays parseable. The 'same backend, branded domain' comment that implied portability was wrong; corrected in place. Co-Authored-By: Claude Opus 5 --- src/config.ts | 38 +++++++++++++++++++++++++++++-------- test/config-api-url.test.ts | 29 ++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/config.ts b/src/config.ts index c90ae25..dfc299d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,13 +19,16 @@ export type GlobalConfig = { export type ProjectConfig = { projectId: string; orgId: string; branch: string } // The cloud API default. Uses the instacloud.com brand domain (matches the agents.instacloud.com -// onboarding), NOT the legacy beta-api.insta.insforge.dev host — same backend, branded domain. +// onboarding). It is NOT a rebrand of the legacy beta-api.insta.insforge.dev host: that name +// resolved to insta-beta-api-lb in us-west-1, while this one is insta-platform-prod-alb in +// us-east-2 — a separate deployment, so sessions do not carry across (see RETIRED_API_URLS). // Only affects fresh installs: a persisted apiUrl (from a prior login) or INSTA_API_URL wins below. const DEFAULT_API = 'https://api.instacloud.com' // Hosts that used to be DEFAULT_API and no longer resolve. `readGlobal` materialises the // default into the object `persist()` writes back, so whatever DEFAULT_API was at a user's -// first login is frozen into their config.json forever — upgrading the binary can't move it. +// first login is frozen into their config.json forever — upgrading the binary can't move it, +// and neither can logging in again (login only calls setApiUrl when --api-url is passed). // beta-api was the default in v0.0.3..v0.0.16 and went NXDOMAIN on 2026-07-27, so those // installs are hard-broken until the value is replaced. Retired hosts only: a persisted // localhost or self-hosted URL is a deliberate choice and must keep winning. @@ -33,18 +36,37 @@ const RETIRED_API_URLS = new Set(['https://beta-api.insta.insforge.dev']) const isRetired = (url: string): boolean => RETIRED_API_URLS.has(url.replace(/\/+$/, '')) +// Once per process, on stderr — stdout carries `--json` output and must stay machine-readable. +let noticed = false +function noticeRetired(retired: string): void { + if (noticed) return + noticed = true + process.stderr.write( + `note: ${retired} has been retired; using ${DEFAULT_API}. Run \`insta login\` to sign in again.\n`, + ) +} + export async function readGlobal(): Promise { // INSTA_API_URL overrides the persisted apiUrl, not just the default — otherwise the // env var is silently ignored as soon as any login has written a config file. const envApi = process.env.INSTA_API_URL try { const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig - // A retired host is treated as absent, so it falls through to DEFAULT_API. Resolving on - // read (rather than rewriting the file here) fixes every invocation immediately, and the - // file itself heals the next time any command persists the config. - const persisted = - typeof parsed.apiUrl === 'string' && parsed.apiUrl && !isRetired(parsed.apiUrl) ? parsed.apiUrl : undefined - return { ...parsed, apiUrl: envApi ?? persisted ?? DEFAULT_API } + if (envApi) return { ...parsed, apiUrl: envApi } + if (typeof parsed.apiUrl === 'string' && isRetired(parsed.apiUrl)) { + // Drop the stored session with the host. The retired deployment is not the one + // DEFAULT_API points at, so its tokens 401 here — keeping them would swap an obvious + // network failure for an opaque "unauthorized" that reads as a bug in this migration. + // Resolving on read fixes every invocation at once; the file heals on the next persist. + const rest: GlobalConfig = { ...parsed, apiUrl: DEFAULT_API } + delete rest.accessToken + delete rest.refreshToken + delete rest.user + noticeRetired(parsed.apiUrl) + return rest + } + const persisted = typeof parsed.apiUrl === 'string' && parsed.apiUrl ? parsed.apiUrl : DEFAULT_API + return { ...parsed, apiUrl: persisted } } catch { return { apiUrl: envApi ?? DEFAULT_API } } diff --git a/test/config-api-url.test.ts b/test/config-api-url.test.ts index b5d8268..47b9b5d 100644 --- a/test/config-api-url.test.ts +++ b/test/config-api-url.test.ts @@ -35,11 +35,32 @@ test('a persisted retired host is replaced by the current default', async () => expect((await (await load()).readGlobal()).apiUrl).toBe(DEFAULT_API) }) -test('replacing the host keeps the rest of the config intact', async () => { +test('the session minted by the retired deployment is dropped with it', async () => { + seedHome({ apiUrl: RETIRED, accessToken: 'a', refreshToken: 'r', user: { id: 'u', email: null, name: null } }) + const cfg = await (await load()).readGlobal() + expect(cfg.accessToken).toBeUndefined() + expect(cfg.refreshToken).toBeUndefined() + expect(cfg.user).toBeUndefined() +}) + +test('non-credential settings survive the replacement', async () => { seedHome({ apiUrl: RETIRED, accessToken: 't', autoUpdate: false }) - expect(await (await load()).readGlobal()).toMatchObject({ - apiUrl: DEFAULT_API, accessToken: 't', autoUpdate: false, - }) + expect(await (await load()).readGlobal()).toMatchObject({ apiUrl: DEFAULT_API, autoUpdate: false }) +}) + +test('the notice goes to stderr, so --json output on stdout stays parseable', async () => { + seedHome({ apiUrl: RETIRED, accessToken: 't' }) + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const out = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + await (await load()).readGlobal() + expect(err).toHaveBeenCalledWith(expect.stringContaining(RETIRED)) + expect(out).not.toHaveBeenCalled() + err.mockRestore(); out.mockRestore() +}) + +test('a healthy config keeps its session', async () => { + seedHome({ apiUrl: 'https://api.instacloud.com', accessToken: 'a', refreshToken: 'r' }) + expect(await (await load()).readGlobal()).toMatchObject({ accessToken: 'a', refreshToken: 'r' }) }) test('a trailing slash does not smuggle the retired host through', async () => { From 5b2b6b1c020f8db5019d34e94675389f74e6cba2 Mon Sep 17 00:00:00 2001 From: engineer-bot Date: Mon, 27 Jul 2026 15:36:04 +0000 Subject: [PATCH 3/3] docs(config): state the real reason the retired session is dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not the error message — requireProject already dies with an actionable 401 hint and the stderr note covers the rest. The reason is that api.ts's 401 path POSTs the stored refresh token to whatever apiUrl now resolves to, which would send a credential minted by the retired deployment to a different one. Co-Authored-By: Claude Opus 5 --- src/config.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index dfc299d..af39f95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -54,9 +54,10 @@ export async function readGlobal(): Promise { const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as GlobalConfig if (envApi) return { ...parsed, apiUrl: envApi } if (typeof parsed.apiUrl === 'string' && isRetired(parsed.apiUrl)) { - // Drop the stored session with the host. The retired deployment is not the one - // DEFAULT_API points at, so its tokens 401 here — keeping them would swap an obvious - // network failure for an opaque "unauthorized" that reads as a bug in this migration. + // Drop the stored session with the host: it was minted by a different deployment (see + // DEFAULT_API above) and cannot authenticate here. Keeping it is not just useless — + // api.ts's 401 path POSTs the refresh token to whatever apiUrl now resolves to, which + // would send one deployment's credential to another. The stderr note covers recovery. // Resolving on read fixes every invocation at once; the file heals on the next persist. const rest: GlobalConfig = { ...parsed, apiUrl: DEFAULT_API } delete rest.accessToken