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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ build never reaches a production installer.
| `insta approvals` | `list` · `approve` · `deny` |
| `insta policy` | `get` · `set <action> <decision>` |
| `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit |
| `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out |
| `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update |

## Configuration
Expand Down
235 changes: 235 additions & 0 deletions src/commands/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// `insta feedback` — report an InstaCloud-side hurdle to the InstaCloud team.
//
// Scope rule (also stated in the skill): this is for problems in OUR toolkit — the CLI, the MCP
// server, the platform, the skills, the docs. Never for problems in the app the user is building.
//
// The backend is InstaCloud dogfooding itself: the "InstaCloud Agent Feedback" project runs the
// ingest service (InsForge/insta-feedback repo) on a postgres + compute pair. It is NOT the
// control-plane API on purpose — feedback must work logged-out, unlinked, and from insta-oss,
// and a control-plane outage is exactly when we most want reports to still arrive.
import { readFileSync } from 'node:fs'
import os from 'node:os'
import * as clack from '@clack/prompts'
import { readGlobal, readProject } from '../config.js'
import { envForApiUrl } from '../env.js'
import { info, printJson } from '../util.js'
import { clean } from '../redact.js'

export const TYPES = ['bug', 'feature-request', 'friction', 'other'] as const
export const COMPONENTS = ['cli', 'mcp', 'platform', 'skills', 'docs', 'other'] as const
export const SEVERITIES = ['blocker', 'major', 'minor'] as const

// Field caps mirror the ingest service's LIMITS (insta-feedback src/app.ts) — the server
// truncates again, so a mismatch degrades gracefully instead of rejecting.
export const LIMITS = {
title: 200,
detail: 4000,
area: 100,
command: 500,
error: 2000,
expected: 1000,
workaround: 1000,
doc: 300,
} as const

// Hardcoded in source, not injected at build time: a build-time credential silently no-ops in
// local/tsx and fork builds, and feedback would appear to work while reports vanish. The token is
// public by design (it ships in this file); it only deflects drive-by scanners — real abuse
// control is server-side (per-IP rate limit + weekly dedup). Env overrides are for tests and
// emergency rotation.
const FEEDBACK_ENDPOINT =
process.env.INSTA_FEEDBACK_URL ||
'https://insta-main-api-cdad9b6c.compute.instacloud.com/v1/feedback'
const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedback-public-v1'
const FEEDBACK_TIMEOUT_MS = 10_000

export type FeedbackOpts = {
type?: string
component?: string
title?: string
detail?: string
file?: string
area?: string
command?: string
error?: string
expected?: string
workaround?: string
doc?: string
severity?: string
json?: boolean
}

export type FeedbackDeps = {
fetchImpl?: typeof fetch
/** Prompts run on a real terminal only — an agent's stdin is not one, and must never block. */
interactive?: boolean
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(', ')}`)
}
return value
}

async function promptMissing(opts: FeedbackOpts): Promise<void> {
clack.intro('insta feedback — report an InstaCloud-side hurdle')
if (!opts.type) {
const answer = await clack.select({
message: 'What kind of hurdle did you hit?',
options: [
{ value: 'bug', label: 'bug — something InstaCloud should do, but does not' },
{ value: 'feature-request', label: 'feature-request — something InstaCloud does not support yet' },
{ value: 'friction', label: 'friction — works, but confusing or awkward' },
{ value: 'other', label: 'other' },
],
})
if (clack.isCancel(answer)) process.exit(0)
opts.type = answer as string
}
if (!opts.component) {
const answer = await clack.select({
message: 'Where in the InstaCloud toolkit is the issue?',
options: COMPONENTS.map((c) => ({ value: c, label: c })),
})
if (clack.isCancel(answer)) process.exit(0)
opts.component = answer as string
}
if (!opts.title) {
const answer = await clack.text({
message: 'One-line summary:',
validate: (v) => (v.trim() ? undefined : 'required'),
})
if (clack.isCancel(answer)) process.exit(0)
opts.title = answer.trim()
}
if (!opts.detail && !opts.file) {
const answer = await clack.text({
message: 'What happened, and what did you expect?',
validate: (v) => (v.trim() ? undefined : 'required'),
})
if (clack.isCancel(answer)) process.exit(0)
opts.detail = answer.trim()
}
}

