diff --git a/.gitignore b/.gitignore
index 0b7e321..3cbd1b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ bun.lock
# Skills
.agents/
+.agent/
.claude/skills/
skills-lock.json
diff --git a/docs/start/index.html b/docs/start/index.html
index bb91bc1..7de38f3 100644
--- a/docs/start/index.html
+++ b/docs/start/index.html
@@ -396,6 +396,20 @@
+login troubleshooting
+if vit login fails, these are the usual fixes:
+
+- make sure the handle is correct and resolves on Bluesky.
+- open the printed URL in your browser, approve vit, and wait for the callback to finish.
+- if you're on a remote machine, rerun with
vit login <handle> --remote and paste the full callback URL.
+- if you used an app password, create a fresh Bluesky app password and try again.
+- check your DNS, firewall, or VPN settings if vit cannot reach Bluesky.
+
+you run this (terminal, on a remote machine)
+vit login your-handle.bsky.social --remote
+
+
+
learn more
- the doctrine — why vit exists and how it works
diff --git a/src/cmd/adopt.js b/src/cmd/adopt.js
index a207a5f..c893b96 100644
--- a/src/cmd/adopt.js
+++ b/src/cmd/adopt.js
@@ -8,6 +8,7 @@ import { parseGitUrl, toBeacon, beaconToHttps } from '../lib/beacon.js';
import { requireNotAgent } from '../lib/agent.js';
import { which } from '../lib/compat.js';
import { mark, name } from '../lib/brand.js';
+import { formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -85,7 +86,7 @@ export default function register(program) {
console.log('');
console.log(`next: start your agent and ask it to run '${name} init'`);
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/beacon.js b/src/cmd/beacon.js
index fa4dfe5..3ac6e63 100644
--- a/src/cmd/beacon.js
+++ b/src/cmd/beacon.js
@@ -6,6 +6,7 @@ import http from 'isomorphic-git/http/node';
import { memfs } from 'memfs';
import { beaconToHttps } from '../lib/beacon.js';
import { mark } from '../lib/brand.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
async function readTreeFile(fs, dir, treeOid, pathParts) {
for (let i = 0; i < pathParts.length; i++) {
@@ -47,7 +48,9 @@ export default function register(program) {
let beacon;
try {
beacon = content && JSON.parse(content).beacon;
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to parse .vit/config.json from ${url}: ${errorMessage(err)}`);
+ }
if (beacon) {
console.log(`${mark} beacon: lit ${beacon}`);
@@ -56,7 +59,7 @@ export default function register(program) {
console.log("the maintainer can light the beacon by running 'vit init' inside the repo.");
}
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/config.js b/src/cmd/config.js
index 18ca93b..1e06f75 100644
--- a/src/cmd/config.js
+++ b/src/cmd/config.js
@@ -2,6 +2,7 @@
// Copyright (c) 2026 sol pbc
import { loadConfig, saveConfig, getScalars } from '../lib/config.js';
+import { formatError } from '../lib/error-format.js';
function coerceValue(str) {
if (str === 'true') return true;
@@ -61,7 +62,7 @@ export default function register(program) {
console.error(`Unknown action: ${action}`);
process.exitCode = 1;
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/doctor.js b/src/cmd/doctor.js
index 2816635..96d89ae 100644
--- a/src/cmd/doctor.js
+++ b/src/cmd/doctor.js
@@ -11,6 +11,7 @@ import { mark, name } from '../lib/brand.js';
import { which } from '../lib/compat.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
import { configPath } from '../lib/paths.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
function scanSkillDir(dir) {
const skills = [];
@@ -29,11 +30,15 @@ function scanSkillDir(dir) {
const versionMatch = match[1].match(/^version:\s*(.+)$/m);
if (versionMatch) version = versionMatch[1].trim();
}
- } catch { /* ignore read errors */ }
+ } catch (err) {
+ console.warn(`warning: failed to read ${skillMd}: ${errorMessage(err)}`);
+ }
skills.push({ name: entry.name, version });
}
}
- } catch { /* ignore dir read errors */ }
+ } catch (err) {
+ console.warn(`warning: failed to read skill directory ${dir}: ${errorMessage(err)}`);
+ }
return skills;
}
@@ -66,7 +71,8 @@ export default function register(program) {
installType = 'source';
if (!opts.json) console.log(`${mark} install: source (${vitPath})`);
}
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to inspect install path ${vitPath}: ${errorMessage(err)}`);
installType = 'source';
if (!opts.json) console.log(`${mark} install: source (${vitPath})`);
}
@@ -124,16 +130,23 @@ export default function register(program) {
authType = local.type || 'oauth';
}
}
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to read ${localLoginPath}: ${errorMessage(err)}`);
+ }
if (!identitySource && effectiveDid) identitySource = 'global';
if (identitySource === 'global' && effectiveDid) {
+ const sessionFile = configPath('session.json');
try {
- const raw = readFileSync(configPath('session.json'), 'utf-8');
- const sessionData = JSON.parse(raw);
- if (sessionData[effectiveDid]?.type === 'app-password') authType = 'app-password';
- } catch {}
+ if (existsSync(sessionFile)) {
+ const raw = readFileSync(sessionFile, 'utf-8');
+ const sessionData = JSON.parse(raw);
+ if (sessionData[effectiveDid]?.type === 'app-password') authType = 'app-password';
+ }
+ } catch (err) {
+ console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
+ }
}
if (!effectiveDid) {
@@ -144,7 +157,8 @@ export default function register(program) {
blueskyOk = true;
pds = session.serverMetadata?.issuer || null;
if (!opts.json) console.log(`${mark} bluesky: ok (${session.did || effectiveDid}${pds ? ', ' + pds : ''})`);
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to validate Bluesky session: ${errorMessage(err)}`);
if (!opts.json) console.log(`${mark} bluesky: token expired or invalid (run ${name} login )`);
}
if (!opts.json) console.log(`${mark} identity: ${identitySource} (${authType})`);
@@ -161,12 +175,11 @@ export default function register(program) {
});
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
}
diff --git a/src/cmd/explore.js b/src/cmd/explore.js
index 6f8d97e..9409e98 100644
--- a/src/cmd/explore.js
+++ b/src/cmd/explore.js
@@ -5,6 +5,7 @@ import { DEFAULT_EXPLORE_URL } from '../lib/constants.js';
import { readProjectConfig } from '../lib/vit-dir.js';
import { brand } from '../lib/brand.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
function timeAgo(isoString) {
const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000);
@@ -25,11 +26,27 @@ function resolveUrl(opts) {
return opts.exploreUrl || process.env.VIT_EXPLORE_URL || DEFAULT_EXPLORE_URL;
}
-function unavailableMessage(baseUrl) {
+function requestErrorMessage(method, url, err) {
+ const code = err?.cause?.code || err?.code;
+ if (code === 'ECONNREFUSED') return `could not connect to ${url} (refused)`;
+ if (code === 'ENOTFOUND') return `could not resolve ${url}`;
+ if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') {
+ return `timed out connecting to ${url}`;
+ }
+ return `request to ${url} failed: ${errorMessage(err)}`;
+}
+
+async function fetchExploreJson(url) {
+ const requestUrl = url.toString();
try {
- return `${new URL(baseUrl).host} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`;
- } catch {
- return `${baseUrl} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`GET ${requestUrl} returned ${res.status}`);
+ return await res.json();
+ } catch (err) {
+ if (err instanceof Error && err.message.startsWith(`GET ${requestUrl} returned `)) {
+ throw err;
+ }
+ throw new Error(requestErrorMessage('GET', requestUrl, err), { cause: err });
}
}
@@ -44,9 +61,7 @@ async function fetchAndShowStats(opts) {
const baseUrl = resolveUrl(opts);
try {
const url = new URL('/api/stats', baseUrl);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`explore API returned ${res.status}`);
- const data = await res.json();
+ const data = await fetchExploreJson(url);
if (opts.json) {
jsonOk(data);
@@ -58,15 +73,11 @@ async function fetchAndShowStats(opts) {
console.log(` vouches: ${data.total_vouches} beacons: ${data.total_beacons}`);
console.log(` active dids: ${data.active_dids} skill publishers: ${data.skill_publishers}`);
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const finalMsg = msg.startsWith('explore API returned ')
- ? msg
- : unavailableMessage(baseUrl);
if (opts.json) {
- jsonError(finalMsg);
+ jsonError(err);
return;
}
- console.error(finalMsg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
}
@@ -97,9 +108,7 @@ export default function register(program) {
url.searchParams.set('ref', ref);
if (opts.beacon) url.searchParams.set('beacon', opts.beacon);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`explore API returned ${res.status}`);
- const data = await res.json();
+ const data = await fetchExploreJson(url);
if (!data.cap) {
const msg = `no cap found with ref '${ref}'`;
@@ -136,15 +145,11 @@ export default function register(program) {
console.log(` vit vet ${data.cap.ref} - inspect before adopting`);
console.log(` vit remix ${data.cap.ref} - remix this cap`);
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const finalMsg = msg.startsWith('explore API returned ')
- ? msg
- : unavailableMessage(baseUrl);
if (opts.json) {
- jsonError(finalMsg);
+ jsonError(err);
return;
}
- console.error(finalMsg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
@@ -163,9 +168,7 @@ export default function register(program) {
const url = new URL('/api/skill', baseUrl);
url.searchParams.set('name', name);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`explore API returned ${res.status}`);
- const data = await res.json();
+ const data = await fetchExploreJson(url);
if (!data.skill) {
const msg = `no skill found with name '${name}'`;
@@ -195,15 +198,11 @@ export default function register(program) {
console.log();
console.log(` vit learn skill-${data.skill.name} - install this skill`);
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const finalMsg = msg.startsWith('explore API returned ')
- ? msg
- : unavailableMessage(baseUrl);
if (opts.json) {
- jsonError(finalMsg);
+ jsonError(err);
return;
}
- console.error(finalMsg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
@@ -245,9 +244,7 @@ export default function register(program) {
if (opts.limit) url.searchParams.set('limit', opts.limit);
if (opts.cursor) url.searchParams.set('cursor', opts.cursor);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`explore API returned ${res.status}`);
- const data = await res.json();
+ const data = await fetchExploreJson(url);
if (opts.json) {
jsonOk({ caps: data.caps, cursor: data.cursor });
@@ -271,15 +268,11 @@ export default function register(program) {
console.log(`\nnext: --cursor ${data.cursor}`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const finalMsg = msg.startsWith('explore API returned ')
- ? msg
- : unavailableMessage(baseUrl);
if (opts.json) {
- jsonError(finalMsg);
+ jsonError(err);
return;
}
- console.error(finalMsg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
@@ -302,9 +295,7 @@ export default function register(program) {
if (opts.limit) url.searchParams.set('limit', opts.limit);
if (opts.cursor) url.searchParams.set('cursor', opts.cursor);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`explore API returned ${res.status}`);
- const data = await res.json();
+ const data = await fetchExploreJson(url);
if (opts.json) {
jsonOk({ skills: data.skills, cursor: data.cursor });
@@ -326,15 +317,11 @@ export default function register(program) {
console.log(`\nnext: --cursor ${data.cursor}`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const finalMsg = msg.startsWith('explore API returned ')
- ? msg
- : unavailableMessage(baseUrl);
if (opts.json) {
- jsonError(finalMsg);
+ jsonError(err);
return;
}
- console.error(finalMsg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
@@ -350,9 +337,7 @@ export default function register(program) {
try {
const url = new URL('/api/beacons', baseUrl);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`explore API returned ${res.status}`);
- const data = await res.json();
+ const data = await fetchExploreJson(url);
if (opts.json) {
jsonOk({ beacons: data.beacons });
@@ -371,15 +356,11 @@ export default function register(program) {
console.log(` caps: ${beacon.cap_count} vouches: ${beacon.vouch_count} last active: ${beacon.last_activity}`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const finalMsg = msg.startsWith('explore API returned ')
- ? msg
- : unavailableMessage(baseUrl);
if (opts.json) {
- jsonError(finalMsg);
+ jsonError(err);
return;
}
- console.error(finalMsg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
@@ -413,9 +394,7 @@ export default function register(program) {
const capsUrl = new URL('/api/caps', baseUrl);
if (opts.beacon) capsUrl.searchParams.set('beacon', opts.beacon);
- const capsRes = await fetch(capsUrl);
- if (!capsRes.ok) throw new Error(`explore API returned ${capsRes.status}`);
- const capsData = await capsRes.json();
+ const capsData = await fetchExploreJson(capsUrl);
const match = capsData.caps?.find((cap) => cap.ref === opts.ref);
if (!match) {
@@ -435,9 +414,7 @@ export default function register(program) {
const url = new URL('/api/vouches', baseUrl);
url.searchParams.set('cap_uri', capUri);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`explore API returned ${res.status}`);
- const data = await res.json();
+ const data = await fetchExploreJson(url);
if (opts.json) {
jsonOk({ vouches: data.vouches, cap_uri: capUri });
@@ -458,15 +435,11 @@ export default function register(program) {
if (ref) console.log(` ${ref}`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- const finalMsg = msg.startsWith('explore API returned ')
- ? msg
- : unavailableMessage(baseUrl);
if (opts.json) {
- jsonError(finalMsg);
+ jsonError(err);
return;
}
- console.error(finalMsg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/firehose.js b/src/cmd/firehose.js
index cadedf2..2421e90 100644
--- a/src/cmd/firehose.js
+++ b/src/cmd/firehose.js
@@ -5,6 +5,7 @@ import { loadConfig } from '../lib/config.js';
import { CAP_COLLECTION, DEFAULT_JETSTREAM_URL } from '../lib/constants.js';
import { resolveRef } from '../lib/cap-ref.js';
import { brand } from '../lib/brand.js';
+import { formatError } from '../lib/error-format.js';
let ws = null;
let shuttingDown = false;
@@ -72,7 +73,7 @@ function connect(opts, cursor) {
try {
msg = JSON.parse(event.data);
} catch {
- console.log('Warning: failed to parse message as JSON; skipping');
+ console.warn('warning: failed to parse message as JSON; skipping');
return;
}
@@ -152,7 +153,7 @@ export default function register(program) {
connect(opts, null);
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/follow.js b/src/cmd/follow.js
index e5cf37e..bfa6ec3 100644
--- a/src/cmd/follow.js
+++ b/src/cmd/follow.js
@@ -6,6 +6,7 @@ import { restoreAgent } from '../lib/oauth.js';
import { readFollowing, writeFollowing } from '../lib/vit-dir.js';
import { mark } from '../lib/brand.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
+import { formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -55,12 +56,11 @@ export default function register(program) {
}
console.log(`${mark} following ${handle} (${targetDid})`);
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
@@ -97,12 +97,11 @@ export default function register(program) {
}
console.log(`${mark} unfollowed ${handle}`);
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
@@ -134,12 +133,11 @@ export default function register(program) {
console.log(`${e.handle} (${e.did})`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/hack.js b/src/cmd/hack.js
index d168c53..6de149b 100644
--- a/src/cmd/hack.js
+++ b/src/cmd/hack.js
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { which } from '../lib/compat.js';
import { mark, name } from '../lib/brand.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -69,14 +70,16 @@ export default function register(program) {
if (opts.from && cloned) {
try {
execFileSync('git', ['remote', 'add', 'upstream', 'https://github.com/solpbc/vit.git'], { stdio: 'inherit' });
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to add upstream remote: ${errorMessage(err)}`);
+ }
}
console.log('');
console.log(`${mark} ${name} installed from source`);
console.log(`run: cd ${dirName}`);
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/inbox.js b/src/cmd/inbox.js
index 7f2e587..b42ae42 100644
--- a/src/cmd/inbox.js
+++ b/src/cmd/inbox.js
@@ -5,6 +5,7 @@ import { DEFAULT_EXPLORE_URL } from '../lib/constants.js';
import { readBeaconSet } from '../lib/vit-dir.js';
import { brand } from '../lib/brand.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
function timeAgo(isoString) {
const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000);
@@ -67,7 +68,7 @@ export default function register(program) {
if (!res.ok) throw new Error(`explore API returned ${res.status}`);
data = await res.json();
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
+ const msg = errorMessage(err);
const finalMsg = msg.startsWith('explore API returned ')
? msg
: unavailableMessage(baseUrl);
@@ -119,12 +120,11 @@ export default function register(program) {
console.log(`tip: 'vit vouch [ --kind want' to signal demand`);
console.log(` 'vit ship --recap ][' to ship an implementation`);
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/init.js b/src/cmd/init.js
index 917830a..73bbc47 100644
--- a/src/cmd/init.js
+++ b/src/cmd/init.js
@@ -9,6 +9,7 @@ import { vitDir, readProjectConfig, writeProjectConfig } from '../lib/vit-dir.js
import { requireAgent } from '../lib/agent.js';
import { mark, name, DOT_VIT_README } from '../lib/brand.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -98,7 +99,8 @@ export default function register(program) {
.trim()
.split('\n')
.filter(Boolean);
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to list git remotes: ${errorMessage(err)}`);
remoteNames = [];
}
if (verbose) vlog(`[verbose] remotes detected: ${remoteNames.length > 0 ? remoteNames.join(', ') : 'none'}`);
@@ -111,7 +113,9 @@ export default function register(program) {
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (url) remotes.push({ name, url });
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to read git remote ${name} url: ${errorMessage(err)}`);
+ }
}
if (verbose && remotes.length > 0) {
vlog(`[verbose] remote urls: ${remotes.map(r => `${r.name}=${r.url}`).join(' ')}`);
@@ -231,12 +235,11 @@ export default function register(program) {
console.log(`${mark} secondary beacon: ${merged.secondaryBeacon}`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/learn.js b/src/cmd/learn.js
index f08b5e2..9713bf7 100644
--- a/src/cmd/learn.js
+++ b/src/cmd/learn.js
@@ -16,6 +16,7 @@ import { mark, name } from '../lib/brand.js';
import { resolvePds, resolveHandle, listRecordsFromPds, batchQuery } from '../lib/pds.js';
import { loadConfig } from '../lib/config.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
async function installSkill({ match, skillName, isGlobal, opts, ref }) {
const { verbose } = opts;
@@ -65,7 +66,11 @@ async function installSkill({ match, skillName, isGlobal, opts, ref }) {
}
if (verbose) vlog('[verbose] installed via npx skills add');
} finally {
- try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
+ try {
+ rmSync(tempDir, { recursive: true, force: true });
+ } catch (err) {
+ console.warn(`warning: failed to remove temporary directory ${tempDir}: ${errorMessage(err)}`);
+ }
}
const installDir = isGlobal
@@ -404,12 +409,11 @@ Examples:
await installSkill({ match, skillName, isGlobal: !!opts.user, opts, ref });
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/link.js b/src/cmd/link.js
index b968fe4..c0d3859 100644
--- a/src/cmd/link.js
+++ b/src/cmd/link.js
@@ -4,6 +4,7 @@
import { existsSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { mark, name } from '../lib/brand.js';
+import { formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -68,7 +69,7 @@ export default function register(program) {
console.log(`${mark} PATH: ok`);
}
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ console.error(formatError(err, { verbose: false }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/login.js b/src/cmd/login.js
index 53d7b6d..f85f380 100644
--- a/src/cmd/login.js
+++ b/src/cmd/login.js
@@ -11,6 +11,14 @@ import { loadConfig, saveConfig } from '../lib/config.js';
import { createOAuthClient, createSessionStore, createStore, checkSession } from '../lib/oauth.js';
import { configDir, configPath } from '../lib/paths.js';
import { vitDir } from '../lib/vit-dir.js';
+import { errorMessage, formatError } from '../lib/error-format.js';
+
+export const LOGIN_COMMON_ISSUES_FOOTER = `Common issues:
+ - make sure the handle is correct and resolves on Bluesky
+ - open the printed URL in your browser, approve vit, and wait for the callback to finish
+ - if you're on a remote machine, rerun with 'vit login --remote' and paste the full callback URL
+ - if you used an app password, create a fresh Bluesky app password and try again
+ - check your DNS, firewall, or VPN settings if vit cannot reach Bluesky`;
function ensureGitignore(dir, entry) {
const gitignorePath = join(dir, '.gitignore');
@@ -21,6 +29,42 @@ function ensureGitignore(dir, entry) {
}
}
+export function cancelLogin({
+ server,
+ rl,
+ timer,
+ clearTimer = clearTimeout,
+ stderr = console.error,
+ exit = process.exit,
+ footer = LOGIN_COMMON_ISSUES_FOOTER,
+}) {
+ try {
+ if (server?.listening) server.close();
+ } catch {
+ // Ignore cleanup failures during cancellation.
+ }
+ try {
+ rl?.close();
+ } catch {
+ // Ignore cleanup failures during cancellation.
+ }
+ try {
+ if (timer) clearTimer(timer);
+ } catch {
+ // Ignore cleanup failures during cancellation.
+ }
+ stderr('\nLogin cancelled.');
+ stderr(footer);
+ exit(130);
+}
+
+export function printLoginFailure(err, { verbose = false, includeFooter = false } = {}) {
+ console.error(formatError(err, { verbose }));
+ if (includeFooter) {
+ console.error(LOGIN_COMMON_ISSUES_FOOTER);
+ }
+}
+
export default function register(program) {
program
.command('login')
@@ -60,7 +104,9 @@ export default function register(program) {
return;
}
}
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`);
+ }
}
} else {
const existing = loadConfig();
@@ -91,7 +137,13 @@ export default function register(program) {
} else {
const sessionFile = configPath('session.json');
let data = {};
- try { data = JSON.parse(readFileSync(sessionFile, 'utf-8')); } catch {}
+ if (existsSync(sessionFile)) {
+ try {
+ data = JSON.parse(readFileSync(sessionFile, 'utf-8'));
+ } catch (err) {
+ console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
+ }
+ }
data[did] = { type: 'app-password', service: 'https://bsky.social', session };
mkdirSync(configDir, { recursive: true });
writeFileSync(sessionFile, JSON.stringify(data, null, 2) + '\n');
@@ -102,7 +154,7 @@ export default function register(program) {
console.log(`Logged in as ${did}`);
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ printLoginFailure(err, { verbose: opts.verbose, includeFooter: true });
process.exitCode = 1;
}
return;
@@ -111,6 +163,8 @@ export default function register(program) {
let server;
let timeout;
let rl;
+ let loginStage = 'preflight';
+ let onSigint = () => {};
try {
let resolveCallback;
@@ -139,6 +193,12 @@ export default function register(program) {
res.end('Not found');
});
+ onSigint = () => {
+ process.off('SIGINT', onSigint);
+ cancelLogin({ server, rl, timer: timeout });
+ };
+ process.once('SIGINT', onSigint);
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
@@ -156,6 +216,7 @@ export default function register(program) {
const sessionStore = createSessionStore();
const client = createOAuthClient({ stateStore, sessionStore, redirectUri });
+ loginStage = 'authorize';
const authUrl = await client.authorize(handle, {
scope: 'atproto transition:generic',
});
@@ -182,6 +243,7 @@ export default function register(program) {
console.log(`Open this URL in your browser:\n ${authUrl.toString()}\n`);
}
+ loginStage = 'callback';
if (isRemote) {
rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question('Paste the callback URL from your browser: ', (line) => {
@@ -229,6 +291,7 @@ export default function register(program) {
}
console.log('Exchanging token...');
+ loginStage = 'token';
const { session } = await client.callback(params);
if (verbose) {
@@ -247,9 +310,13 @@ export default function register(program) {
}
console.log(`Logged in as ${session.did}`);
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ printLoginFailure(err, {
+ verbose: opts.verbose,
+ includeFooter: loginStage !== 'preflight',
+ });
process.exitCode = 1;
} finally {
+ process.removeListener('SIGINT', onSigint);
if (timeout) {
clearTimeout(timeout);
}
diff --git a/src/cmd/remix.js b/src/cmd/remix.js
index 091fea5..744291c 100644
--- a/src/cmd/remix.js
+++ b/src/cmd/remix.js
@@ -12,6 +12,7 @@ import { brand, name } from '../lib/brand.js';
import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js';
import { loadConfig } from '../lib/config.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
+import { formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -173,12 +174,11 @@ export default function register(program) {
console.log('');
console.log(text);
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/scan.js b/src/cmd/scan.js
index adec05b..e54d198 100644
--- a/src/cmd/scan.js
+++ b/src/cmd/scan.js
@@ -7,6 +7,7 @@ import { resolveHandleFromDid } from '../lib/pds.js';
import { brand } from '../lib/brand.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
import { readBeaconSet } from '../lib/vit-dir.js';
+import { formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -91,7 +92,12 @@ export default function register(program) {
ws.onmessage = (event) => {
let msg;
- try { msg = JSON.parse(event.data); } catch { return; }
+ try {
+ msg = JSON.parse(event.data);
+ } catch {
+ console.warn('warning: failed to parse Jetstream event as JSON; skipping');
+ return;
+ }
if (msg.kind !== 'commit' || msg.commit?.operation !== 'create') return;
@@ -197,12 +203,11 @@ export default function register(program) {
console.log(` ${parts.join(' ')}`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/ship.js b/src/cmd/ship.js
index cb4fd44..627d1e3 100644
--- a/src/cmd/ship.js
+++ b/src/cmd/ship.js
@@ -16,6 +16,7 @@ import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
import { toBeacon } from '../lib/beacon.js';
import { hashTo3Words } from '../lib/cap-ref.js';
+import { formatError } from '../lib/error-format.js';
const STOP_WORDS = new Set([
'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
@@ -664,12 +665,11 @@ export default function register(program) {
await shipCap(opts);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
})
diff --git a/src/cmd/skim.js b/src/cmd/skim.js
index 7140b16..003317e 100644
--- a/src/cmd/skim.js
+++ b/src/cmd/skim.js
@@ -10,6 +10,8 @@ import { resolveRef } from '../lib/cap-ref.js';
import { skillRefFromName } from '../lib/skill-ref.js';
import { name } from '../lib/brand.js';
import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js';
+import { jsonError } from '../lib/json-output.js';
+import { formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -208,7 +210,11 @@ export default function register(program) {
}
}
} catch (err) {
- console.error(err instanceof Error ? err.message : String(err));
+ if (opts.json) {
+ jsonError(err);
+ return;
+ }
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/vet.js b/src/cmd/vet.js
index a47cdb6..93db0a1 100644
--- a/src/cmd/vet.js
+++ b/src/cmd/vet.js
@@ -17,6 +17,7 @@ import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js';
import { loadConfig } from '../lib/config.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
import { sandboxArgs } from '../lib/sandbox.js';
+import { formatError } from '../lib/error-format.js';
const execFileAsync = promisify(execFile);
@@ -629,12 +630,11 @@ export default function register(program) {
console.log(` vit vet ${ref} --trust`);
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/cmd/vouch.js b/src/cmd/vouch.js
index 93a522e..6d50401 100644
--- a/src/cmd/vouch.js
+++ b/src/cmd/vouch.js
@@ -12,6 +12,7 @@ import { mark, name } from '../lib/brand.js';
import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js';
import { loadConfig } from '../lib/config.js';
import { jsonOk, jsonError } from '../lib/json-output.js';
+import { formatError } from '../lib/error-format.js';
export default function register(program) {
program
@@ -302,12 +303,11 @@ export default function register(program) {
}
}
} catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
if (opts.json) {
- jsonError(msg);
+ jsonError(err);
return;
}
- console.error(msg);
+ console.error(formatError(err, { verbose: opts.verbose }));
process.exitCode = 1;
}
});
diff --git a/src/lib/config.js b/src/lib/config.js
index d35c8b0..808ccdc 100644
--- a/src/lib/config.js
+++ b/src/lib/config.js
@@ -4,6 +4,7 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { configDir, configPath } from './paths.js';
+import { errorMessage } from './error-format.js';
const vitJsonPath = configPath('vit.json');
@@ -11,7 +12,8 @@ export function loadConfig() {
if (!existsSync(vitJsonPath)) return {};
try {
return JSON.parse(readFileSync(vitJsonPath, 'utf-8'));
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to read ${vitJsonPath}: ${errorMessage(err)}`);
return {};
}
}
@@ -26,13 +28,15 @@ export function saveConfig(obj) {
export function requireDid(opts) {
if (opts?.did) return opts.did;
+ const localLogin = join(process.cwd(), '.vit', 'login.json');
try {
- const localLogin = join(process.cwd(), '.vit', 'login.json');
if (existsSync(localLogin)) {
const local = JSON.parse(readFileSync(localLogin, 'utf-8'));
if (local.did) return local.did;
}
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to read ${localLogin}: ${errorMessage(err)}`);
+ }
const did = loadConfig().did;
if (!did) {
console.error("no DID configured. run 'vit login ' first or pass --did.");
diff --git a/src/lib/error-format.js b/src/lib/error-format.js
new file mode 100644
index 0000000..20f66e7
--- /dev/null
+++ b/src/lib/error-format.js
@@ -0,0 +1,78 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 sol pbc
+
+const MAX_CAUSES = 10;
+
+function isObjectLike(value) {
+ return (typeof value === 'object' && value !== null) || typeof value === 'function';
+}
+
+export function errorMessage(value) {
+ if (value instanceof Error) return value.message || String(value);
+ if (isObjectLike(value) && typeof value.message === 'string') return value.message;
+ return String(value);
+}
+
+function stackLinesOf(value) {
+ if (!(value instanceof Error) || typeof value.stack !== 'string' || value.stack === '') return [];
+ return value.stack.split('\n').map(line => ` ${line}`);
+}
+
+function walkChain(err) {
+ if (!(err instanceof Error)) {
+ return [{ value: err, message: String(err), stackLines: [] }];
+ }
+
+ const levels = [];
+ const seen = new Set();
+ let current = err;
+ let causeCount = 0;
+
+ while (current !== undefined) {
+ if (isObjectLike(current)) {
+ if (seen.has(current)) break;
+ seen.add(current);
+ }
+
+ levels.push({
+ value: current,
+ message: errorMessage(current),
+ stackLines: stackLinesOf(current),
+ });
+
+ if (!isObjectLike(current) || !('cause' in current)) break;
+
+ const next = current.cause;
+ if (next === undefined) break;
+ if (causeCount >= MAX_CAUSES) break;
+
+ current = next;
+ causeCount += 1;
+ }
+
+ return levels;
+}
+
+export function collectCauses(err) {
+ if (!(err instanceof Error)) return [];
+ return walkChain(err).slice(1).map(level => level.message);
+}
+
+export function formatError(err, { hint, verbose = false } = {}) {
+ const levels = walkChain(err);
+ const lines = [];
+
+ for (let i = 0; i < levels.length; i += 1) {
+ const level = levels[i];
+ lines.push(i === 0 ? level.message : ` caused by: ${level.message}`);
+ if (verbose && level.stackLines.length > 0) {
+ lines.push(...level.stackLines);
+ }
+ }
+
+ if (typeof hint === 'string' && hint) {
+ lines.push(`hint: ${hint}`);
+ }
+
+ return lines.join('\n');
+}
diff --git a/src/lib/json-output.js b/src/lib/json-output.js
index 6106522..bd9da90 100644
--- a/src/lib/json-output.js
+++ b/src/lib/json-output.js
@@ -1,13 +1,38 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 sol pbc
+import { collectCauses } from './error-format.js';
+
+function errorMessage(input) {
+ if (input instanceof Error) return input.message || String(input);
+ return String(input);
+}
+
export function jsonOk(data) {
console.log(JSON.stringify({ ok: true, ...data }, null, 2));
}
-export function jsonError(error, hint) {
- const obj = { ok: false, error };
- if (hint) obj.hint = hint;
+export function jsonError(input, hintArg) {
+ if (typeof input === 'string') {
+ const obj = { ok: false, error: input };
+ if (hintArg) obj.hint = hintArg;
+ console.log(JSON.stringify(obj, null, 2));
+ process.exitCode = 1;
+ return;
+ }
+
+ const obj = {
+ ok: false,
+ error: errorMessage(input),
+ };
+ const causes = collectCauses(input);
+ if (causes.length > 0) obj.causes = causes;
+
+ const resolvedHint = typeof hintArg === 'string'
+ ? hintArg
+ : (typeof input?.hint === 'string' ? input.hint : undefined);
+ if (resolvedHint) obj.hint = resolvedHint;
+
console.log(JSON.stringify(obj, null, 2));
process.exitCode = 1;
}
diff --git a/src/lib/oauth.js b/src/lib/oauth.js
index 30ea104..30253de 100644
--- a/src/lib/oauth.js
+++ b/src/lib/oauth.js
@@ -6,6 +6,7 @@ import { NodeOAuthClient } from '@atproto/oauth-client-node';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { configDir, configPath } from './paths.js';
+import { errorMessage } from './error-format.js';
const requestLock = async (_name, fn) => await fn();
@@ -44,9 +45,13 @@ export function createStore() {
export function createSessionStore() {
const sessionFile = configPath('session.json');
let data = {};
- try {
- data = JSON.parse(readFileSync(sessionFile, 'utf-8'));
- } catch {}
+ if (existsSync(sessionFile)) {
+ try {
+ data = JSON.parse(readFileSync(sessionFile, 'utf-8'));
+ } catch (err) {
+ console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
+ }
+ }
return {
set: async (key, value) => {
data[key] = value;
@@ -64,18 +69,22 @@ export function createSessionStore() {
export function checkSession(did) {
// Check project-local app-password session
+ const localPath = join(process.cwd(), '.vit', 'login.json');
try {
- const localPath = join(process.cwd(), '.vit', 'login.json');
if (existsSync(localPath)) {
const local = JSON.parse(readFileSync(localPath, 'utf-8'));
if (local.did === did && local.type === 'app-password' && local.session?.accessJwt) {
return did;
}
}
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`);
+ }
+ const sessionFile = configPath('session.json');
+ if (!existsSync(sessionFile)) return null;
try {
- const raw = readFileSync(configPath('session.json'), 'utf-8');
+ const raw = readFileSync(sessionFile, 'utf-8');
const data = JSON.parse(raw);
const entry = data[did];
if (!entry) return null;
@@ -89,7 +98,8 @@ export function checkSession(did) {
const accessValid = tokenSet.expires_at && new Date(tokenSet.expires_at) > new Date();
if (accessValid || tokenSet.refresh_token) return did;
return null;
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
return null;
}
}
@@ -108,8 +118,8 @@ export function createOAuthClient({ stateStore, sessionStore, redirectUri }) {
export async function restoreAgent(did) {
// Check project-local app-password session
+ const localPath = join(process.cwd(), '.vit', 'login.json');
try {
- const localPath = join(process.cwd(), '.vit', 'login.json');
if (existsSync(localPath)) {
const local = JSON.parse(readFileSync(localPath, 'utf-8'));
if (local.did === did && local.type === 'app-password' && local.session) {
@@ -118,19 +128,26 @@ export async function restoreAgent(did) {
return { agent, session: { did: local.did, handle: local.handle } };
}
}
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`);
+ }
// Check global app-password session
+ const sessionFile = configPath('session.json');
try {
- const raw = readFileSync(configPath('session.json'), 'utf-8');
- const data = JSON.parse(raw);
- const entry = data[did];
- if (entry?.type === 'app-password' && entry.session) {
- const agent = new AtpAgent({ service: entry.service || 'https://bsky.social' });
- await agent.resumeSession(entry.session);
- return { agent, session: { did, handle: entry.session.handle } };
+ if (existsSync(sessionFile)) {
+ const raw = readFileSync(sessionFile, 'utf-8');
+ const data = JSON.parse(raw);
+ const entry = data[did];
+ if (entry?.type === 'app-password' && entry.session) {
+ const agent = new AtpAgent({ service: entry.service || 'https://bsky.social' });
+ await agent.resumeSession(entry.session);
+ return { agent, session: { did, handle: entry.session.handle } };
+ }
}
- } catch {}
+ } catch (err) {
+ console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`);
+ }
// Existing OAuth restore path
const sessionStore = createSessionStore();
diff --git a/src/lib/pds.js b/src/lib/pds.js
index 1fe8749..675f97d 100644
--- a/src/lib/pds.js
+++ b/src/lib/pds.js
@@ -1,9 +1,35 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 sol pbc
+import { errorMessage } from './error-format.js';
+
const PLC_DIRECTORY = 'https://plc.directory';
const pdsCache = new Map();
+function requestErrorMessage(method, url, err) {
+ const code = err?.cause?.code || err?.code;
+ if (code === 'ECONNREFUSED') return `could not connect to ${url} (refused)`;
+ if (code === 'ENOTFOUND') return `could not resolve ${url}`;
+ if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') {
+ return `timed out connecting to ${url}`;
+ }
+ return `request to ${url} failed: ${errorMessage(err)}`;
+}
+
+async function fetchJson(method, url) {
+ const requestUrl = url.toString();
+ try {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`${method} ${requestUrl} returned ${res.status}`);
+ return await res.json();
+ } catch (err) {
+ if (err instanceof Error && err.message.startsWith(`${method} ${requestUrl} returned `)) {
+ throw err;
+ }
+ throw new Error(requestErrorMessage(method, requestUrl, err), { cause: err });
+ }
+}
+
async function fetchDidDocument(did) {
let url;
if (did.startsWith('did:web:')) {
@@ -19,9 +45,7 @@ async function fetchDidDocument(did) {
url = `${PLC_DIRECTORY}/${did}`;
}
- const res = await fetch(url);
- if (!res.ok) throw new Error(`failed to resolve DID document for ${did}: ${res.status} ${res.statusText}`);
- return res.json();
+ return fetchJson('GET', url);
}
export async function resolvePds(did) {
@@ -42,9 +66,7 @@ export async function listRecordsFromPds(pdsUrl, repo, collection, limit) {
url.searchParams.set('collection', collection);
if (limit) url.searchParams.set('limit', String(limit));
if (cursor) url.searchParams.set('cursor', cursor);
- const res = await fetch(url);
- if (!res.ok) throw new Error(`listRecords failed for ${repo}: ${res.status} ${res.statusText}`);
- const data = await res.json();
+ const data = await fetchJson('GET', url);
records.push(...data.records);
cursor = data.cursor;
} while (cursor);
@@ -56,16 +78,15 @@ export async function resolveHandleFromDid(did) {
const doc = await fetchDidDocument(did);
const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://'));
return aka ? aka.replace('at://', '') : did;
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to resolve handle for ${did}: ${errorMessage(err)}`);
return did;
}
}
export async function resolveHandle(handle) {
const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
- const res = await fetch(url);
- if (!res.ok) throw new Error(`could not resolve handle: ${handle}`);
- const data = await res.json();
+ const data = await fetchJson('GET', url);
return data.did;
}
diff --git a/src/lib/vit-dir.js b/src/lib/vit-dir.js
index 5e99bac..0abeb1c 100644
--- a/src/lib/vit-dir.js
+++ b/src/lib/vit-dir.js
@@ -3,6 +3,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
+import { errorMessage } from './error-format.js';
export function vitDir(dir) {
return join(dir || process.cwd(), '.vit');
@@ -13,7 +14,8 @@ export function readProjectConfig(dir) {
if (!existsSync(p)) return {};
try {
return JSON.parse(readFileSync(p, 'utf-8'));
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to read ${p}: ${errorMessage(err)}`);
return {};
}
}
@@ -45,9 +47,17 @@ export function readLog(filename, dir) {
return readFileSync(p, 'utf-8')
.split('\n')
.filter(line => line.trim())
- .map(line => { try { return JSON.parse(line); } catch { return null; } })
+ .map((line, index) => {
+ try {
+ return JSON.parse(line);
+ } catch (err) {
+ console.warn(`warning: skipping malformed line ${index + 1} in ${p}: ${errorMessage(err)}`);
+ return null;
+ }
+ })
.filter(Boolean);
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to read ${p}: ${errorMessage(err)}`);
return [];
}
}
@@ -57,7 +67,8 @@ export function readFollowing(dir) {
if (!existsSync(p)) return [];
try {
return JSON.parse(readFileSync(p, 'utf-8'));
- } catch {
+ } catch (err) {
+ console.warn(`warning: failed to read ${p}: ${errorMessage(err)}`);
return [];
}
}
diff --git a/test/error-format.test.js b/test/error-format.test.js
new file mode 100644
index 0000000..8960233
--- /dev/null
+++ b/test/error-format.test.js
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 sol pbc
+
+import { describe, test, expect } from 'bun:test';
+import { collectCauses, formatError } from '../src/lib/error-format.js';
+
+describe('error-format', () => {
+ test('formats a flat Error', () => {
+ const err = new Error('top level');
+
+ expect(collectCauses(err)).toEqual([]);
+ expect(formatError(err)).toBe('top level');
+ });
+
+ test('formats nested causes', () => {
+ const root = new Error('top level');
+ const middle = new Error('middle');
+ const leaf = new Error('leaf');
+ root.cause = middle;
+ middle.cause = leaf;
+
+ expect(collectCauses(root)).toEqual(['middle', 'leaf']);
+ expect(formatError(root)).toBe([
+ 'top level',
+ ' caused by: middle',
+ ' caused by: leaf',
+ ].join('\n'));
+ });
+
+ test('formats a non-Error throwable as a flat value', () => {
+ expect(collectCauses('boom')).toEqual([]);
+ expect(formatError('boom')).toBe('boom');
+ });
+
+ test('formats a non-Error cause', () => {
+ const err = new Error('top level');
+ err.cause = 'socket closed';
+
+ expect(collectCauses(err)).toEqual(['socket closed']);
+ expect(formatError(err)).toBe([
+ 'top level',
+ ' caused by: socket closed',
+ ].join('\n'));
+ });
+
+ test('stops on circular causes', () => {
+ const root = new Error('top level');
+ const middle = new Error('middle');
+ root.cause = middle;
+ middle.cause = root;
+
+ expect(collectCauses(root)).toEqual(['middle']);
+ expect(formatError(root)).toBe([
+ 'top level',
+ ' caused by: middle',
+ ].join('\n'));
+ });
+
+ test('caps the cause list at 10 levels', () => {
+ const root = new Error('root');
+ let current = root;
+ for (let i = 1; i <= 12; i += 1) {
+ const next = new Error(`cause ${i}`);
+ current.cause = next;
+ current = next;
+ }
+
+ expect(collectCauses(root)).toHaveLength(10);
+ expect(collectCauses(root)[0]).toBe('cause 1');
+ expect(collectCauses(root)[9]).toBe('cause 10');
+ });
+
+ test('includes indented stack traces in verbose mode', () => {
+ const root = new Error('top level');
+ const leaf = new Error('leaf');
+ root.stack = 'Error: top level\nat top';
+ leaf.stack = 'Error: leaf\nat leaf';
+ root.cause = leaf;
+
+ expect(formatError(root, { verbose: true })).toBe([
+ 'top level',
+ ' Error: top level',
+ ' at top',
+ ' caused by: leaf',
+ ' Error: leaf',
+ ' at leaf',
+ ].join('\n'));
+ });
+});
diff --git a/test/explore.test.js b/test/explore.test.js
index 0af82bd..c369b70 100644
--- a/test/explore.test.js
+++ b/test/explore.test.js
@@ -161,7 +161,7 @@ describe('vit explore', () => {
expect(result.exitCode).not.toBe(0);
const data = JSON.parse(result.stdout);
expect(data.ok).toBe(false);
- expect(data.error).toContain('unavailable');
+ expect(data.error).toContain('request to http://localhost:1/api/stats failed');
});
test('graceful error on invalid URL', () => {
@@ -169,7 +169,7 @@ describe('vit explore', () => {
expect(result.exitCode).not.toBe(0);
const data = JSON.parse(result.stdout);
expect(data.ok).toBe(false);
- expect(data.error).toContain('unavailable');
+ expect(data.error).toContain('not-a-url');
});
test('vouches requires --cap or --ref', () => {
@@ -184,7 +184,7 @@ describe('vit explore', () => {
expect(result.exitCode).not.toBe(0);
const data = JSON.parse(result.stdout);
expect(data.ok).toBe(false);
- expect(data.error).toContain('unavailable');
+ expect(data.error).toContain('request to http://localhost:1/api/stats failed');
});
test('flag overrides env var', () => {
@@ -259,7 +259,7 @@ describe('vit explore', () => {
expect(result.exitCode).not.toBe(0);
const data = JSON.parse(result.stdout);
expect(data.ok).toBe(false);
- expect(data.error).toContain('unavailable');
+ expect(data.error).toContain('request to http://localhost:1/api/caps?kind=request failed');
});
test('bare explore returns stats JSON', () => {
diff --git a/test/json-output.test.js b/test/json-output.test.js
index d207832..edcf717 100644
--- a/test/json-output.test.js
+++ b/test/json-output.test.js
@@ -6,6 +6,7 @@ import { run } from './helpers.js';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
+import { jsonError } from '../src/lib/json-output.js';
const agentEnv = { CLAUDECODE: '1' };
@@ -13,8 +14,28 @@ function parseJson(stdout) {
return JSON.parse(stdout);
}
+function captureJsonError(input, hintArg) {
+ const lines = [];
+ const originalLog = console.log;
+ const originalExitCode = process.exitCode;
+ console.log = (line) => lines.push(line);
+ process.exitCode = 0;
+
+ try {
+ jsonError(input, hintArg);
+ return {
+ exitCode: process.exitCode,
+ parsed: JSON.parse(lines.join('')),
+ };
+ } finally {
+ console.log = originalLog;
+ process.exitCode = originalExitCode;
+ }
+}
+
describe('--json flag', () => {
let tmpDir;
+ let tmpHome;
beforeEach(() => {
tmpDir = join(tmpdir(), '.test-json-' + Math.random().toString(36).slice(2));
@@ -23,8 +44,15 @@ describe('--json flag', () => {
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
+ if (tmpHome) rmSync(tmpHome, { recursive: true, force: true });
});
+ function doctorEnv() {
+ tmpHome = join(tmpdir(), '.test-json-doctor-' + Math.random().toString(36).slice(2));
+ mkdirSync(tmpHome, { recursive: true });
+ return { HOME: tmpHome, XDG_CONFIG_HOME: join(tmpHome, '.config') };
+ }
+
describe('init --json', () => {
test('reports status as JSON when not initialized', () => {
const r = run('init --json', tmpDir, agentEnv);
@@ -59,7 +87,7 @@ describe('--json flag', () => {
describe('doctor --json', () => {
test('returns health report as JSON', () => {
- const r = run('doctor --json');
+ const r = run('doctor --json', undefined, doctorEnv());
const j = parseJson(r.stdout);
expect(j.ok).toBe(true);
expect(j).toHaveProperty('install');
@@ -68,7 +96,7 @@ describe('--json flag', () => {
});
test('status --json also works', () => {
- const r = run('status --json');
+ const r = run('status --json', undefined, doctorEnv());
const j = parseJson(r.stdout);
expect(j.ok).toBe(true);
});
@@ -250,4 +278,33 @@ describe('--json flag', () => {
expect(j.error).toContain('--days must be a positive integer');
});
});
+
+ describe('jsonError throwable input', () => {
+ test('includes a causes array for nested causes', () => {
+ const root = new Error('top level');
+ const leaf = new Error('leaf');
+ root.cause = leaf;
+
+ const { parsed, exitCode } = captureJsonError(root);
+ expect(exitCode).toBe(1);
+ expect(parsed).toEqual({
+ ok: false,
+ error: 'top level',
+ causes: ['leaf'],
+ });
+ });
+
+ test('omits causes for flat errors', () => {
+ class XRPCError extends Error {}
+ const err = new XRPCError('Invalid identifier or password');
+
+ const { parsed, exitCode } = captureJsonError(err);
+ expect(exitCode).toBe(1);
+ expect(parsed).toEqual({
+ ok: false,
+ error: 'Invalid identifier or password',
+ });
+ expect(parsed).not.toHaveProperty('causes');
+ });
+ });
});
diff --git a/test/login.test.js b/test/login.test.js
index 3646898..fda2f78 100644
--- a/test/login.test.js
+++ b/test/login.test.js
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 sol pbc
-import { describe, test, expect } from 'bun:test';
+import { describe, test, expect, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { run } from './helpers.js';
+import { cancelLogin, printLoginFailure, LOGIN_COMMON_ISSUES_FOOTER } from '../src/cmd/login.js';
describe('login', () => {
test('--help shows --remote and --browser options', () => {
@@ -44,4 +45,81 @@ describe('login', () => {
rmSync(tmp, { recursive: true });
}
});
+
+ test('cancelLogin closes resources, prints footer, and exits 130', () => {
+ const lines = [];
+ const server = { listening: true, close: () => lines.push('server.close') };
+ const rl = { close: () => lines.push('rl.close') };
+ const cleared = [];
+ const exits = [];
+ const timer = { id: 'timer' };
+
+ cancelLogin({
+ server,
+ rl,
+ timer,
+ clearTimer: (value) => cleared.push(value),
+ stderr: (line) => lines.push(line),
+ exit: (code) => exits.push(code),
+ });
+
+ expect(lines).toContain('server.close');
+ expect(lines).toContain('rl.close');
+ expect(lines).toContain('\nLogin cancelled.');
+ expect(lines).toContain(LOGIN_COMMON_ISSUES_FOOTER);
+ expect(cleared).toEqual([timer]);
+ expect(exits).toEqual([130]);
+ });
+
+ test('cancelLogin skips server.close when the server is not listening', () => {
+ let serverClosed = false;
+ let rlClosed = false;
+
+ cancelLogin({
+ server: { listening: false, close: () => { serverClosed = true; } },
+ rl: { close: () => { rlClosed = true; } },
+ timer: null,
+ stderr: () => {},
+ exit: () => {},
+ });
+
+ expect(serverClosed).toBe(false);
+ expect(rlClosed).toBe(true);
+ });
+
+ test('printLoginFailure renders cause chains', () => {
+ const root = new Error('Failed to resolve identity: bogus-handle.invalid');
+ const middle = new Error('Handle bogus-handle.invalid does not resolve to a DID');
+ const leaf = new Error('fetch failed');
+ root.cause = middle;
+ middle.cause = leaf;
+
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ printLoginFailure(root, { verbose: false, includeFooter: true });
+ const output = errorSpy.mock.calls.map(args => args.join(' ')).join('\n');
+ expect(output).toContain('Failed to resolve identity: bogus-handle.invalid');
+ expect(output).toContain('caused by: Handle bogus-handle.invalid does not resolve to a DID');
+ expect(output).toContain('caused by: fetch failed');
+ expect(output).toContain(LOGIN_COMMON_ISSUES_FOOTER);
+ } finally {
+ errorSpy.mockRestore();
+ }
+ });
+
+ test('printLoginFailure includes the footer for timeout errors', () => {
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ printLoginFailure(new Error('Timed out waiting for callback.'), {
+ verbose: false,
+ includeFooter: true,
+ });
+ const output = errorSpy.mock.calls.map(args => args.join(' ')).join('\n');
+ expect(output).toContain('Timed out waiting for callback.');
+ expect(output).toContain('Common issues:');
+ expect(output).toContain("vit login --remote");
+ } finally {
+ errorSpy.mockRestore();
+ }
+ });
});
diff --git a/test/pds.test.js b/test/pds.test.js
index a867014..930f0d5 100644
--- a/test/pds.test.js
+++ b/test/pds.test.js
@@ -143,7 +143,9 @@ describe('pds', () => {
test('throws on non-ok response', async () => {
global.fetch = async () => jsonResponse({}, { ok: false, status: 404, statusText: 'Not Found' });
- await expect(resolveHandle('nonexistent.test')).rejects.toThrow('could not resolve handle: nonexistent.test');
+ await expect(resolveHandle('nonexistent.test')).rejects.toThrow(
+ 'GET https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=nonexistent.test returned 404',
+ );
});
});
@@ -197,7 +199,7 @@ describe('pds', () => {
global.fetch = async () => jsonResponse({}, { ok: false, status: 500, statusText: 'Server Error' });
await expect(listRecordsFromPds('https://pds.example.com', 'did:plc:three', 'app.test.record', 50)).rejects.toThrow(
- 'listRecords failed for did:plc:three: 500 Server Error',
+ 'GET https://pds.example.com/xrpc/com.atproto.repo.listRecords?repo=did%3Aplc%3Athree&collection=app.test.record&limit=50 returned 500',
);
});
});
]