open source is social v-it.org
0
fork

Configure Feed

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

Merge branch 'hopper-rc2ly7nn-local-login'

+206 -22
+32 -4
src/cmd/doctor.js
··· 10 10 import { mark, name } from '../lib/brand.js'; 11 11 import { which } from '../lib/compat.js'; 12 12 import { jsonOk, jsonError } from '../lib/json-output.js'; 13 + import { configPath } from '../lib/paths.js'; 13 14 14 15 function scanSkillDir(dir) { 15 16 const skills = []; ··· 119 120 } 120 121 } 121 122 122 - if (!config.did) { 123 + let effectiveDid = config.did; 124 + let identitySource = effectiveDid ? 'global' : null; 125 + let authType = 'oauth'; 126 + 127 + const localLoginPath = join(process.cwd(), '.vit', 'login.json'); 128 + try { 129 + if (existsSync(localLoginPath)) { 130 + const local = JSON.parse(readFileSync(localLoginPath, 'utf-8')); 131 + if (local.did) { 132 + effectiveDid = local.did; 133 + identitySource = 'local'; 134 + authType = local.type || 'oauth'; 135 + } 136 + } 137 + } catch {} 138 + 139 + if (!identitySource && effectiveDid) identitySource = 'global'; 140 + 141 + if (identitySource === 'global' && effectiveDid) { 142 + try { 143 + const raw = readFileSync(configPath('session.json'), 'utf-8'); 144 + const sessionData = JSON.parse(raw); 145 + if (sessionData[effectiveDid]?.type === 'app-password') authType = 'app-password'; 146 + } catch {} 147 + } 148 + 149 + if (!effectiveDid) { 123 150 if (!opts.json) console.log(`${mark} bluesky: not logged in (run ${name} login <handle>)`); 124 151 } else { 125 152 try { 126 - const { session } = await restoreAgent(config.did); 153 + const { session } = await restoreAgent(effectiveDid); 127 154 blueskyOk = true; 128 155 pds = session.serverMetadata?.issuer || null; 129 - if (!opts.json) console.log(`${mark} bluesky: ok (${session.did}${pds ? ', ' + pds : ''})`); 156 + if (!opts.json) console.log(`${mark} bluesky: ok (${session.did || effectiveDid}${pds ? ', ' + pds : ''})`); 130 157 } catch { 131 158 if (!opts.json) console.log(`${mark} bluesky: token expired or invalid (run ${name} login <handle>)`); 132 159 } 160 + if (!opts.json) console.log(`${mark} identity: ${identitySource} (${authType})`); 133 161 } 134 162 135 163 if (opts.json) { ··· 140 168 skill: skillInstalled, 141 169 projectSkills, 142 170 userSkills, 143 - bluesky: { ok: blueskyOk, did: config.did || null, pds }, 171 + bluesky: { ok: blueskyOk, did: effectiveDid || null, pds, source: identitySource, authType }, 144 172 }); 145 173 } 146 174 } catch (err) {
+94 -12
src/cmd/login.js
··· 4 4 import { createServer } from 'node:http'; 5 5 import { spawn } from 'node:child_process'; 6 6 import { createInterface } from 'node:readline'; 7 + import { AtpAgent } from '@atproto/api'; 8 + import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 9 + import { join } from 'node:path'; 7 10 import { loadConfig, saveConfig } from '../lib/config.js'; 8 11 import { createOAuthClient, createSessionStore, createStore, checkSession } from '../lib/oauth.js'; 12 + import { configDir, configPath } from '../lib/paths.js'; 13 + import { vitDir } from '../lib/vit-dir.js'; 14 + 15 + function ensureGitignore(dir, entry) { 16 + const gitignorePath = join(dir, '.gitignore'); 17 + let content = ''; 18 + try { content = readFileSync(gitignorePath, 'utf-8'); } catch {} 19 + if (!content.split('\n').includes(entry)) { 20 + writeFileSync(gitignorePath, content + (content.endsWith('\n') ? '' : '\n') + entry + '\n'); 21 + } 22 + } 9 23 10 24 export default function register(program) { 11 25 program 12 26 .command('login') 13 - .description('Log in to Bluesky via browser-based OAuth') 27 + .description('Log in to Bluesky') 14 28 .argument('<handle>', 'Bluesky handle (e.g. alice.bsky.social)') 15 29 .option('-v, --verbose', 'Show discovery details') 16 30 .option('--force', 'Force re-login, skip session validation') 17 31 .option('--remote', 'Skip browser launch; prompt to paste callback URL (auto-detected over SSH)') 18 32 .option('--browser <command>', 'Browser command to use (e.g. firefox)') 33 + .option('--app-password <password>', 'Authenticate with an app password (skips browser OAuth)') 34 + .option('--local', 'Store session in project .vit/ instead of global config') 19 35 .action(async (handle, opts) => { 20 - const { verbose, force, remote, browser } = opts; 36 + const { verbose, force, remote, browser, appPassword, local: localLogin } = opts; 21 37 const isRemote = remote || !!(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT); 22 38 handle = handle.replace(/^@/, ''); 23 39 40 + if (localLogin) { 41 + const dir = vitDir(); 42 + if (!existsSync(dir)) { 43 + console.error("no .vit directory found. run 'vit init' first."); 44 + process.exitCode = 1; 45 + return; 46 + } 47 + } 48 + 24 49 if (!force) { 25 - const existing = loadConfig(); 26 - if (existing.did) { 27 - console.log('Checking session...'); 28 - const validDid = checkSession(existing.did); 29 - if (validDid) { 30 - console.log(`Already logged in as ${validDid}`); 31 - return; 50 + if (localLogin) { 51 + const localPath = join(vitDir(), 'login.json'); 52 + if (existsSync(localPath)) { 53 + try { 54 + const local = JSON.parse(readFileSync(localPath, 'utf-8')); 55 + if (local.did) { 56 + console.log('Checking local session...'); 57 + const validDid = checkSession(local.did); 58 + if (validDid) { 59 + console.log(`Already logged in locally as ${validDid}`); 60 + return; 61 + } 62 + } 63 + } catch {} 64 + } 65 + } else { 66 + const existing = loadConfig(); 67 + if (existing.did) { 68 + console.log('Checking session...'); 69 + const validDid = checkSession(existing.did); 70 + if (validDid) { 71 + console.log(`Already logged in as ${validDid}`); 72 + return; 73 + } 32 74 } 33 75 } 76 + } 77 + 78 + if (appPassword) { 79 + try { 80 + const agent = new AtpAgent({ service: 'https://bsky.social' }); 81 + if (verbose) console.log('[verbose] Authenticating with app password...'); 82 + const res = await agent.login({ identifier: handle, password: appPassword }); 83 + const { did, handle: resolvedHandle, accessJwt, refreshJwt } = res.data; 84 + const session = { accessJwt, refreshJwt, handle: resolvedHandle, did, active: true }; 85 + 86 + if (localLogin) { 87 + const loginData = { did, handle: resolvedHandle, type: 'app-password', service: 'https://bsky.social', session }; 88 + const dir = vitDir(); 89 + writeFileSync(join(dir, 'login.json'), JSON.stringify(loginData, null, 2) + '\n'); 90 + ensureGitignore(dir, 'login.json'); 91 + } else { 92 + const sessionFile = configPath('session.json'); 93 + let data = {}; 94 + try { data = JSON.parse(readFileSync(sessionFile, 'utf-8')); } catch {} 95 + data[did] = { type: 'app-password', service: 'https://bsky.social', session }; 96 + mkdirSync(configDir, { recursive: true }); 97 + writeFileSync(sessionFile, JSON.stringify(data, null, 2) + '\n'); 98 + const config = loadConfig(); 99 + config.did = did; 100 + saveConfig(config); 101 + } 102 + 103 + console.log(`Logged in as ${did}`); 104 + } catch (err) { 105 + console.error(err instanceof Error ? err.message : String(err)); 106 + process.exitCode = 1; 107 + } 108 + return; 34 109 } 35 110 36 111 let server; ··· 160 235 console.log(`[verbose] Token exchange result for DID: ${session.did}`); 161 236 } 162 237 163 - const config = loadConfig(); 164 - config.did = session.did; 165 - saveConfig(config); 238 + if (localLogin) { 239 + const loginData = { did: session.did, handle, type: 'oauth' }; 240 + const dir = vitDir(); 241 + writeFileSync(join(dir, 'login.json'), JSON.stringify(loginData, null, 2) + '\n'); 242 + ensureGitignore(dir, 'login.json'); 243 + } else { 244 + const config = loadConfig(); 245 + config.did = session.did; 246 + saveConfig(config); 247 + } 166 248 console.log(`Logged in as ${session.did}`); 167 249 } catch (err) { 168 250 console.error(err instanceof Error ? err.message : String(err));
+10 -1
src/lib/config.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; 5 + import { join } from 'node:path'; 5 6 import { configDir, configPath } from './paths.js'; 6 7 7 8 const vitJsonPath = configPath('vit.json'); ··· 24 25 } 25 26 26 27 export function requireDid(opts) { 27 - const did = opts?.did || loadConfig().did; 28 + if (opts?.did) return opts.did; 29 + try { 30 + const localLogin = join(process.cwd(), '.vit', 'login.json'); 31 + if (existsSync(localLogin)) { 32 + const local = JSON.parse(readFileSync(localLogin, 'utf-8')); 33 + if (local.did) return local.did; 34 + } 35 + } catch {} 36 + const did = loadConfig().did; 28 37 if (!did) { 29 38 console.error("no DID configured. run 'vit login <handle>' first or pass --did."); 30 39 process.exitCode = 1;
+48 -5
src/lib/oauth.js
··· 1 1 // SPDX-License-Identifier: MIT 2 2 // Copyright (c) 2026 sol pbc 3 3 4 - import { Agent } from '@atproto/api'; 4 + import { Agent, AtpAgent } from '@atproto/api'; 5 5 import { NodeOAuthClient } from '@atproto/oauth-client-node'; 6 - import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 6 + import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 7 + import { join } from 'node:path'; 7 8 import { configDir, configPath } from './paths.js'; 8 9 9 10 const requestLock = async (_name, fn) => await fn(); ··· 62 63 } 63 64 64 65 export function checkSession(did) { 66 + // Check project-local app-password session 67 + try { 68 + const localPath = join(process.cwd(), '.vit', 'login.json'); 69 + if (existsSync(localPath)) { 70 + const local = JSON.parse(readFileSync(localPath, 'utf-8')); 71 + if (local.did === did && local.type === 'app-password' && local.session?.accessJwt) { 72 + return did; 73 + } 74 + } 75 + } catch {} 76 + 65 77 try { 66 78 const raw = readFileSync(configPath('session.json'), 'utf-8'); 67 79 const data = JSON.parse(raw); 68 - const tokenSet = data[did]?.tokenSet; 80 + const entry = data[did]; 81 + if (!entry) return null; 82 + // App-password session in global store 83 + if (entry.type === 'app-password') { 84 + return entry.session?.accessJwt ? did : null; 85 + } 86 + // OAuth session 87 + const tokenSet = entry?.tokenSet; 69 88 if (!tokenSet) return null; 70 - // Session is valid if access token is fresh OR a refresh token exists 71 - // (the library will auto-refresh expired access tokens on restore) 72 89 const accessValid = tokenSet.expires_at && new Date(tokenSet.expires_at) > new Date(); 73 90 if (accessValid || tokenSet.refresh_token) return did; 74 91 return null; ··· 90 107 } 91 108 92 109 export async function restoreAgent(did) { 110 + // Check project-local app-password session 111 + try { 112 + const localPath = join(process.cwd(), '.vit', 'login.json'); 113 + if (existsSync(localPath)) { 114 + const local = JSON.parse(readFileSync(localPath, 'utf-8')); 115 + if (local.did === did && local.type === 'app-password' && local.session) { 116 + const agent = new AtpAgent({ service: local.service || 'https://bsky.social' }); 117 + await agent.resumeSession(local.session); 118 + return { agent, session: { did: local.did, handle: local.handle } }; 119 + } 120 + } 121 + } catch {} 122 + 123 + // Check global app-password session 124 + 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 } }; 132 + } 133 + } catch {} 134 + 135 + // Existing OAuth restore path 93 136 const sessionStore = createSessionStore(); 94 137 const client = new NodeOAuthClient({ 95 138 handleResolver: { resolve() { throw new Error('handle resolution not needed for restore'); } },
+22
test/login.test.js
··· 2 2 // Copyright (c) 2026 sol pbc 3 3 4 4 import { describe, test, expect } from 'bun:test'; 5 + import { mkdtempSync, rmSync } from 'node:fs'; 6 + import { tmpdir } from 'node:os'; 7 + import { join } from 'node:path'; 5 8 import { run } from './helpers.js'; 6 9 7 10 describe('login', () => { ··· 18 21 expect(stdout).toContain('--force'); 19 22 }); 20 23 24 + test('--help shows --app-password and --local options', () => { 25 + const { stdout, exitCode } = run('login --help'); 26 + expect(exitCode).toBe(0); 27 + expect(stdout).toContain('--app-password'); 28 + expect(stdout).toContain('--local'); 29 + }); 30 + 21 31 test('requires handle argument', () => { 22 32 const result = run('login'); 23 33 expect(result.exitCode).not.toBe(0); 34 + }); 35 + 36 + test('--local without .vit/ directory fails', () => { 37 + const tmp = mkdtempSync(join(tmpdir(), 'vit-test-')); 38 + try { 39 + const result = run('login testhandle --local', tmp); 40 + expect(result.exitCode).not.toBe(0); 41 + const output = (result.stdout || '') + ' ' + (result.stderr || ''); 42 + expect(output).toContain('vit init'); 43 + } finally { 44 + rmSync(tmp, { recursive: true }); 45 + } 24 46 }); 25 47 });