Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalConfig> {
// 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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: With INSTA_API_URL set, retired configurations skip session cleanup and the recovery notice. An authenticated request that gets a 401 can then POST the retired deployment’s refresh token to the env-selected endpoint; detect/clear the retired persisted URL before applying the env API override.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/config.ts, line 55:

<comment>With `INSTA_API_URL` set, retired configurations skip session cleanup and the recovery notice. An authenticated request that gets a 401 can then POST the retired deployment’s refresh token to the env-selected endpoint; detect/clear the retired persisted URL before applying the env API override.</comment>

<file context>
@@ -19,32 +19,55 @@ export type GlobalConfig = {
-    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: it was minted by a different deployment (see
</file context>

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 }
}
Expand Down
93 changes: 93 additions & 0 deletions test/config-api-url.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A failed assertion in the stderr/stdout test leaves both process stream spies installed because cleanup is performed only after the assertions. Restoring the spies in a finally block or from afterEach would keep one test failure from contaminating later tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/config-api-url.test.ts, line 58:

<comment>A failed assertion in the stderr/stdout test leaves both process stream spies installed because cleanup is performed only after the assertions. Restoring the spies in a `finally` block or from `afterEach` would keep one test failure from contaminating later tests.</comment>

<file context>
@@ -35,11 +35,32 @@ test('a persisted retired host is replaced by the current default', async () =>
+  await (await load()).readGlobal()
+  expect(err).toHaveBeenCalledWith(expect.stringContaining(RETIRED))
+  expect(out).not.toHaveBeenCalled()
+  err.mockRestore(); out.mockRestore()
+})
+
</file context>

})

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)
})
Loading