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
52 changes: 44 additions & 8 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,30 @@ import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-pr
import { die } from './util.js'
import { USER_AGENT } from './version.js'
import { agentHeaders, agentMode } from './agent.js'
import { describeTarget, failureReason, hostOf, targetLines } from './target.js'

/** Extra lines an error carries under its first line: which host answered (or did not), and what
* pointed the CLI there. Attached at throw time, where the target is known; index.ts prints them. */
export type ErrorContext = string[]

export class ApiError extends Error {
// body carries the parsed error payload for callers that branch on machine-readable errors
// (e.g. template deploy's missing_variables); the message stays the human line.
constructor(public status: number, msg: string, public body?: any) { super(msg); this.name = 'ApiError' }
constructor(public status: number, msg: string, public body?: any, public context?: ErrorContext) { super(msg); this.name = 'ApiError' }

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: When deploy remaps a 409 error, it drops the new ApiError.context, so non-default target failures omit the URL and provenance. Preserve e.context when constructing the replacement error.

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

<comment>When deploy remaps a 409 error, it drops the new `ApiError.context`, so non-default target failures omit the URL and provenance. Preserve `e.context` when constructing the replacement error.</comment>

<file context>
@@ -5,11 +5,30 @@ import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-pr
   // body carries the parsed error payload for callers that branch on machine-readable errors
   // (e.g. template deploy's missing_variables); the message stays the human line.
-  constructor(public status: number, msg: string, public body?: any) { super(msg); this.name = 'ApiError' }
+  constructor(public status: number, msg: string, public body?: any, public context?: ErrorContext) { super(msg); this.name = 'ApiError' }
+}
+
</file context>

}

/** The request never got an answer: DNS, TCP, TLS, or a timeout.
*
* undici collapses all of those into `TypeError: fetch failed`, which the CLI printed verbatim —
* no host, no reason, no hint that the target was not the cloud. This names the host it tried and
* why it failed, and keeps the original as `cause` so telemetry still lifts `cause.code`. */
export class NetworkError extends Error {
constructor(public url: string, cause: unknown, public context?: ErrorContext) {
const reason = failureReason(cause)
super(`cannot reach ${hostOf(url)}${reason ? ` (${reason})` : ''}`)
this.name = 'NetworkError'
this.cause = cause
}
}
export class AgentApprovalRequired extends Error {
constructor(public body: any) { super(body.message ?? `approval required: ${body.approvalId}`) }
Expand Down Expand Up @@ -57,17 +76,27 @@ export class ApiClient {
async request<T = any>(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<T> {
const res = await this.raw(method, path, body, opts.auth ?? true)
if (agentMode() && res.status === 202 && res.body?.status === 'approval_required') throw new AgentApprovalRequired(res.body)
if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body)
if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body, await this.targetContext())
return res.body as T
}

// Like request but returns {status, body} so callers can branch on 202 (approval_required).
async rawRequest(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise<RawResult> {
const res = await this.raw(method, path, body, opts.auth ?? true)
if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body)
if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body, await this.targetContext())
return res
}

