open source is social v-it.org
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: unify CLI error surface with cause-unwrapping helper

Add a shared error formatter that unwraps cause chains for both text and
JSON output, route the CLI command catches through it, tighten the login
failure path with a SIGINT cancel helper and common-issues footer, make
network errors name the exact URL they failed against, replace the
approved hidden catches with warnings, and document login troubleshooting
in the getting-started guide.

Files touched:
- helpers: src/lib/error-format.js, src/lib/json-output.js
- commands: src/cmd/adopt.js, src/cmd/beacon.js, src/cmd/config.js,
src/cmd/doctor.js, src/cmd/explore.js, src/cmd/firehose.js,
src/cmd/follow.js, src/cmd/hack.js, src/cmd/inbox.js,
src/cmd/init.js, src/cmd/learn.js, src/cmd/link.js,
src/cmd/login.js, src/cmd/remix.js, src/cmd/scan.js,
src/cmd/ship.js, src/cmd/skim.js, src/cmd/vet.js,
src/cmd/vouch.js
- libs: src/lib/config.js, src/lib/oauth.js, src/lib/pds.js,
src/lib/vit-dir.js
- docs/tests: docs/start/index.html, test/error-format.test.js,
test/explore.test.js, test/json-output.test.js,
test/login.test.js, test/pds.test.js

Silent-catch ledger:
- src/lib/config.js: warn on unreadable vit.json; warn on unreadable
local .vit/login.json.
- src/lib/vit-dir.js: warn on unreadable .vit/config.json; warn on
malformed JSONL lines; warn on unreadable JSONL logs; warn on
unreadable following.json.
- src/lib/oauth.js: warn on unreadable session.json in the session
store, checkSession, and restoreAgent paths; warn on unreadable local
app-password sessions in checkSession and restoreAgent.
- src/lib/pds.js: warn when DID-document handle resolution fails, then
fall back to the DID.
- src/cmd/doctor.js: warn on unreadable SKILL.md files, unreadable
skill directories, install-path inspect failure, unreadable
local/global session files, and unexpected Bluesky session validation
failures.
- src/cmd/beacon.js: warn on invalid target .vit/config.json content.
- src/cmd/hack.js: warn when git remote add upstream fails.
- src/cmd/learn.js: warn when temp-dir cleanup fails.
- src/cmd/login.js: keep the .gitignore probe silent; warn on
unreadable local/global session files during login probes.
- src/cmd/firehose.js: warn and skip malformed JSON messages.
- src/cmd/scan.js: warn and skip malformed Jetstream events.
- src/cmd/init.js: keep the git work-tree probe and optional remote URL
probe chain silent; warn on git remote failure and unreadable remote
URLs.
- src/lib/compat.js: keep the executable lookup probe silent.

Test changes:
- added test/error-format.test.js for flat errors, nested causes,
non-Error throwables, non-Error causes, circular causes, the 10-level
cap, and verbose stack indentation
- added login coverage for cancelLogin() and printLoginFailure()
- added json-output throwable-input coverage for nested and flat errors
- updated explore and pds expectations for URL-specific error messages

