diff --git a/src/config.ts b/src/config.ts index a7edf1d..af39f95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,17 +19,55 @@ 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, +// 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. +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 - return { ...parsed, apiUrl: envApi ?? parsed.apiUrl ?? DEFAULT_API } + if (envApi) return { ...parsed, apiUrl: envApi } + if (typeof parsed.apiUrl === 'string' && isRetired(parsed.apiUrl)) { + // 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 + 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 new file mode 100644 index 0000000..47b9b5d --- /dev/null +++ b/test/config-api-url.test.ts @@ -0,0 +1,93 @@ +// 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('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, 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 () => { + 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) +})