// Computed once, and only on the failure path: a run that succeeds pays nothing, and the config
// file is not re-read per error. Empty on the cloud default, so nothing is added to the common case.
private noteCache?: ErrorContext
private async targetContext(): Promise<ErrorContext> {
if (!this.noteCache) {
try { this.noteCache = targetLines(await describeTarget(this.apiUrl)) } catch { this.noteCache = [] }

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: When login --env <name> fails before persistence, targetContext() reports the target as --api-url flag because it derives provenance without the command's --env option. Pass the explicit target provenance into ApiClient so the error identifies --env=<name> correctly.

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

<comment>When `login --env <name>` fails before persistence, `targetContext()` reports the target as `--api-url flag` because it derives provenance without the command's `--env` option. Pass the explicit target provenance into `ApiClient` so the error identifies `--env=<name>` correctly.</comment>

<file context>
@@ -57,17 +76,27 @@ export class ApiClient {
+  private noteCache?: ErrorContext
+  private async targetContext(): Promise<ErrorContext> {
+    if (!this.noteCache) {
+      try { this.noteCache = targetLines(await describeTarget(this.apiUrl)) } catch { this.noteCache = [] }
+    }
+    return this.noteCache
</file context>

}
return this.noteCache
}

private async raw(method: string, path: string, body: unknown, auth: boolean): Promise<RawResult> {
let r = await this.fetch(method, path, body, auth)
if (r.status === 401 && auth && this.cfg.refreshToken) {
Expand All @@ -80,11 +109,18 @@ export class ApiClient {
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}`
if (auth) Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body)))
const res = await this.fetchImpl(this.apiUrl + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
let res: Response
try {
res = await this.fetchImpl(this.apiUrl + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
} catch (e) {
// A transport failure, not an HTTP status: there is no response to parse and no 401 to
// refresh past, so it goes straight out as a NetworkError naming the host and the setting.
throw new NetworkError(this.apiUrl, (e as { cause?: unknown })?.cause ?? e, await this.targetContext())
}
const text = await res.text()
let parsed: any = null
try { parsed = text ? JSON.parse(text) : null } catch { parsed = { raw: text } }
Comment on lines +112 to 126

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: When the peer closes the connection while res.text() reads the response body, the new catch has already finished, so the CLI still leaks TypeError: fetch failed without the host or reason. Include body consumption in the NetworkError try/catch path.

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

<comment>When the peer closes the connection while `res.text()` reads the response body, the new catch has already finished, so the CLI still leaks `TypeError: fetch failed` without the host or reason. Include body consumption in the `NetworkError` try/catch path.</comment>

<file context>
@@ -80,11 +109,18 @@ export class ApiClient {
-      headers,
-      body: body === undefined ? undefined : JSON.stringify(body),
-    })
+    let res: Response
+    try {
+      res = await this.fetchImpl(this.apiUrl + path, {
</file context>

Expand Down
20 changes: 15 additions & 5 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { ApiClient, ApiError, linkedProject } from '../api.js'
import { ApiClient, ApiError, NetworkError, linkedProject } from '../api.js'
import { describeTarget } from '../target.js'
import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js'
import { info, die, printJson, promptPassword, openUrl } from '../util.js'

Expand Down Expand Up @@ -233,14 +234,23 @@ export async function logout(): Promise<void> {
export async function status(opts: { json?: boolean }): Promise<void> {
const api = await ApiClient.load()
let user: any = null
try { user = (await api.request('GET', '/me')).user } catch { /* not logged in */ }
// A host that never answered is not the same as a rejected credential, and reporting the first
// as "(not logged in)" is what sent the user hunting for a login problem that did not exist.
let unreachable: string | null = null
try { user = (await api.request('GET', '/me')).user } catch (e) {
if (e instanceof NetworkError) unreachable = e.message
}
const project = await linkedProject()
// Surface the environment name alongside the URL: "api: https://api.staging.instacloud.com" is
// easy to skim past, and mistaking staging for prod is the mistake worth making loud.
const env = envForApiUrl(api.apiUrl)
if (opts.json) return printJson({ env, apiUrl: api.apiUrl, user, project })
info(`env: ${env ?? '(custom)'}`)
const target = await describeTarget(api.apiUrl)
// The stable token in json, the prose on a terminal. See envShow.
if (opts.json) return printJson({ env, apiUrl: api.apiUrl, source: target.kind, unreachable, user, project })
info(`env: ${env ?? 'custom'}`)
info(`api: ${api.apiUrl}`)
info(`user: ${user ? (user.email ?? user.id) : '(not logged in)'}`)
info(`source: ${target.source}`)
info(`user: ${unreachable ?? (user ? (user.email ?? user.id) : '(not logged in)')}`)
info(`project: ${project ? `${project.projectId} (branch ${project.branch})` : '(none linked)'}`)
if (!env) info(`switch: ${target.recovery}`)
}
20 changes: 15 additions & 5 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,27 @@
// same file `login --api-url` already writes, so this adds a surface, not a concept.
import { readPersistedGlobal, resolveEnv, writeGlobal, type GlobalConfig } from '../config.js'
import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, normalizeUrl, type EnvName } from '../env.js'
import { describeTarget } from '../target.js'
import { die, info, printJson } from '../util.js'

export async function envShow(opts: { json?: boolean }): Promise<void> {
const { apiUrl, env, mcpUrl, skills } = await resolveEnv()
const mcpServer = mcpServerName(env ?? DEFAULT_ENV)
if (opts.json) return printJson({ env, apiUrl, mcpUrl, mcpServer, skills })
info(`env: ${env ?? '(custom)'}`)
// `source` answers the question the old output left open: the URL was on screen, but nothing
// said which of INSTA_API_URL / INSTA_ENV / a months-old `login --api-url` had chosen it.
const target = await describeTarget(apiUrl)
// json gets the stable token, a terminal gets the prose. An agent that branched on the wording of
// "saved by `insta login --api-url`" would break the next time that sentence is reworded, and the
// backticks in it are noise in a machine field.
if (opts.json) return printJson({ env, apiUrl, source: target.kind, mcpUrl, mcpServer, skills })
info(`env: ${env ?? 'custom'}`)
info(`api: ${apiUrl}`)
info(`mcp: ${mcpUrl} (${mcpServer})`)
info(`skills: ${skills}`)
if (!env) info(' (custom apiUrl — `insta env use <name>` to switch to a named environment)')
info(`source: ${target.source}`)
// A custom apiUrl has no matching mcp/skills entry, so resolveEnv falls back to the cloud's.
// Say so: an unlabelled cloud mcp host under an insta-oss api host reads as a matched pair.
info(`mcp: ${mcpUrl} (${mcpServer}${env ? '' : ', cloud fallback'})`)
info(`skills: ${skills}${env ? '' : ' (cloud fallback)'}`)
Comment on lines +28 to +29

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: When a custom apiUrl is combined with INSTA_MCP_URL or INSTA_SKILLS_REPO, env show still calls the selected values cloud fallbacks. Check each override before adding its fallback label.

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

<comment>When a custom `apiUrl` is combined with `INSTA_MCP_URL` or `INSTA_SKILLS_REPO`, `env show` still calls the selected values cloud fallbacks. Check each override before adding its fallback label.</comment>

<file context>
@@ -7,17 +7,24 @@
+  info(`source:  ${target.source}`)
+  // A custom apiUrl has no matching mcp/skills entry, so resolveEnv falls back to the cloud's.
+  // Say so: an unlabelled cloud mcp host under an insta-oss api host reads as a matched pair.
+  info(`mcp:     ${mcpUrl} (${mcpServer}${env ? '' : ', cloud fallback'})`)
+  info(`skills:  ${skills}${env ? '' : ' (cloud fallback)'}`)
+  if (!env) info(`switch:  ${target.recovery}`)
</file context>
Suggested change
info(`mcp: ${mcpUrl} (${mcpServer}${env ? '' : ', cloud fallback'})`)
info(`skills: ${skills}${env ? '' : ' (cloud fallback)'}`)
info(`mcp: ${mcpUrl} (${mcpServer}${env || process.env.INSTA_MCP_URL ? '' : ', cloud fallback'})`)
info(`skills: ${skills}${env || process.env.INSTA_SKILLS_REPO ? '' : ' (cloud fallback)'}`)

if (!env) info(`switch: ${target.recovery}`)
}

// One stable schema for BOTH envUse outcomes (no-op and real switch), so a scripted caller can key
Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ export async function readPersistedGlobal(): Promise<GlobalConfig> {
}
}

/** The apiUrl the config FILE holds, or null when there is no file or it names none.
*
* Distinct from readPersistedGlobal, which substitutes DEFAULT_API: "prod because nothing was
* ever chosen" and "prod because a login saved it" are the same host but different provenance,
* and the messages in target.ts have to say which. */
export async function storedApiUrl(): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as Partial<GlobalConfig>
return typeof parsed.apiUrl === 'string' && parsed.apiUrl !== '' ? parsed.apiUrl : null
} catch {
return null
}
}

export async function writeGlobal(c: GlobalConfig): Promise<void> {
await mkdir(GLOBAL_DIR, { recursive: true })
await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2))
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Command } from 'commander'
import { configureAgent, detectAgent } from './agent.js'
import * as agentPolicy from './commands/agent-policy.js'
import { ApiError, AgentApprovalRequired } from './api.js'
import { ApiError, AgentApprovalRequired, NetworkError } from './api.js'
import { CliCancel, CliExit, fail, relayedExitCode } from './util.js'
import { trackCommand } from './telemetry.js'
import { cliVersion } from './version.js'
Expand Down Expand Up @@ -43,7 +43,8 @@ function onError(e: unknown): void {
return
}
if (e instanceof CliExit || e instanceof CliCancel) return
if (e instanceof ApiError) return fail(`${e.message} (HTTP ${e.status})`)
if (e instanceof NetworkError) return fail(e.message, e.context)
if (e instanceof ApiError) return fail(`${e.message} (HTTP ${e.status})`, e.context)
fail(e instanceof Error ? e.message : String(e))
}

Expand Down
146 changes: 146 additions & 0 deletions src/target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Where the CLI is pointed, and which setting put it there.
//
// Everything here is DERIVED: nothing is stored, and nothing is asked of the host. That is
// deliberate on both counts. The failure this exists for is a host that does not answer, so an
// answer from the host is exactly what cannot be relied on; and a stored label ("this URL is an
// insta-oss box") goes stale the moment the box is rebuilt or the URL reused, which is worse than
// no label at all. Provenance needs neither: `env use` only ever writes a host from the env table,
// so a persisted apiUrl that is not one of those can only have come from `login --api-url`.
import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, normalizeUrl, type EnvName } from './env.js'
import { storedApiUrl } from './config.js'

/** Which setting chose the target. A stable token, so `--json` consumers (agents, mostly) can
* branch on it: the human `source` string is prose and will be reworded. */
export type TargetSource =
| 'env-api-url' // $INSTA_API_URL
| 'env-name' // $INSTA_ENV
| 'saved-api-url' // persisted by `insta login --api-url`
| 'saved-env' // persisted by `insta env use` / `login --env`
| 'default' // nothing was ever chosen
| 'flag' // --api-url on the running command, not yet persisted

export type Target = {
apiUrl: string
host: string
/** null for a host no environment name covers: an insta-oss daemon, a preview, a tunnel. */
env: EnvName | null
/** Machine-stable; `source` is the same fact as prose. */
kind: TargetSource
/** The setting that chose apiUrl, phrased to sit after a `source:` label. */
source: string
/** The one command that gets back to InstaCloud from here. */
recovery: string
}

export const hostOf = (url: string): string => {
try { return new URL(url).host } catch { return url }
}

/** Pure core (unit-tested). `stored` is what the config FILE holds, null when it holds nothing. */
export function buildTarget(i: {
apiUrl: string
stored: string | null
envApiUrl?: string
envName?: string
}): Target {
const want = normalizeUrl(i.apiUrl)
const env = envForApiUrl(i.apiUrl)
const named = i.envName?.trim().toLowerCase()
const namedApi = named && (ENV_NAMES as string[]).includes(named) ? ENVS[named as EnvName].api : undefined

let kind: TargetSource
if (i.envApiUrl && normalizeUrl(i.envApiUrl) === want) kind = 'env-api-url'
else if (namedApi && normalizeUrl(namedApi) === want) kind = 'env-name'
else if (i.stored && normalizeUrl(i.stored) === want) {
// A stored host the env table knows was written by `env use` (or by `login --env`, which
// writes the same value); anything else was a literal URL the user typed at `login --api-url`.
kind = envForApiUrl(i.stored) ? 'saved-env' : 'saved-api-url'
} else if (i.stored === null && env === DEFAULT_ENV) kind = 'default'
// Nothing in the environment or on disk accounts for this URL, so it came from the flag the
// running command was given (`login --api-url`, before anything is persisted).
else kind = 'flag'

const source =
kind === 'env-api-url' ? 'INSTA_API_URL'
: kind === 'env-name' ? `INSTA_ENV=${named}`
: kind === 'saved-env' ? 'saved by `insta env use`'
: kind === 'saved-api-url' ? 'saved by `insta login --api-url`'
: kind === 'default' ? 'built-in default'
: '--api-url flag'

// Undo the thing that actually chose this host, not the thing that usually does. `env use prod`
// is the right advice only when something is PERSISTED; against a bare `--api-url` on the
// running command it is a no-op that prints "already on prod" and deepens the confusion.
const recovery =
kind === 'env-api-url' ? 'unset INSTA_API_URL'
: kind === 'env-name' ? 'unset INSTA_ENV'
: kind === 'flag' ? 'drop --api-url'
: 'insta env use prod'

return { apiUrl: i.apiUrl, host: hostOf(i.apiUrl), env, kind, source, recovery }
}

/** The live target, from the resolved apiUrl plus the config file and the environment. */
export async function describeTarget(apiUrl: string): Promise<Target> {
return buildTarget({
apiUrl,
stored: await storedApiUrl(),
envApiUrl: process.env.INSTA_API_URL,
envName: process.env.INSTA_ENV,
})
}

/** The context an error adds beneath its first line. Empty on the cloud default: the overwhelming
* majority of runs are there, and a run that is where it expects to be has nothing to explain. */
export function targetLines(t: Target): string[] {
if (t.env === DEFAULT_ENV) return []
const lines = [` target: ${t.apiUrl} (${t.source})`]
if (!t.env) lines.push(` for InstaCloud: ${t.recovery}`)

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: When the resolved target is staging, t.env is non-null, so this suppresses the recovery command even though staging is not the cloud default. Add the recovery line for every t.env !== DEFAULT_ENV target.

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

<comment>When the resolved target is staging, `t.env` is non-null, so this suppresses the recovery command even though staging is not the cloud default. Add the recovery line for every `t.env !== DEFAULT_ENV` target.</comment>

<file context>
@@ -0,0 +1,104 @@
+export function targetLines(t: Target): string[] {
+  if (t.env === DEFAULT_ENV) return []
+  const lines = [`  target: ${t.apiUrl} (${t.source})`]
+  if (!t.env) lines.push(`  for InstaCloud: ${t.recovery}`)
+  return lines
+}
</file context>
Suggested change
if (!t.env) lines.push(` for InstaCloud: ${t.recovery}`)
if (t.env !== DEFAULT_ENV) lines.push(` for InstaCloud: ${t.recovery}`)

return lines
}

// Two runtimes, two vocabularies, and the shipped artifact is the one that is easy to forget.
//
// On Node (`npm i -g insta`) undici reports every transport failure as the same
// `TypeError: fetch failed`, with the code that says WHICH failure on `.cause`. On Bun (the
// compiled binaries install.sh serves, so: most users) it is a plain `Error` carrying the code on
// the error ITSELF, no cause at all, spelled in Bun's own CamelCase.
//
// Bun is also coarser, and that part needs care rather than a translation. It reports a connect
// TIMEOUT as `ConnectionRefused`, so it cannot tell a terminated box from a refused port from a
// dead DNS name. Rendering that as "connection refused" would be a confident lie about the exact
// host this feature exists for, so it gets the honest, non-committal "could not connect". Node's
// own ECONNREFUSED really does mean refused and keeps the precise wording.
//
// Anything unmapped falls through to the code, then the message, so a new runtime code degrades to
// a raw-but-present reason rather than to nothing.
const REASONS: Record<string, string> = {
// Bun (the compiled binaries).
ConnectionRefused: 'could not connect',
ConnectionClosed: 'connection closed',
FailedToOpenSocket: 'could not open a socket',
Timeout: 'timed out',
// Node / undici (the npm install).
UND_ERR_CONNECT_TIMEOUT: 'connect timeout',
UND_ERR_HEADERS_TIMEOUT: 'no response headers',
UND_ERR_SOCKET: 'socket closed',
ECONNREFUSED: 'connection refused',
ECONNRESET: 'connection reset',
EHOSTUNREACH: 'host unreachable',
ENETUNREACH: 'network unreachable',
ETIMEDOUT: 'timed out',
ENOTFOUND: 'DNS lookup failed',
EAI_AGAIN: 'DNS temporarily unavailable',
CERT_HAS_EXPIRED: 'TLS certificate expired',
DEPTH_ZERO_SELF_SIGNED_CERT: 'self-signed TLS certificate',
UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS certificate not trusted',
ERR_TLS_CERT_ALTNAME_INVALID: 'TLS certificate does not cover this host',
}

/** Why the connection failed, in a few words. Pure (unit-tested). */
export function failureReason(cause: unknown): string | null {
const c = cause as { code?: string; message?: string } | undefined
if (!c) return null
const known = c.code ? REASONS[c.code] : undefined
return known ?? c.code ?? c.message ?? null
}
5 changes: 4 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,11 @@ export class CliCancel extends Error {
}
}

export function fail(msg: string): void {
// `context` lines print under the error, already indented, and say where the CLI was pointed and
// what pointed it there. Empty on the cloud default, so the common failure reads exactly as before.
export function fail(msg: string, context: string[] = []): void {
process.stderr.write(`error: ${msg}\n`)
for (const line of context) process.stderr.write(line + '\n')
process.exitCode = 1
}

Expand Down
Loading
Loading