/** Pure payload assembly (unit-tested): validation, redaction, caps, and ambient context. */
export async function buildPayload(
opts: FeedbackOpts,
ctx: { cliVersion: string },
): Promise<Record<string, unknown>> {
const type = requireEnum(opts.type ?? '', TYPES, '--type')
const component = requireEnum(opts.component ?? '', COMPONENTS, '--component')
const severity = opts.severity ? requireEnum(opts.severity, SEVERITIES, '--severity') : 'minor'

let detail = opts.detail
if (!detail && opts.file) {
try {
detail = readFileSync(opts.file, 'utf8')
} catch (e) {
throw new Error(`--file ${opts.file}: ${e instanceof Error ? e.message : String(e)}`)
}
}
const title = clean(opts.title, LIMITS.title)
if (!title) throw new Error('--title is required (one-line summary, ≤200 chars)')
const cleanedDetail = clean(detail, LIMITS.detail)
if (!cleanedDetail) throw new Error('--detail (or --file <path>) is required: what happened vs what you expected')

const project = await readProject()
const { apiUrl } = await readGlobal()
// envForApiUrl → null means a custom host: insta-oss or a preview deployment (see env.ts).
const target = envForApiUrl(apiUrl) ? 'cloud' : 'oss'

return {
type,
component,
severity,
title,
detail: cleanedDetail,
area: clean(opts.area, LIMITS.area),
command: clean(opts.command, LIMITS.command),
error: clean(opts.error, LIMITS.error),
expected: clean(opts.expected, LIMITS.expected),
workaround: clean(opts.workaround, LIMITS.workaround),
doc_ref: clean(opts.doc, LIMITS.doc),
source: 'cli',
target,
client_version: ctx.cliVersion,
node_version: process.version,
os: `${os.platform()} ${os.release()}`,
project_id: project?.projectId,
org_id: project?.orgId,
branch: project?.branch,
}
}

export type SubmitResult =
| { status: 'received' | 'duplicate'; id: string | null }
| { status: 'error'; error: string }

