Skip to content
Merged
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
7 changes: 4 additions & 3 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { readGlobal, writeGlobal, readProject, writeProject, type GlobalConfig, type ProjectConfig } from './config.js'
import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-project.js'
import { die } from './util.js'
import { USER_AGENT } from './version.js'

export class ApiError extends Error {
// body carries the parsed error payload for callers that branch on machine-readable errors
Expand All @@ -20,7 +21,7 @@ export function storeApiKeyCredential(cfg: GlobalConfig, token: string, user?: G
type RawResult = { status: number; body: any }

export class ApiClient {
constructor(private cfg: GlobalConfig) {}
constructor(private cfg: GlobalConfig, private readonly fetchImpl: typeof fetch = fetch) {}

static async load(): Promise<ApiClient> { return new ApiClient(await readGlobal()) }

Expand Down Expand Up @@ -71,9 +72,9 @@ export class ApiClient {
}

private async fetch(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Insta-Hints': '1' }
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT }
if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}`
const res = await fetch(this.apiUrl + path, {
const res = await this.fetchImpl(this.apiUrl + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
Expand Down
14 changes: 2 additions & 12 deletions src/commands/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { readGlobal, readProject } from '../config.js'
import { envForApiUrl } from '../env.js'
import { info, printJson, CliCancel } from '../util.js'
import { clean } from '../redact.js'
import { cliVersion } from '../version.js'

export const TYPES = ['bug', 'feature-request', 'friction', 'other'] as const
export const COMPONENTS = ['cli', 'mcp', 'platform', 'skills', 'docs', 'other'] as const
Expand Down Expand Up @@ -70,17 +71,6 @@ export type FeedbackDeps = {
cliVersion?: string
}

function resolveCliVersion(): string {
// Same resolution as index.ts: the standalone binary bakes INSTA_CLI_VERSION via --define;
// npm/node reads the installed package.json next to dist/.
if (process.env.INSTA_CLI_VERSION) return process.env.INSTA_CLI_VERSION
try {
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version as string
} catch {
return '0.0.0'
}
}

function requireEnum(value: string, allowed: readonly string[], flag: string): string {
if (!allowed.includes(value)) {
throw new Error(`${flag} must be one of: ${allowed.join(', ')}`)
Expand Down Expand Up @@ -238,7 +228,7 @@ export async function feedback(opts: FeedbackOpts, deps: FeedbackDeps = {}): Pro
// transport-failure shapes) instead of guard()'s plaintext stderr line.
let payload: Record<string, unknown>
try {
payload = await buildPayload(opts, { cliVersion: deps.cliVersion ?? resolveCliVersion() })
payload = await buildPayload(opts, { cliVersion: deps.cliVersion ?? cliVersion() })
} catch (e) {
if (!opts.json) throw e
printJson({ status: 'error', submitted: false, error: e instanceof Error ? e.message : String(e) })
Expand Down
20 changes: 6 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs'
import { Command } from 'commander'
import { ApiError } from './api.js'
import { CliCancel, CliExit, fail, relayedExitCode } from './util.js'
import { trackCommand } from './telemetry.js'
import { cliVersion } from './version.js'
import * as auth from './commands/auth.js'
import * as envCmd_ from './commands/env.js'
import { ENV_NAMES } from './env.js'
Expand Down Expand Up @@ -46,7 +46,7 @@ const guard = (fn: (...a: any[]) => Promise<unknown>) => async (...a: any[]): Pr
try { await fn(...a) } catch (e) { error = e; onError(e) }
await trackCommand(a[a.length - 1] as Command, a.slice(0, -2), {
error, durationMs: Date.now() - started, exitCode: Number(process.exitCode ?? 0), childExitCode: relayedExitCode(),
}, resolveVersion())
}, cliVersion())
}

const program = new Command()
Expand All @@ -58,15 +58,7 @@ const program = new Command()
// group's own options only match before the subcommand name, so occurrences after it are matched
// against the subcommand's own (identically-named) option instead.
program.enablePositionalOptions()
// Version resolution: INSTA_CLI_VERSION (baked into the standalone binary via bun build --define) →
// the installed package.json (npm/node — ../package.json sits beside dist/) → 0.0.0.
function resolveVersion(): string {
if (process.env.INSTA_CLI_VERSION) return process.env.INSTA_CLI_VERSION
try {
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version as string
} catch { return '0.0.0' }
}
program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(resolveVersion())
program.name('insta').description('InstaCloud CLI — manage projects, branches, secrets, deploys').version(cliVersion())

// ---- auth ----
program.command('login').description('Log in — bare: sign in from your browser (any account type); or --email <email> + password, --oauth <github|google>, --device (headless), --api-key <insta_…> (headless, durable token)')
Expand Down Expand Up @@ -386,10 +378,10 @@ program.command('feedback')

// ---- self-update ----
program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
.action(guard(() => selfUpdate.upgrade(resolveVersion())))
.action(guard(() => selfUpdate.upgrade(cliVersion())))
program.command('autoupdate [mode]').description('Show or set auto-update: on | off (default: on while pre-1.0)')
.action(guard((mode) => selfUpdate.autoupdate(mode)))
program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(resolveVersion())))
program.command('__update-check', { hidden: true }).action(guard(() => selfUpdate.backgroundCheck(cliVersion())))

selfUpdate.maybeUpdate(resolveVersion(), process.argv)
selfUpdate.maybeUpdate(cliVersion(), process.argv)
program.parseAsync(computeArgv)
13 changes: 13 additions & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'

// bun build --define bakes INSTA_CLI_VERSION into the standalone binary, which has no package.json to read.
export function cliVersion(): string {
if (process.env.INSTA_CLI_VERSION) return process.env.INSTA_CLI_VERSION
try {
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version as string
} catch {
return '0.0.0'
}
}

export const USER_AGENT = `insta-cli/${cliVersion()}`
13 changes: 13 additions & 0 deletions test/user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { ApiClient } from '../src/api.js'
import { USER_AGENT, cliVersion } from '../src/version.js'

describe('User-Agent', () => {
it('names the CLI and its version so the platform can attribute the call', async () => {
expect(USER_AGENT).toBe(`insta-cli/${cliVersion()}`)
let seen: Record<string, string> = {}
const fetchImpl = (async (_url: any, init: any) => { seen = init.headers; return new Response('{}', { status: 200 }) }) as typeof fetch
await new ApiClient({ apiUrl: 'https://api.test' }, fetchImpl).request('GET', '/me', undefined, { auth: false })
expect(seen['User-Agent']).toBe(USER_AGENT)
})
})
Loading