Not done / founder steps:
- make ship BUMP=patch (-> 0.4.3): founder's step.
- make deploy-site: founder's step. Required because
docs/start/index.html changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+656 -180
+1
.gitignore
··· 9 9 10 10 # Skills 11 11 .agents/ 12 + .agent/ 12 13 .claude/skills/ 13 14 skills-lock.json 14 15
+14
docs/start/index.html
··· 396 396 397 397 <hr> 398 398 399 + <h3>login troubleshooting</h3> 400 + <p>if <code>vit login</code> fails, these are the usual fixes:</p> 401 + <ul> 402 + <li>make sure the handle is correct and resolves on Bluesky.</li> 403 + <li>open the printed URL in your browser, approve vit, and wait for the callback to finish.</li> 404 + <li>if you&#39;re on a remote machine, rerun with <code>vit login &lt;handle&gt; --remote</code> and paste the full callback URL.</li> 405 + <li>if you used an app password, create a fresh Bluesky app password and try again.</li> 406 + <li>check your DNS, firewall, or VPN settings if vit cannot reach Bluesky.</li> 407 + </ul> 408 + <span class="cmd-label">you run this (terminal, on a remote machine)</span> 409 + <pre><code>vit login your-handle.bsky.social --remote</code></pre> 410 + 411 + <hr> 412 + 399 413 <h2>learn more</h2> 400 414 <ul> 401 415 <li><a href="/doctrine/">the doctrine</a> &mdash; why vit exists and how it works</li>
+2 -1
src/cmd/adopt.js
··· 8 8 import { requireNotAgent } from '../lib/agent.js'; 9 9 import { which } from '../lib/compat.js'; 10 10 import { mark, name } from '../lib/brand.js'; 11 + import { formatError } from '../lib/error-format.js'; 11 12 12 13 export default function register(program) { 13 14 program ··· 85 86 console.log(''); 86 87 console.log(`next: start your agent and ask it to run '${name} init'`); 87 88 } catch (err) { 88 - console.error(err instanceof Error ? err.message : String(err)); 89 + console.error(formatError(err, { verbose: opts.verbose })); 89 90 process.exitCode = 1; 90 91 } 91 92 });
+5 -2
src/cmd/beacon.js
··· 6 6 import { memfs } from 'memfs'; 7 7 import { beaconToHttps } from '../lib/beacon.js'; 8 8 import { mark } from '../lib/brand.js'; 9 + import { errorMessage, formatError } from '../lib/error-format.js'; 9 10 10 11 async function readTreeFile(fs, dir, treeOid, pathParts) { 11 12 for (let i = 0; i < pathParts.length; i++) { ··· 47 48 let beacon; 48 49 try { 49 50 beacon = content && JSON.parse(content).beacon; 50 - } catch {} 51 + } catch (err) { 52 + console.warn(`warning: failed to parse .vit/config.json from ${url}: ${errorMessage(err)}`); 53 + } 51 54 52 55 if (beacon) { 53 56 console.log(`${mark} beacon: lit ${beacon}`); ··· 56 59 console.log("the maintainer can light the beacon by running 'vit init' inside the repo."); 57 60 } 58 61 } catch (err) { 59 - console.error(err instanceof Error ? err.message : String(err)); 62 + console.error(formatError(err, { verbose: opts.verbose })); 60 63 process.exitCode = 1; 61 64 } 62 65 });
+2 -1
src/cmd/config.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { loadConfig, saveConfig, getScalars } from '../lib/config.js'; 5 + import { formatError } from '../lib/error-format.js'; 5 6 6 7 function coerceValue(str) { 7 8 if (str === 'true') return true; ··· 61 62 console.error(`Unknown action: ${action}`); 62 63 process.exitCode = 1; 63 64 } catch (err) { 64 - console.error(err instanceof Error ? err.message : String(err)); 65 + console.error(formatError(err, { verbose: false })); 65 66 process.exitCode = 1; 66 67 } 67 68 });
+25 -12
src/cmd/doctor.js
··· 11 11 import { which } from '../lib/compat.js'; 12 12 import { jsonOk, jsonError } from '../lib/json-output.js'; 13 13 import { configPath } from '../lib/paths.js'; 14 + import { errorMessage, formatError } from '../lib/error-format.js'; 14 15 15 16 function scanSkillDir(dir) { 16 17 const skills = []; ··· 29 30 const versionMatch = match[1].match(/^version:\s*(.+)$/m); 30 31 if (versionMatch) version = versionMatch[1].trim(); 31 32 } 32 - } catch { /* ignore read errors */ } 33 + } catch (err) { 34 + console.warn(`warning: failed to read ${skillMd}: ${errorMessage(err)}`); 35 + } 33 36 skills.push({ name: entry.name, version }); 34 37 } 35 38 } 36 - } catch { /* ignore dir read errors */ } 39 + } catch (err) { 40 + console.warn(`warning: failed to read skill directory ${dir}: ${errorMessage(err)}`); 41 + } 37 42 return skills; 38 43 } 39 44 ··· 66 71 installType = 'source'; 67 72 if (!opts.json) console.log(`${mark} install: source (${vitPath})`); 68 73 } 69 - } catch { 74 + } catch (err) { 75 + console.warn(`warning: failed to inspect install path ${vitPath}: ${errorMessage(err)}`); 70 76 installType = 'source'; 71 77 if (!opts.json) console.log(`${mark} install: source (${vitPath})`); 72 78 } ··· 124 130 authType = local.type || 'oauth'; 125 131 } 126 132 } 127 - } catch {} 133 + } catch (err) { 134 + console.warn(`warning: failed to read ${localLoginPath}: ${errorMessage(err)}`); 135 + } 128 136 129 137 if (!identitySource && effectiveDid) identitySource = 'global'; 130 138 131 139 if (identitySource === 'global' && effectiveDid) { 140 + const sessionFile = configPath('session.json'); 132 141 try { 133 - const raw = readFileSync(configPath('session.json'), 'utf-8'); 134 - const sessionData = JSON.parse(raw); 135 - if (sessionData[effectiveDid]?.type === 'app-password') authType = 'app-password'; 136 - } catch {} 142 + if (existsSync(sessionFile)) { 143 + const raw = readFileSync(sessionFile, 'utf-8'); 144 + const sessionData = JSON.parse(raw); 145 + if (sessionData[effectiveDid]?.type === 'app-password') authType = 'app-password'; 146 + } 147 + } catch (err) { 148 + console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`); 149 + } 137 150 } 138 151 139 152 if (!effectiveDid) { ··· 144 157 blueskyOk = true; 145 158 pds = session.serverMetadata?.issuer || null; 146 159 if (!opts.json) console.log(`${mark} bluesky: ok (${session.did || effectiveDid}${pds ? ', ' + pds : ''})`); 147 - } catch { 160 + } catch (err) { 161 + console.warn(`warning: failed to validate Bluesky session: ${errorMessage(err)}`); 148 162 if (!opts.json) console.log(`${mark} bluesky: token expired or invalid (run ${name} login <handle>)`); 149 163 } 150 164 if (!opts.json) console.log(`${mark} identity: ${identitySource} (${authType})`); ··· 161 175 }); 162 176 } 163 177 } catch (err) { 164 - const msg = err instanceof Error ? err.message : String(err); 165 178 if (opts.json) { 166 - jsonError(msg); 179 + jsonError(err); 167 180 return; 168 181 } 169 - console.error(msg); 182 + console.error(formatError(err, { verbose: false })); 170 183 process.exitCode = 1; 171 184 } 172 185 }
+43 -70
src/cmd/explore.js
··· 5 5 import { readProjectConfig } from '../lib/vit-dir.js'; 6 6 import { brand } from '../lib/brand.js'; 7 7 import { jsonOk, jsonError } from '../lib/json-output.js'; 8 + import { errorMessage, formatError } from '../lib/error-format.js'; 8 9 9 10 function timeAgo(isoString) { 10 11 const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); ··· 25 26 return opts.exploreUrl || process.env.VIT_EXPLORE_URL || DEFAULT_EXPLORE_URL; 26 27 } 27 28 28 - function unavailableMessage(baseUrl) { 29 + function requestErrorMessage(method, url, err) { 30 + const code = err?.cause?.code || err?.code; 31 + if (code === 'ECONNREFUSED') return `could not connect to ${url} (refused)`; 32 + if (code === 'ENOTFOUND') return `could not resolve ${url}`; 33 + if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') { 34 + return `timed out connecting to ${url}`; 35 + } 36 + return `request to ${url} failed: ${errorMessage(err)}`; 37 + } 38 + 39 + async function fetchExploreJson(url) { 40 + const requestUrl = url.toString(); 29 41 try { 30 - return `${new URL(baseUrl).host} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`; 31 - } catch { 32 - return `${baseUrl} is unavailable. try 'vit scan' for network-wide discovery or 'vit skim' for your followed accounts.`; 42 + const res = await fetch(url); 43 + if (!res.ok) throw new Error(`GET ${requestUrl} returned ${res.status}`); 44 + return await res.json(); 45 + } catch (err) { 46 + if (err instanceof Error && err.message.startsWith(`GET ${requestUrl} returned `)) { 47 + throw err; 48 + } 49 + throw new Error(requestErrorMessage('GET', requestUrl, err), { cause: err }); 33 50 } 34 51 } 35 52 ··· 44 61 const baseUrl = resolveUrl(opts); 45 62 try { 46 63 const url = new URL('/api/stats', baseUrl); 47 - const res = await fetch(url); 48 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 49 - const data = await res.json(); 64 + const data = await fetchExploreJson(url); 50 65 51 66 if (opts.json) { 52 67 jsonOk(data); ··· 58 73 console.log(` vouches: ${data.total_vouches} beacons: ${data.total_beacons}`); 59 74 console.log(` active dids: ${data.active_dids} skill publishers: ${data.skill_publishers}`); 60 75 } catch (err) { 61 - const msg = err instanceof Error ? err.message : String(err); 62 - const finalMsg = msg.startsWith('explore API returned ') 63 - ? msg 64 - : unavailableMessage(baseUrl); 65 76 if (opts.json) { 66 - jsonError(finalMsg); 77 + jsonError(err); 67 78 return; 68 79 } 69 - console.error(finalMsg); 80 + console.error(formatError(err, { verbose: false })); 70 81 process.exitCode = 1; 71 82 } 72 83 } ··· 97 108 url.searchParams.set('ref', ref); 98 109 if (opts.beacon) url.searchParams.set('beacon', opts.beacon); 99 110 100 - const res = await fetch(url); 101 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 102 - const data = await res.json(); 111 + const data = await fetchExploreJson(url); 103 112 104 113 if (!data.cap) { 105 114 const msg = `no cap found with ref '${ref}'`; ··· 136 145 console.log(` vit vet ${data.cap.ref} - inspect before adopting`); 137 146 console.log(` vit remix ${data.cap.ref} - remix this cap`); 138 147 } catch (err) { 139 - const msg = err instanceof Error ? err.message : String(err); 140 - const finalMsg = msg.startsWith('explore API returned ') 141 - ? msg 142 - : unavailableMessage(baseUrl); 143 148 if (opts.json) { 144 - jsonError(finalMsg); 149 + jsonError(err); 145 150 return; 146 151 } 147 - console.error(finalMsg); 152 + console.error(formatError(err, { verbose: false })); 148 153 process.exitCode = 1; 149 154 } 150 155 }); ··· 163 168 const url = new URL('/api/skill', baseUrl); 164 169 url.searchParams.set('name', name); 165 170 166 - const res = await fetch(url); 167 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 168 - const data = await res.json(); 171 + const data = await fetchExploreJson(url); 169 172 170 173 if (!data.skill) { 171 174 const msg = `no skill found with name '${name}'`; ··· 195 198 console.log(); 196 199 console.log(` vit learn skill-${data.skill.name} - install this skill`); 197 200 } catch (err) { 198 - const msg = err instanceof Error ? err.message : String(err); 199 - const finalMsg = msg.startsWith('explore API returned ') 200 - ? msg 201 - : unavailableMessage(baseUrl); 202 201 if (opts.json) { 203 - jsonError(finalMsg); 202 + jsonError(err); 204 203 return; 205 204 } 206 - console.error(finalMsg); 205 + console.error(formatError(err, { verbose: false })); 207 206 process.exitCode = 1; 208 207 } 209 208 }); ··· 245 244 if (opts.limit) url.searchParams.set('limit', opts.limit); 246 245 if (opts.cursor) url.searchParams.set('cursor', opts.cursor); 247 246 248 - const res = await fetch(url); 249 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 250 - const data = await res.json(); 247 + const data = await fetchExploreJson(url); 251 248 252 249 if (opts.json) { 253 250 jsonOk({ caps: data.caps, cursor: data.cursor }); ··· 271 268 console.log(`\nnext: --cursor ${data.cursor}`); 272 269 } 273 270 } catch (err) { 274 - const msg = err instanceof Error ? err.message : String(err); 275 - const finalMsg = msg.startsWith('explore API returned ') 276 - ? msg 277 - : unavailableMessage(baseUrl); 278 271 if (opts.json) { 279 - jsonError(finalMsg); 272 + jsonError(err); 280 273 return; 281 274 } 282 - console.error(finalMsg); 275 + console.error(formatError(err, { verbose: false })); 283 276 process.exitCode = 1; 284 277 } 285 278 }); ··· 302 295 if (opts.limit) url.searchParams.set('limit', opts.limit); 303 296 if (opts.cursor) url.searchParams.set('cursor', opts.cursor); 304 297 305 - const res = await fetch(url); 306 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 307 - const data = await res.json(); 298 + const data = await fetchExploreJson(url); 308 299 309 300 if (opts.json) { 310 301 jsonOk({ skills: data.skills, cursor: data.cursor }); ··· 326 317 console.log(`\nnext: --cursor ${data.cursor}`); 327 318 } 328 319 } catch (err) { 329 - const msg = err instanceof Error ? err.message : String(err); 330 - const finalMsg = msg.startsWith('explore API returned ') 331 - ? msg 332 - : unavailableMessage(baseUrl); 333 320 if (opts.json) { 334 - jsonError(finalMsg); 321 + jsonError(err); 335 322 return; 336 323 } 337 - console.error(finalMsg); 324 + console.error(formatError(err, { verbose: false })); 338 325 process.exitCode = 1; 339 326 } 340 327 }); ··· 350 337 351 338 try { 352 339 const url = new URL('/api/beacons', baseUrl); 353 - const res = await fetch(url); 354 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 355 - const data = await res.json(); 340 + const data = await fetchExploreJson(url); 356 341 357 342 if (opts.json) { 358 343 jsonOk({ beacons: data.beacons }); ··· 371 356 console.log(` caps: ${beacon.cap_count} vouches: ${beacon.vouch_count} last active: ${beacon.last_activity}`); 372 357 } 373 358 } catch (err) { 374 - const msg = err instanceof Error ? err.message : String(err); 375 - const finalMsg = msg.startsWith('explore API returned ') 376 - ? msg 377 - : unavailableMessage(baseUrl); 378 359 if (opts.json) { 379 - jsonError(finalMsg); 360 + jsonError(err); 380 361 return; 381 362 } 382 - console.error(finalMsg); 363 + console.error(formatError(err, { verbose: false })); 383 364 process.exitCode = 1; 384 365 } 385 366 }); ··· 413 394 const capsUrl = new URL('/api/caps', baseUrl); 414 395 if (opts.beacon) capsUrl.searchParams.set('beacon', opts.beacon); 415 396 416 - const capsRes = await fetch(capsUrl); 417 - if (!capsRes.ok) throw new Error(`explore API returned ${capsRes.status}`); 418 - const capsData = await capsRes.json(); 397 + const capsData = await fetchExploreJson(capsUrl); 419 398 const match = capsData.caps?.find((cap) => cap.ref === opts.ref); 420 399 421 400 if (!match) { ··· 435 414 const url = new URL('/api/vouches', baseUrl); 436 415 url.searchParams.set('cap_uri', capUri); 437 416 438 - const res = await fetch(url); 439 - if (!res.ok) throw new Error(`explore API returned ${res.status}`); 440 - const data = await res.json(); 417 + const data = await fetchExploreJson(url); 441 418 442 419 if (opts.json) { 443 420 jsonOk({ vouches: data.vouches, cap_uri: capUri }); ··· 458 435 if (ref) console.log(` ${ref}`); 459 436 } 460 437 } catch (err) { 461 - const msg = err instanceof Error ? err.message : String(err); 462 - const finalMsg = msg.startsWith('explore API returned ') 463 - ? msg 464 - : unavailableMessage(baseUrl); 465 438 if (opts.json) { 466 - jsonError(finalMsg); 439 + jsonError(err); 467 440 return; 468 441 } 469 - console.error(finalMsg); 442 + console.error(formatError(err, { verbose: false })); 470 443 process.exitCode = 1; 471 444 } 472 445 });
+3 -2
src/cmd/firehose.js
··· 5 5 import { CAP_COLLECTION, DEFAULT_JETSTREAM_URL } from '../lib/constants.js'; 6 6 import { resolveRef } from '../lib/cap-ref.js'; 7 7 import { brand } from '../lib/brand.js'; 8 + import { formatError } from '../lib/error-format.js'; 8 9 9 10 let ws = null; 10 11 let shuttingDown = false; ··· 72 73 try { 73 74 msg = JSON.parse(event.data); 74 75 } catch { 75 - console.log('Warning: failed to parse message as JSON; skipping'); 76 + console.warn('warning: failed to parse message as JSON; skipping'); 76 77 return; 77 78 } 78 79 ··· 152 153 153 154 connect(opts, null); 154 155 } catch (err) { 155 - console.error(err instanceof Error ? err.message : String(err)); 156 + console.error(formatError(err, { verbose: opts.verbose })); 156 157 process.exitCode = 1; 157 158 } 158 159 });
+7 -9
src/cmd/follow.js
··· 6 6 import { readFollowing, writeFollowing } from '../lib/vit-dir.js'; 7 7 import { mark } from '../lib/brand.js'; 8 8 import { jsonOk, jsonError } from '../lib/json-output.js'; 9 + import { formatError } from '../lib/error-format.js'; 9 10 10 11 export default function register(program) { 11 12 program ··· 55 56 } 56 57 console.log(`${mark} following ${handle} (${targetDid})`); 57 58 } catch (err) { 58 - const msg = err instanceof Error ? err.message : String(err); 59 59 if (opts.json) { 60 - jsonError(msg); 60 + jsonError(err); 61 61 return; 62 62 } 63 - console.error(msg); 63 + console.error(formatError(err, { verbose: opts.verbose })); 64 64 process.exitCode = 1; 65 65 } 66 66 }); ··· 97 97 } 98 98 console.log(`${mark} unfollowed ${handle}`); 99 99 } catch (err) { 100 - const msg = err instanceof Error ? err.message : String(err); 101 100 if (opts.json) { 102 - jsonError(msg); 101 + jsonError(err); 103 102 return; 104 103 } 105 - console.error(msg); 104 + console.error(formatError(err, { verbose: opts.verbose })); 106 105 process.exitCode = 1; 107 106 } 108 107 }); ··· 134 133 console.log(`${e.handle} (${e.did})`); 135 134 } 136 135 } catch (err) { 137 - const msg = err instanceof Error ? err.message : String(err); 138 136 if (opts.json) { 139 - jsonError(msg); 137 + jsonError(err); 140 138 return; 141 139 } 142 - console.error(msg); 140 + console.error(formatError(err, { verbose: opts.verbose })); 143 141 process.exitCode = 1; 144 142 } 145 143 });
+5 -2
src/cmd/hack.js
··· 6 6 import { join, resolve } from 'node:path'; 7 7 import { which } from '../lib/compat.js'; 8 8 import { mark, name } from '../lib/brand.js'; 9 + import { errorMessage, formatError } from '../lib/error-format.js'; 9 10 10 11 export default function register(program) { 11 12 program ··· 69 70 if (opts.from && cloned) { 70 71 try { 71 72 execFileSync('git', ['remote', 'add', 'upstream', 'https://github.com/solpbc/vit.git'], { stdio: 'inherit' }); 72 - } catch {} 73 + } catch (err) { 74 + console.warn(`warning: failed to add upstream remote: ${errorMessage(err)}`); 75 + } 73 76 } 74 77 75 78 console.log(''); 76 79 console.log(`${mark} ${name} installed from source`); 77 80 console.log(`run: cd ${dirName}`); 78 81 } catch (err) { 79 - console.error(err instanceof Error ? err.message : String(err)); 82 + console.error(formatError(err, { verbose: false })); 80 83 process.exitCode = 1; 81 84 } 82 85 });
+4 -4
src/cmd/inbox.js
··· 5 5 import { readBeaconSet } from '../lib/vit-dir.js'; 6 6 import { brand } from '../lib/brand.js'; 7 7 import { jsonOk, jsonError } from '../lib/json-output.js'; 8 + import { errorMessage, formatError } from '../lib/error-format.js'; 8 9 9 10 function timeAgo(isoString) { 10 11 const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); ··· 67 68 if (!res.ok) throw new Error(`explore API returned ${res.status}`); 68 69 data = await res.json(); 69 70 } catch (err) { 70 - const msg = err instanceof Error ? err.message : String(err); 71 + const msg = errorMessage(err); 71 72 const finalMsg = msg.startsWith('explore API returned ') 72 73 ? msg 73 74 : unavailableMessage(baseUrl); ··· 119 120 console.log(`tip: 'vit vouch <ref> --kind want' to signal demand`); 120 121 console.log(` 'vit ship --recap <ref>' to ship an implementation`); 121 122 } catch (err) { 122 - const msg = err instanceof Error ? err.message : String(err); 123 123 if (opts.json) { 124 - jsonError(msg); 124 + jsonError(err); 125 125 return; 126 126 } 127 - console.error(msg); 127 + console.error(formatError(err, { verbose: false })); 128 128 process.exitCode = 1; 129 129 } 130 130 });
+8 -5
src/cmd/init.js
··· 9 9 import { requireAgent } from '../lib/agent.js'; 10 10 import { mark, name, DOT_VIT_README } from '../lib/brand.js'; 11 11 import { jsonOk, jsonError } from '../lib/json-output.js'; 12 + import { errorMessage, formatError } from '../lib/error-format.js'; 12 13 13 14 export default function register(program) { 14 15 program ··· 98 99 .trim() 99 100 .split('\n') 100 101 .filter(Boolean); 101 - } catch { 102 + } catch (err) { 103 + console.warn(`warning: failed to list git remotes: ${errorMessage(err)}`); 102 104 remoteNames = []; 103 105 } 104 106 if (verbose) vlog(`[verbose] remotes detected: ${remoteNames.length > 0 ? remoteNames.join(', ') : 'none'}`); ··· 111 113 stdio: ['pipe', 'pipe', 'pipe'], 112 114 }).trim(); 113 115 if (url) remotes.push({ name, url }); 114 - } catch {} 116 + } catch (err) { 117 + console.warn(`warning: failed to read git remote ${name} url: ${errorMessage(err)}`); 118 + } 115 119 } 116 120 if (verbose && remotes.length > 0) { 117 121 vlog(`[verbose] remote urls: ${remotes.map(r => `${r.name}=${r.url}`).join(' ')}`); ··· 231 235 console.log(`${mark} secondary beacon: ${merged.secondaryBeacon}`); 232 236 } 233 237 } catch (err) { 234 - const msg = err instanceof Error ? err.message : String(err); 235 238 if (opts.json) { 236 - jsonError(msg); 239 + jsonError(err); 237 240 return; 238 241 } 239 - console.error(msg); 242 + console.error(formatError(err, { verbose: opts.verbose })); 240 243 process.exitCode = 1; 241 244 } 242 245 });
+8 -4
src/cmd/learn.js
··· 16 16 import { resolvePds, resolveHandle, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 17 17 import { loadConfig } from '../lib/config.js'; 18 18 import { jsonOk, jsonError } from '../lib/json-output.js'; 19 + import { errorMessage, formatError } from '../lib/error-format.js'; 19 20 20 21 async function installSkill({ match, skillName, isGlobal, opts, ref }) { 21 22 const { verbose } = opts; ··· 65 66 } 66 67 if (verbose) vlog('[verbose] installed via npx skills add'); 67 68 } finally { 68 - try { rmSync(tempDir, { recursive: true, force: true }); } catch {} 69 + try { 70 + rmSync(tempDir, { recursive: true, force: true }); 71 + } catch (err) { 72 + console.warn(`warning: failed to remove temporary directory ${tempDir}: ${errorMessage(err)}`); 73 + } 69 74 } 70 75 71 76 const installDir = isGlobal ··· 404 409 405 410 await installSkill({ match, skillName, isGlobal: !!opts.user, opts, ref }); 406 411 } catch (err) { 407 - const msg = err instanceof Error ? err.message : String(err); 408 412 if (opts.json) { 409 - jsonError(msg); 413 + jsonError(err); 410 414 return; 411 415 } 412 - console.error(msg); 416 + console.error(formatError(err, { verbose: opts.verbose })); 413 417 process.exitCode = 1; 414 418 } 415 419 });
+2 -1
src/cmd/link.js
··· 4 4 import { existsSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync, writeFileSync } from 'node:fs'; 5 5 import { join, resolve } from 'node:path'; 6 6 import { mark, name } from '../lib/brand.js'; 7 + import { formatError } from '../lib/error-format.js'; 7 8 8 9 export default function register(program) { 9 10 program ··· 68 69 console.log(`${mark} PATH: ok`); 69 70 } 70 71 } catch (err) { 71 - console.error(err instanceof Error ? err.message : String(err)); 72 + console.error(formatError(err, { verbose: false })); 72 73 process.exitCode = 1; 73 74 } 74 75 });
+71 -4
src/cmd/login.js
··· 11 11 import { createOAuthClient, createSessionStore, createStore, checkSession } from '../lib/oauth.js'; 12 12 import { configDir, configPath } from '../lib/paths.js'; 13 13 import { vitDir } from '../lib/vit-dir.js'; 14 + import { errorMessage, formatError } from '../lib/error-format.js'; 15 + 16 + export const LOGIN_COMMON_ISSUES_FOOTER = `Common issues: 17 + - make sure the handle is correct and resolves on Bluesky 18 + - open the printed URL in your browser, approve vit, and wait for the callback to finish 19 + - if you're on a remote machine, rerun with 'vit login <handle> --remote' and paste the full callback URL 20 + - if you used an app password, create a fresh Bluesky app password and try again 21 + - check your DNS, firewall, or VPN settings if vit cannot reach Bluesky`; 14 22 15 23 function ensureGitignore(dir, entry) { 16 24 const gitignorePath = join(dir, '.gitignore'); ··· 21 29 } 22 30 } 23 31 32 + export function cancelLogin({ 33 + server, 34 + rl, 35 + timer, 36 + clearTimer = clearTimeout, 37 + stderr = console.error, 38 + exit = process.exit, 39 + footer = LOGIN_COMMON_ISSUES_FOOTER, 40 + }) { 41 + try { 42 + if (server?.listening) server.close(); 43 + } catch { 44 + // Ignore cleanup failures during cancellation. 45 + } 46 + try { 47 + rl?.close(); 48 + } catch { 49 + // Ignore cleanup failures during cancellation. 50 + } 51 + try { 52 + if (timer) clearTimer(timer); 53 + } catch { 54 + // Ignore cleanup failures during cancellation. 55 + } 56 + stderr('\nLogin cancelled.'); 57 + stderr(footer); 58 + exit(130); 59 + } 60 + 61 + export function printLoginFailure(err, { verbose = false, includeFooter = false } = {}) { 62 + console.error(formatError(err, { verbose })); 63 + if (includeFooter) { 64 + console.error(LOGIN_COMMON_ISSUES_FOOTER); 65 + } 66 + } 67 + 24 68 export default function register(program) { 25 69 program 26 70 .command('login') ··· 60 104 return; 61 105 } 62 106 } 63 - } catch {} 107 + } catch (err) { 108 + console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`); 109 + } 64 110 } 65 111 } else { 66 112 const existing = loadConfig(); ··· 91 137 } else { 92 138 const sessionFile = configPath('session.json'); 93 139 let data = {}; 94 - try { data = JSON.parse(readFileSync(sessionFile, 'utf-8')); } catch {} 140 + if (existsSync(sessionFile)) { 141 + try { 142 + data = JSON.parse(readFileSync(sessionFile, 'utf-8')); 143 + } catch (err) { 144 + console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`); 145 + } 146 + } 95 147 data[did] = { type: 'app-password', service: 'https://bsky.social', session }; 96 148 mkdirSync(configDir, { recursive: true }); 97 149 writeFileSync(sessionFile, JSON.stringify(data, null, 2) + '\n'); ··· 102 154 103 155 console.log(`Logged in as ${did}`); 104 156 } catch (err) { 105 - console.error(err instanceof Error ? err.message : String(err)); 157 + printLoginFailure(err, { verbose: opts.verbose, includeFooter: true }); 106 158 process.exitCode = 1; 107 159 } 108 160 return; ··· 111 163 let server; 112 164 let timeout; 113 165 let rl; 166 + let loginStage = 'preflight'; 167 + let onSigint = () => {}; 114 168 115 169 try { 116 170 let resolveCallback; ··· 138 192 res.writeHead(404); 139 193 res.end('Not found'); 140 194 }); 195 + 196 + onSigint = () => { 197 + process.off('SIGINT', onSigint); 198 + cancelLogin({ server, rl, timer: timeout }); 199 + }; 200 + process.once('SIGINT', onSigint); 141 201 142 202 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); 143 203 const port = server.address().port; ··· 156 216 const sessionStore = createSessionStore(); 157 217 const client = createOAuthClient({ stateStore, sessionStore, redirectUri }); 158 218 219 + loginStage = 'authorize'; 159 220 const authUrl = await client.authorize(handle, { 160 221 scope: 'atproto transition:generic', 161 222 }); ··· 182 243 console.log(`Open this URL in your browser:\n ${authUrl.toString()}\n`); 183 244 } 184 245 246 + loginStage = 'callback'; 185 247 if (isRemote) { 186 248 rl = createInterface({ input: process.stdin, output: process.stdout }); 187 249 rl.question('Paste the callback URL from your browser: ', (line) => { ··· 229 291 } 230 292 231 293 console.log('Exchanging token...'); 294 + loginStage = 'token'; 232 295 const { session } = await client.callback(params); 233 296 234 297 if (verbose) { ··· 247 310 } 248 311 console.log(`Logged in as ${session.did}`); 249 312 } catch (err) { 250 - console.error(err instanceof Error ? err.message : String(err)); 313 + printLoginFailure(err, { 314 + verbose: opts.verbose, 315 + includeFooter: loginStage !== 'preflight', 316 + }); 251 317 process.exitCode = 1; 252 318 } finally { 319 + process.removeListener('SIGINT', onSigint); 253 320 if (timeout) { 254 321 clearTimeout(timeout); 255 322 }
+3 -3
src/cmd/remix.js
··· 12 12 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 13 import { loadConfig } from '../lib/config.js'; 14 14 import { jsonOk, jsonError } from '../lib/json-output.js'; 15 + import { formatError } from '../lib/error-format.js'; 15 16 16 17 export default function register(program) { 17 18 program ··· 173 174 console.log(''); 174 175 console.log(text); 175 176 } catch (err) { 176 - const msg = err instanceof Error ? err.message : String(err); 177 177 if (opts.json) { 178 - jsonError(msg); 178 + jsonError(err); 179 179 return; 180 180 } 181 - console.error(msg); 181 + console.error(formatError(err, { verbose: opts.verbose })); 182 182 process.exitCode = 1; 183 183 } 184 184 });
+9 -4
src/cmd/scan.js
··· 7 7 import { brand } from '../lib/brand.js'; 8 8 import { jsonOk, jsonError } from '../lib/json-output.js'; 9 9 import { readBeaconSet } from '../lib/vit-dir.js'; 10 + import { formatError } from '../lib/error-format.js'; 10 11 11 12 export default function register(program) { 12 13 program ··· 91 92 92 93 ws.onmessage = (event) => { 93 94 let msg; 94 - try { msg = JSON.parse(event.data); } catch { return; } 95 + try { 96 + msg = JSON.parse(event.data); 97 + } catch { 98 + console.warn('warning: failed to parse Jetstream event as JSON; skipping'); 99 + return; 100 + } 95 101 96 102 if (msg.kind !== 'commit' || msg.commit?.operation !== 'create') return; 97 103 ··· 197 203 console.log(` ${parts.join(' ')}`); 198 204 } 199 205 } catch (err) { 200 - const msg = err instanceof Error ? err.message : String(err); 201 206 if (opts.json) { 202 - jsonError(msg); 207 + jsonError(err); 203 208 return; 204 209 } 205 - console.error(msg); 210 + console.error(formatError(err, { verbose: opts.verbose })); 206 211 process.exitCode = 1; 207 212 } 208 213 });
+3 -3
src/cmd/ship.js
··· 16 16 import { jsonOk, jsonError } from '../lib/json-output.js'; 17 17 import { toBeacon } from '../lib/beacon.js'; 18 18 import { hashTo3Words } from '../lib/cap-ref.js'; 19 + import { formatError } from '../lib/error-format.js'; 19 20 20 21 const STOP_WORDS = new Set([ 21 22 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', ··· 664 665 await shipCap(opts); 665 666 } 666 667 } catch (err) { 667 - const msg = err instanceof Error ? err.message : String(err); 668 668 if (opts.json) { 669 - jsonError(msg); 669 + jsonError(err); 670 670 return; 671 671 } 672 - console.error(msg); 672 + console.error(formatError(err, { verbose: opts.verbose })); 673 673 process.exitCode = 1; 674 674 } 675 675 })
+7 -1
src/cmd/skim.js
··· 10 10 import { skillRefFromName } from '../lib/skill-ref.js'; 11 11 import { name } from '../lib/brand.js'; 12 12 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 + import { jsonError } from '../lib/json-output.js'; 14 + import { formatError } from '../lib/error-format.js'; 13 15 14 16 export default function register(program) { 15 17 program ··· 208 210 } 209 211 } 210 212 } catch (err) { 211 - console.error(err instanceof Error ? err.message : String(err)); 213 + if (opts.json) { 214 + jsonError(err); 215 + return; 216 + } 217 + console.error(formatError(err, { verbose: opts.verbose })); 212 218 process.exitCode = 1; 213 219 } 214 220 });
+3 -3
src/cmd/vet.js
··· 17 17 import { loadConfig } from '../lib/config.js'; 18 18 import { jsonOk, jsonError } from '../lib/json-output.js'; 19 19 import { sandboxArgs } from '../lib/sandbox.js'; 20 + import { formatError } from '../lib/error-format.js'; 20 21 21 22 const execFileAsync = promisify(execFile); 22 23 ··· 629 630 console.log(` vit vet ${ref} --trust`); 630 631 } 631 632 } catch (err) { 632 - const msg = err instanceof Error ? err.message : String(err); 633 633 if (opts.json) { 634 - jsonError(msg); 634 + jsonError(err); 635 635 return; 636 636 } 637 - console.error(msg); 637 + console.error(formatError(err, { verbose: opts.verbose })); 638 638 process.exitCode = 1; 639 639 } 640 640 });
+3 -3
src/cmd/vouch.js
··· 12 12 import { resolvePds, listRecordsFromPds, batchQuery } from '../lib/pds.js'; 13 13 import { loadConfig } from '../lib/config.js'; 14 14 import { jsonOk, jsonError } from '../lib/json-output.js'; 15 + import { formatError } from '../lib/error-format.js'; 15 16 16 17 export default function register(program) { 17 18 program ··· 302 303 } 303 304 } 304 305 } catch (err) { 305 - const msg = err instanceof Error ? err.message : String(err); 306 306 if (opts.json) { 307 - jsonError(msg); 307 + jsonError(err); 308 308 return; 309 309 } 310 - console.error(msg); 310 + console.error(formatError(err, { verbose: opts.verbose })); 311 311 process.exitCode = 1; 312 312 } 313 313 });
+7 -3
src/lib/config.js
··· 4 4 import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; 5 5 import { join } from 'node:path'; 6 6 import { configDir, configPath } from './paths.js'; 7 + import { errorMessage } from './error-format.js'; 7 8 8 9 const vitJsonPath = configPath('vit.json'); 9 10 ··· 11 12 if (!existsSync(vitJsonPath)) return {}; 12 13 try { 13 14 return JSON.parse(readFileSync(vitJsonPath, 'utf-8')); 14 - } catch { 15 + } catch (err) { 16 + console.warn(`warning: failed to read ${vitJsonPath}: ${errorMessage(err)}`); 15 17 return {}; 16 18 } 17 19 } ··· 26 28 27 29 export function requireDid(opts) { 28 30 if (opts?.did) return opts.did; 31 + const localLogin = join(process.cwd(), '.vit', 'login.json'); 29 32 try { 30 - const localLogin = join(process.cwd(), '.vit', 'login.json'); 31 33 if (existsSync(localLogin)) { 32 34 const local = JSON.parse(readFileSync(localLogin, 'utf-8')); 33 35 if (local.did) return local.did; 34 36 } 35 - } catch {} 37 + } catch (err) { 38 + console.warn(`warning: failed to read ${localLogin}: ${errorMessage(err)}`); 39 + } 36 40 const did = loadConfig().did; 37 41 if (!did) { 38 42 console.error("no DID configured. run 'vit login <handle>' first or pass --did.");
+78
src/lib/error-format.js
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + const MAX_CAUSES = 10; 5 + 6 + function isObjectLike(value) { 7 + return (typeof value === 'object' && value !== null) || typeof value === 'function'; 8 + } 9 + 10 + export function errorMessage(value) { 11 + if (value instanceof Error) return value.message || String(value); 12 + if (isObjectLike(value) && typeof value.message === 'string') return value.message; 13 + return String(value); 14 + } 15 + 16 + function stackLinesOf(value) { 17 + if (!(value instanceof Error) || typeof value.stack !== 'string' || value.stack === '') return []; 18 + return value.stack.split('\n').map(line => ` ${line}`); 19 + } 20 + 21 + function walkChain(err) { 22 + if (!(err instanceof Error)) { 23 + return [{ value: err, message: String(err), stackLines: [] }]; 24 + } 25 + 26 + const levels = []; 27 + const seen = new Set(); 28 + let current = err; 29 + let causeCount = 0; 30 + 31 + while (current !== undefined) { 32 + if (isObjectLike(current)) { 33 + if (seen.has(current)) break; 34 + seen.add(current); 35 + } 36 + 37 + levels.push({ 38 + value: current, 39 + message: errorMessage(current), 40 + stackLines: stackLinesOf(current), 41 + }); 42 + 43 + if (!isObjectLike(current) || !('cause' in current)) break; 44 + 45 + const next = current.cause; 46 + if (next === undefined) break; 47 + if (causeCount >= MAX_CAUSES) break; 48 + 49 + current = next; 50 + causeCount += 1; 51 + } 52 + 53 + return levels; 54 + } 55 + 56 + export function collectCauses(err) { 57 + if (!(err instanceof Error)) return []; 58 + return walkChain(err).slice(1).map(level => level.message); 59 + } 60 + 61 + export function formatError(err, { hint, verbose = false } = {}) { 62 + const levels = walkChain(err); 63 + const lines = []; 64 + 65 + for (let i = 0; i < levels.length; i += 1) { 66 + const level = levels[i]; 67 + lines.push(i === 0 ? level.message : ` caused by: ${level.message}`); 68 + if (verbose && level.stackLines.length > 0) { 69 + lines.push(...level.stackLines); 70 + } 71 + } 72 + 73 + if (typeof hint === 'string' && hint) { 74 + lines.push(`hint: ${hint}`); 75 + } 76 + 77 + return lines.join('\n'); 78 + }
+28 -3
src/lib/json-output.js
··· 1 1 // SPDX-License-Identifier: MIT 2 2 // Copyright (c) 2026 sol pbc 3 3 4 + import { collectCauses } from './error-format.js'; 5 + 6 + function errorMessage(input) { 7 + if (input instanceof Error) return input.message || String(input); 8 + return String(input); 9 + } 10 + 4 11 export function jsonOk(data) { 5 12 console.log(JSON.stringify({ ok: true, ...data }, null, 2)); 6 13 } 7 14 8 - export function jsonError(error, hint) { 9 - const obj = { ok: false, error }; 10 - if (hint) obj.hint = hint; 15 + export function jsonError(input, hintArg) { 16 + if (typeof input === 'string') { 17 + const obj = { ok: false, error: input }; 18 + if (hintArg) obj.hint = hintArg; 19 + console.log(JSON.stringify(obj, null, 2)); 20 + process.exitCode = 1; 21 + return; 22 + } 23 + 24 + const obj = { 25 + ok: false, 26 + error: errorMessage(input), 27 + }; 28 + const causes = collectCauses(input); 29 + if (causes.length > 0) obj.causes = causes; 30 + 31 + const resolvedHint = typeof hintArg === 'string' 32 + ? hintArg 33 + : (typeof input?.hint === 'string' ? input.hint : undefined); 34 + if (resolvedHint) obj.hint = resolvedHint; 35 + 11 36 console.log(JSON.stringify(obj, null, 2)); 12 37 process.exitCode = 1; 13 38 }
+34 -17
src/lib/oauth.js
··· 6 6 import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 7 7 import { join } from 'node:path'; 8 8 import { configDir, configPath } from './paths.js'; 9 + import { errorMessage } from './error-format.js'; 9 10 10 11 const requestLock = async (_name, fn) => await fn(); 11 12 ··· 44 45 export function createSessionStore() { 45 46 const sessionFile = configPath('session.json'); 46 47 let data = {}; 47 - try { 48 - data = JSON.parse(readFileSync(sessionFile, 'utf-8')); 49 - } catch {} 48 + if (existsSync(sessionFile)) { 49 + try { 50 + data = JSON.parse(readFileSync(sessionFile, 'utf-8')); 51 + } catch (err) { 52 + console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`); 53 + } 54 + } 50 55 return { 51 56 set: async (key, value) => { 52 57 data[key] = value; ··· 64 69 65 70 export function checkSession(did) { 66 71 // Check project-local app-password session 72 + const localPath = join(process.cwd(), '.vit', 'login.json'); 67 73 try { 68 - const localPath = join(process.cwd(), '.vit', 'login.json'); 69 74 if (existsSync(localPath)) { 70 75 const local = JSON.parse(readFileSync(localPath, 'utf-8')); 71 76 if (local.did === did && local.type === 'app-password' && local.session?.accessJwt) { 72 77 return did; 73 78 } 74 79 } 75 - } catch {} 80 + } catch (err) { 81 + console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`); 82 + } 76 83 84 + const sessionFile = configPath('session.json'); 85 + if (!existsSync(sessionFile)) return null; 77 86 try { 78 - const raw = readFileSync(configPath('session.json'), 'utf-8'); 87 + const raw = readFileSync(sessionFile, 'utf-8'); 79 88 const data = JSON.parse(raw); 80 89 const entry = data[did]; 81 90 if (!entry) return null; ··· 89 98 const accessValid = tokenSet.expires_at && new Date(tokenSet.expires_at) > new Date(); 90 99 if (accessValid || tokenSet.refresh_token) return did; 91 100 return null; 92 - } catch { 101 + } catch (err) { 102 + console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`); 93 103 return null; 94 104 } 95 105 } ··· 108 118 109 119 export async function restoreAgent(did) { 110 120 // Check project-local app-password session 121 + const localPath = join(process.cwd(), '.vit', 'login.json'); 111 122 try { 112 - const localPath = join(process.cwd(), '.vit', 'login.json'); 113 123 if (existsSync(localPath)) { 114 124 const local = JSON.parse(readFileSync(localPath, 'utf-8')); 115 125 if (local.did === did && local.type === 'app-password' && local.session) { ··· 118 128 return { agent, session: { did: local.did, handle: local.handle } }; 119 129 } 120 130 } 121 - } catch {} 131 + } catch (err) { 132 + console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`); 133 + } 122 134 123 135 // Check global app-password session 136 + const sessionFile = configPath('session.json'); 124 137 try { 125 - const raw = readFileSync(configPath('session.json'), 'utf-8'); 126 - const data = JSON.parse(raw); 127 - const entry = data[did]; 128 - if (entry?.type === 'app-password' && entry.session) { 129 - const agent = new AtpAgent({ service: entry.service || 'https://bsky.social' }); 130 - await agent.resumeSession(entry.session); 131 - return { agent, session: { did, handle: entry.session.handle } }; 138 + if (existsSync(sessionFile)) { 139 + const raw = readFileSync(sessionFile, 'utf-8'); 140 + const data = JSON.parse(raw); 141 + const entry = data[did]; 142 + if (entry?.type === 'app-password' && entry.session) { 143 + const agent = new AtpAgent({ service: entry.service || 'https://bsky.social' }); 144 + await agent.resumeSession(entry.session); 145 + return { agent, session: { did, handle: entry.session.handle } }; 146 + } 132 147 } 133 - } catch {} 148 + } catch (err) { 149 + console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`); 150 + } 134 151 135 152 // Existing OAuth restore path 136 153 const sessionStore = createSessionStore();
+31 -10
src/lib/pds.js
··· 1 1 // SPDX-License-Identifier: MIT 2 2 // Copyright (c) 2026 sol pbc 3 3 4 + import { errorMessage } from './error-format.js'; 5 + 4 6 const PLC_DIRECTORY = 'https://plc.directory'; 5 7 const pdsCache = new Map(); 6 8 9 + function requestErrorMessage(method, url, err) { 10 + const code = err?.cause?.code || err?.code; 11 + if (code === 'ECONNREFUSED') return `could not connect to ${url} (refused)`; 12 + if (code === 'ENOTFOUND') return `could not resolve ${url}`; 13 + if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') { 14 + return `timed out connecting to ${url}`; 15 + } 16 + return `request to ${url} failed: ${errorMessage(err)}`; 17 + } 18 + 19 + async function fetchJson(method, url) { 20 + const requestUrl = url.toString(); 21 + try { 22 + const res = await fetch(url); 23 + if (!res.ok) throw new Error(`${method} ${requestUrl} returned ${res.status}`); 24 + return await res.json(); 25 + } catch (err) { 26 + if (err instanceof Error && err.message.startsWith(`${method} ${requestUrl} returned `)) { 27 + throw err; 28 + } 29 + throw new Error(requestErrorMessage(method, requestUrl, err), { cause: err }); 30 + } 31 + } 32 + 7 33 async function fetchDidDocument(did) { 8 34 let url; 9 35 if (did.startsWith('did:web:')) { ··· 19 45 url = `${PLC_DIRECTORY}/${did}`; 20 46 } 21 47 22 - const res = await fetch(url); 23 - if (!res.ok) throw new Error(`failed to resolve DID document for ${did}: ${res.status} ${res.statusText}`); 24 - return res.json(); 48 + return fetchJson('GET', url); 25 49 } 26 50 27 51 export async function resolvePds(did) { ··· 42 66 url.searchParams.set('collection', collection); 43 67 if (limit) url.searchParams.set('limit', String(limit)); 44 68 if (cursor) url.searchParams.set('cursor', cursor); 45 - const res = await fetch(url); 46 - if (!res.ok) throw new Error(`listRecords failed for ${repo}: ${res.status} ${res.statusText}`); 47 - const data = await res.json(); 69 + const data = await fetchJson('GET', url); 48 70 records.push(...data.records); 49 71 cursor = data.cursor; 50 72 } while (cursor); ··· 56 78 const doc = await fetchDidDocument(did); 57 79 const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://')); 58 80 return aka ? aka.replace('at://', '') : did; 59 - } catch { 81 + } catch (err) { 82 + console.warn(`warning: failed to resolve handle for ${did}: ${errorMessage(err)}`); 60 83 return did; 61 84 } 62 85 } 63 86 64 87 export async function resolveHandle(handle) { 65 88 const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 66 - const res = await fetch(url); 67 - if (!res.ok) throw new Error(`could not resolve handle: ${handle}`); 68 - const data = await res.json(); 89 + const data = await fetchJson('GET', url); 69 90 return data.did; 70 91 } 71 92
+15 -4
src/lib/vit-dir.js
··· 3 3 4 4 import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; 5 5 import { join } from 'node:path'; 6 + import { errorMessage } from './error-format.js'; 6 7 7 8 export function vitDir(dir) { 8 9 return join(dir || process.cwd(), '.vit'); ··· 13 14 if (!existsSync(p)) return {}; 14 15 try { 15 16 return JSON.parse(readFileSync(p, 'utf-8')); 16 - } catch { 17 + } catch (err) { 18 + console.warn(`warning: failed to read ${p}: ${errorMessage(err)}`); 17 19 return {}; 18 20 } 19 21 } ··· 45 47 return readFileSync(p, 'utf-8') 46 48 .split('\n') 47 49 .filter(line => line.trim()) 48 - .map(line => { try { return JSON.parse(line); } catch { return null; } }) 50 + .map((line, index) => { 51 + try { 52 + return JSON.parse(line); 53 + } catch (err) { 54 + console.warn(`warning: skipping malformed line ${index + 1} in ${p}: ${errorMessage(err)}`); 55 + return null; 56 + } 57 + }) 49 58 .filter(Boolean); 50 - } catch { 59 + } catch (err) { 60 + console.warn(`warning: failed to read ${p}: ${errorMessage(err)}`); 51 61 return []; 52 62 } 53 63 } ··· 57 67 if (!existsSync(p)) return []; 58 68 try { 59 69 return JSON.parse(readFileSync(p, 'utf-8')); 60 - } catch { 70 + } catch (err) { 71 + console.warn(`warning: failed to read ${p}: ${errorMessage(err)}`); 61 72 return []; 62 73 } 63 74 }
+89
test/error-format.test.js
··· 1 + // SPDX-License-Identifier: MIT 2 + // Copyright (c) 2026 sol pbc 3 + 4 + import { describe, test, expect } from 'bun:test'; 5 + import { collectCauses, formatError } from '../src/lib/error-format.js'; 6 + 7 + describe('error-format', () => { 8 + test('formats a flat Error', () => { 9 + const err = new Error('top level'); 10 + 11 + expect(collectCauses(err)).toEqual([]); 12 + expect(formatError(err)).toBe('top level'); 13 + }); 14 + 15 + test('formats nested causes', () => { 16 + const root = new Error('top level'); 17 + const middle = new Error('middle'); 18 + const leaf = new Error('leaf'); 19 + root.cause = middle; 20 + middle.cause = leaf; 21 + 22 + expect(collectCauses(root)).toEqual(['middle', 'leaf']); 23 + expect(formatError(root)).toBe([ 24 + 'top level', 25 + ' caused by: middle', 26 + ' caused by: leaf', 27 + ].join('\n')); 28 + }); 29 + 30 + test('formats a non-Error throwable as a flat value', () => { 31 + expect(collectCauses('boom')).toEqual([]); 32 + expect(formatError('boom')).toBe('boom'); 33 + }); 34 + 35 + test('formats a non-Error cause', () => { 36 + const err = new Error('top level'); 37 + err.cause = 'socket closed'; 38 + 39 + expect(collectCauses(err)).toEqual(['socket closed']); 40 + expect(formatError(err)).toBe([ 41 + 'top level', 42 + ' caused by: socket closed', 43 + ].join('\n')); 44 + }); 45 + 46 + test('stops on circular causes', () => { 47 + const root = new Error('top level'); 48 + const middle = new Error('middle'); 49 + root.cause = middle; 50 + middle.cause = root; 51 + 52 + expect(collectCauses(root)).toEqual(['middle']); 53 + expect(formatError(root)).toBe([ 54 + 'top level', 55 + ' caused by: middle', 56 + ].join('\n')); 57 + }); 58 + 59 + test('caps the cause list at 10 levels', () => { 60 + const root = new Error('root'); 61 + let current = root; 62 + for (let i = 1; i <= 12; i += 1) { 63 + const next = new Error(`cause ${i}`); 64 + current.cause = next; 65 + current = next; 66 + } 67 + 68 + expect(collectCauses(root)).toHaveLength(10); 69 + expect(collectCauses(root)[0]).toBe('cause 1'); 70 + expect(collectCauses(root)[9]).toBe('cause 10'); 71 + }); 72 + 73 + test('includes indented stack traces in verbose mode', () => { 74 + const root = new Error('top level'); 75 + const leaf = new Error('leaf'); 76 + root.stack = 'Error: top level\nat top'; 77 + leaf.stack = 'Error: leaf\nat leaf'; 78 + root.cause = leaf; 79 + 80 + expect(formatError(root, { verbose: true })).toBe([ 81 + 'top level', 82 + ' Error: top level', 83 + ' at top', 84 + ' caused by: leaf', 85 + ' Error: leaf', 86 + ' at leaf', 87 + ].join('\n')); 88 + }); 89 + });
+4 -4
test/explore.test.js
··· 161 161 expect(result.exitCode).not.toBe(0); 162 162 const data = JSON.parse(result.stdout); 163 163 expect(data.ok).toBe(false); 164 - expect(data.error).toContain('unavailable'); 164 + expect(data.error).toContain('request to http://localhost:1/api/stats failed'); 165 165 }); 166 166 167 167 test('graceful error on invalid URL', () => { ··· 169 169 expect(result.exitCode).not.toBe(0); 170 170 const data = JSON.parse(result.stdout); 171 171 expect(data.ok).toBe(false); 172 - expect(data.error).toContain('unavailable'); 172 + expect(data.error).toContain('not-a-url'); 173 173 }); 174 174 175 175 test('vouches requires --cap or --ref', () => { ··· 184 184 expect(result.exitCode).not.toBe(0); 185 185 const data = JSON.parse(result.stdout); 186 186 expect(data.ok).toBe(false); 187 - expect(data.error).toContain('unavailable'); 187 + expect(data.error).toContain('request to http://localhost:1/api/stats failed'); 188 188 }); 189 189 190 190 test('flag overrides env var', () => { ··· 259 259 expect(result.exitCode).not.toBe(0); 260 260 const data = JSON.parse(result.stdout); 261 261 expect(data.ok).toBe(false); 262 - expect(data.error).toContain('unavailable'); 262 + expect(data.error).toContain('request to http://localhost:1/api/caps?kind=request failed'); 263 263 }); 264 264 265 265 test('bare explore returns stats JSON', () => {
+59 -2
test/json-output.test.js
··· 6 6 import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; 7 7 import { tmpdir } from 'node:os'; 8 8 import { join } from 'node:path'; 9 + import { jsonError } from '../src/lib/json-output.js'; 9 10 10 11 const agentEnv = { CLAUDECODE: '1' }; 11 12 ··· 13 14 return JSON.parse(stdout); 14 15 } 15 16 17 + function captureJsonError(input, hintArg) { 18 + const lines = []; 19 + const originalLog = console.log; 20 + const originalExitCode = process.exitCode; 21 + console.log = (line) => lines.push(line); 22 + process.exitCode = 0; 23 + 24 + try { 25 + jsonError(input, hintArg); 26 + return { 27 + exitCode: process.exitCode, 28 + parsed: JSON.parse(lines.join('')), 29 + }; 30 + } finally { 31 + console.log = originalLog; 32 + process.exitCode = originalExitCode; 33 + } 34 + } 35 + 16 36 describe('--json flag', () => { 17 37 let tmpDir; 38 + let tmpHome; 18 39 19 40 beforeEach(() => { 20 41 tmpDir = join(tmpdir(), '.test-json-' + Math.random().toString(36).slice(2)); ··· 23 44 24 45 afterEach(() => { 25 46 rmSync(tmpDir, { recursive: true, force: true }); 47 + if (tmpHome) rmSync(tmpHome, { recursive: true, force: true }); 26 48 }); 49 + 50 + function doctorEnv() { 51 + tmpHome = join(tmpdir(), '.test-json-doctor-' + Math.random().toString(36).slice(2)); 52 + mkdirSync(tmpHome, { recursive: true }); 53 + return { HOME: tmpHome, XDG_CONFIG_HOME: join(tmpHome, '.config') }; 54 + } 27 55 28 56 describe('init --json', () => { 29 57 test('reports status as JSON when not initialized', () => { ··· 59 87 60 88 describe('doctor --json', () => { 61 89 test('returns health report as JSON', () => { 62 - const r = run('doctor --json'); 90 + const r = run('doctor --json', undefined, doctorEnv()); 63 91 const j = parseJson(r.stdout); 64 92 expect(j.ok).toBe(true); 65 93 expect(j).toHaveProperty('install'); ··· 68 96 }); 69 97 70 98 test('status --json also works', () => { 71 - const r = run('status --json'); 99 + const r = run('status --json', undefined, doctorEnv()); 72 100 const j = parseJson(r.stdout); 73 101 expect(j.ok).toBe(true); 74 102 }); ··· 248 276 const j = parseJson(r.stdout); 249 277 expect(j.ok).toBe(false); 250 278 expect(j.error).toContain('--days must be a positive integer'); 279 + }); 280 + }); 281 + 282 + describe('jsonError throwable input', () => { 283 + test('includes a causes array for nested causes', () => { 284 + const root = new Error('top level'); 285 + const leaf = new Error('leaf'); 286 + root.cause = leaf; 287 + 288 + const { parsed, exitCode } = captureJsonError(root); 289 + expect(exitCode).toBe(1); 290 + expect(parsed).toEqual({ 291 + ok: false, 292 + error: 'top level', 293 + causes: ['leaf'], 294 + }); 295 + }); 296 + 297 + test('omits causes for flat errors', () => { 298 + class XRPCError extends Error {} 299 + const err = new XRPCError('Invalid identifier or password'); 300 + 301 + const { parsed, exitCode } = captureJsonError(err); 302 + expect(exitCode).toBe(1); 303 + expect(parsed).toEqual({ 304 + ok: false, 305 + error: 'Invalid identifier or password', 306 + }); 307 + expect(parsed).not.toHaveProperty('causes'); 251 308 }); 252 309 }); 253 310 });
+79 -1
test/login.test.js
··· 1 1 // SPDX-License-Identifier: MIT 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { describe, test, expect } from 'bun:test'; 4 + import { describe, test, expect, spyOn } from 'bun:test'; 5 5 import { mkdtempSync, rmSync } from 'node:fs'; 6 6 import { tmpdir } from 'node:os'; 7 7 import { join } from 'node:path'; 8 8 import { run } from './helpers.js'; 9 + import { cancelLogin, printLoginFailure, LOGIN_COMMON_ISSUES_FOOTER } from '../src/cmd/login.js'; 9 10 10 11 describe('login', () => { 11 12 test('--help shows --remote and --browser options', () => { ··· 42 43 expect(output).toContain('vit init'); 43 44 } finally { 44 45 rmSync(tmp, { recursive: true }); 46 + } 47 + }); 48 + 49 + test('cancelLogin closes resources, prints footer, and exits 130', () => { 50 + const lines = []; 51 + const server = { listening: true, close: () => lines.push('server.close') }; 52 + const rl = { close: () => lines.push('rl.close') }; 53 + const cleared = []; 54 + const exits = []; 55 + const timer = { id: 'timer' }; 56 + 57 + cancelLogin({ 58 + server, 59 + rl, 60 + timer, 61 + clearTimer: (value) => cleared.push(value), 62 + stderr: (line) => lines.push(line), 63 + exit: (code) => exits.push(code), 64 + }); 65 + 66 + expect(lines).toContain('server.close'); 67 + expect(lines).toContain('rl.close'); 68 + expect(lines).toContain('\nLogin cancelled.'); 69 + expect(lines).toContain(LOGIN_COMMON_ISSUES_FOOTER); 70 + expect(cleared).toEqual([timer]); 71 + expect(exits).toEqual([130]); 72 + }); 73 + 74 + test('cancelLogin skips server.close when the server is not listening', () => { 75 + let serverClosed = false; 76 + let rlClosed = false; 77 + 78 + cancelLogin({ 79 + server: { listening: false, close: () => { serverClosed = true; } }, 80 + rl: { close: () => { rlClosed = true; } }, 81 + timer: null, 82 + stderr: () => {}, 83 + exit: () => {}, 84 + }); 85 + 86 + expect(serverClosed).toBe(false); 87 + expect(rlClosed).toBe(true); 88 + }); 89 + 90 + test('printLoginFailure renders cause chains', () => { 91 + const root = new Error('Failed to resolve identity: bogus-handle.invalid'); 92 + const middle = new Error('Handle bogus-handle.invalid does not resolve to a DID'); 93 + const leaf = new Error('fetch failed'); 94 + root.cause = middle; 95 + middle.cause = leaf; 96 + 97 + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); 98 + try { 99 + printLoginFailure(root, { verbose: false, includeFooter: true }); 100 + const output = errorSpy.mock.calls.map(args => args.join(' ')).join('\n'); 101 + expect(output).toContain('Failed to resolve identity: bogus-handle.invalid'); 102 + expect(output).toContain('caused by: Handle bogus-handle.invalid does not resolve to a DID'); 103 + expect(output).toContain('caused by: fetch failed'); 104 + expect(output).toContain(LOGIN_COMMON_ISSUES_FOOTER); 105 + } finally { 106 + errorSpy.mockRestore(); 107 + } 108 + }); 109 + 110 + test('printLoginFailure includes the footer for timeout errors', () => { 111 + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); 112 + try { 113 + printLoginFailure(new Error('Timed out waiting for callback.'), { 114 + verbose: false, 115 + includeFooter: true, 116 + }); 117 + const output = errorSpy.mock.calls.map(args => args.join(' ')).join('\n'); 118 + expect(output).toContain('Timed out waiting for callback.'); 119 + expect(output).toContain('Common issues:'); 120 + expect(output).toContain("vit login <handle> --remote"); 121 + } finally { 122 + errorSpy.mockRestore(); 45 123 } 46 124 }); 47 125 });
+4 -2
test/pds.test.js
··· 143 143 test('throws on non-ok response', async () => { 144 144 global.fetch = async () => jsonResponse({}, { ok: false, status: 404, statusText: 'Not Found' }); 145 145 146 - await expect(resolveHandle('nonexistent.test')).rejects.toThrow('could not resolve handle: nonexistent.test'); 146 + await expect(resolveHandle('nonexistent.test')).rejects.toThrow( 147 + 'GET https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=nonexistent.test returned 404', 148 + ); 147 149 }); 148 150 }); 149 151 ··· 197 199 global.fetch = async () => jsonResponse({}, { ok: false, status: 500, statusText: 'Server Error' }); 198 200 199 201 await expect(listRecordsFromPds('https://pds.example.com', 'did:plc:three', 'app.test.record', 50)).rejects.toThrow( 200 - 'listRecords failed for did:plc:three: 500 Server Error', 202 + 'GET https://pds.example.com/xrpc/com.atproto.repo.listRecords?repo=did%3Aplc%3Athree&collection=app.test.record&limit=50 returned 500', 201 203 ); 202 204 }); 203 205 });