/** One POST, 10s timeout, zero retries — feedback is a side quest and must never hang the CLI.
* Transport and server failures come back as a result, not an exception: the caller downgrades
* them to a warning so a broken feedback backend can't fail the user's actual task. */
export async function submit(payload: Record<string, unknown>, fetchImpl: typeof fetch): Promise<SubmitResult> {
let res: Response
try {
res = await fetchImpl(FEEDBACK_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${FEEDBACK_INGEST_TOKEN}`,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS),
})
} catch (e) {
const timedOut = e instanceof Error && e.name === 'TimeoutError'
return { status: 'error', error: timedOut ? `timed out after ${FEEDBACK_TIMEOUT_MS / 1000}s` : `network error: ${e instanceof Error ? e.message : String(e)}` }
}
let body: any = {}
try {
body = await res.json()
} catch { /* non-JSON body — fall through to status handling */ }
if (!res.ok) return { status: 'error', error: body?.error ?? `HTTP ${res.status}` }
return { status: body?.status === 'duplicate' ? 'duplicate' : 'received', id: body?.id ?? null }
}

export async function feedback(opts: FeedbackOpts, deps: FeedbackDeps = {}): Promise<void> {
const interactive = deps.interactive ?? (!opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY)
const missingRequired = !opts.type || !opts.component || !opts.title || (!opts.detail && !opts.file)
if (missingRequired && interactive) await promptMissing(opts)

// Throws on bad/missing input → guard() → exit 1: an agent CAN fix its flags, so that error
// must be loud and self-teaching (it lists the exact enum values).
const payload = await buildPayload(opts, { cliVersion: deps.cliVersion ?? resolveCliVersion() })

const result = await submit(payload, deps.fetchImpl ?? fetch)

if (result.status === 'error') {
// Deliberate exit 0: an agent CANNOT fix a down/rate-limited backend, and feedback must never
// fail or distract from the task the user actually asked for. Do not retry.
if (opts.json) return printJson({ status: 'error', submitted: false, error: result.error })
process.stderr.write(`warning: feedback not submitted (${result.error}) — continue with your task, do not retry\n`)
return
}

if (opts.json) return printJson({ status: result.status, id: result.id })
if (result.status === 'duplicate') {
info(`already reported this week — bumped its count instead (id: ${result.id})`)
} else {
info(`feedback submitted (id: ${result.id}) — thank you!`)
}
info('PII (emails, tokens, keys, home paths) was redacted before sending.')
}
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as observe from './commands/observe.js'
import * as obs from './commands/metrics.js'
import { billing, billingUpgrade, billingPortal } from './commands/billing.js'
import * as selfUpdate from './commands/upgrade.js'
import * as feedbackCmd from './commands/feedback.js'

function onError(e: unknown): never {
if (e instanceof ApiError) die(`${e.message} (HTTP ${e.status})`)
Expand Down Expand Up @@ -280,6 +281,24 @@ const pol = program.command('policy').description('Governance policy')
pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)))
pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)))

// ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
program.command('feedback')
.description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. Works logged-out and unlinked.')
.option('--type <type>', `what kind of hurdle: ${feedbackCmd.TYPES.join(' | ')}`)
.option('--component <component>', `which part of the toolkit: ${feedbackCmd.COMPONENTS.join(' | ')}`)
.option('--title <title>', 'one-line summary (≤200 chars)')
.option('--detail <text>', 'what happened vs what you expected (≤4000 chars)')
.option('--file <path>', 'read the detail from a file instead of --detail')
.option('--area <area>', 'product area, free text: deploy, branch, secrets, db, storage, compute, governance, billing, …')
.option('--command <cmd>', 'the insta command that hit the issue')
.option('--error <text>', 'error output (redacted + truncated locally before sending)')
.option('--expected <text>', 'what the docs/skill said should happen')
.option('--workaround <text>', 'what you did instead, if anything worked')
.option('--doc <ref>', 'doc or skill file that led you here (for stale-instruction reports)')
.option('--severity <severity>', `${feedbackCmd.SEVERITIES.join(' | ')} (default: minor)`)
.option('--json')
.action(guard((o) => feedbackCmd.feedback(o)))

// ---- self-update ----
program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
.action(guard(() => selfUpdate.upgrade()))
Expand Down
70 changes: 70 additions & 0 deletions src/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Local PII redaction for outbound feedback text. Pattern-based: a safety net, not a license to
// paste credentials. The ingest service re-scrubs server-side with the same patterns
// (insta-feedback repo), so a drift here is caught by the second pass. IPv6 and phone numbers are
// deliberately not matched — the false-positive rate against UUIDs and hashes destroys the
// diagnostic value of error text.

const PATTERNS: Array<[RegExp, string]> = [
// URL-embedded credentials: scheme://user:pass@host (DATABASE_URLs pasted into error output)
[/(\w+:\/\/)[^\s/@:]+:[^\s/@]+@/g, '$1[REDACTED]@'],
// JWTs
[/eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{5,}/g, '[REDACTED_JWT]'],
// Bearer tokens
[/\b[Bb]earer\s+[\w~+/.=-]{8,}/g, 'Bearer [REDACTED]'],
// insta_ platform tokens are insta_ + a ≥24-char Better Auth apiKey. The tail must stay ≥24:
// MCP tool names (insta_feedback, insta_storage_download_url, …) share the prefix with tails
// up to 20 chars, and they are exactly what feedback text quotes most.
[/\binsta_[\w-]{24,}/g, '[REDACTED_KEY]'],
// Common third-party key prefixes
[/\b(?:uak_|sk_live_|sk_test_|whsec_|ghp_|github_pat_|npm_|AIza|xox[a-z]-)[\w-]{6,}/g, '[REDACTED_KEY]'],
[/\bsk-[\w-]{16,}/g, '[REDACTED_KEY]'],
[/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]'],
// Generic assignments: password=..., api_key: "..."
[/\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\b(\s*[:=]\s*)["']?[^\s"'&,;]{4,}["']?/gi, '$1$2[REDACTED]'],
// Emails
[/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[REDACTED_EMAIL]'],
// Home directories carry the username (unix + windows)
[/\/(?:Users|home)\/[\w.-]+/g, '~'],
[/[A-Z]:[\\/]Users[\\/][\w.-]+/g, '~'],
]

// Public IPv4 only — private/loopback ranges are kept for their debug value.
const IPV4 = /\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b/g

function isPrivateIp(a: number, b: number): boolean {
if (a === 10 || a === 127 || a === 0) return true
if (a === 192 && b === 168) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 169 && b === 254) return true
return false
}

export function redactSensitive(text: string): string {
let out = text
for (const [re, sub] of PATTERNS) out = out.replace(re, sub)
out = out.replace(IPV4, (m, a, b, c, d) => {
const [na, nb, nc, nd] = [Number(a), Number(b), Number(c), Number(d)]
if (na > 255 || nb > 255 || nc > 255 || nd > 255) return m
return isPrivateIp(na, nb) ? m : '[REDACTED_IP]'
})
return out
}

/** Middle truncation keeping 60% head + 40% tail — the start of an error names the failure, the
* end carries the actual cause; the middle is usually a stack. */
export function truncateMiddle(text: string, max: number): string {
if (text.length <= max) return text
const marker = `…[${text.length - max} chars truncated]…`
const head = Math.floor(max * 0.6)
const tail = max - head
return text.slice(0, head) + marker + text.slice(text.length - tail)
}

/** Redact BEFORE truncating: truncating first could leave half a token visible at the cut and the
* redaction pattern would no longer match it. */
export function clean(value: unknown, max: number): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
return truncateMiddle(redactSensitive(trimmed), max)
}
Loading
Loading