Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537import { test, before, after } from 'node:test';import * as assert from 'node:assert/strict';import * as FS from 'node:fs';import * as OS from 'node:os';import * as PATH from 'node:path';import { spawn } from 'node:child_process';import { startMock, type Mock } from './mock-pds.ts';
// The CLI end to end, in-process, against a mock PDS: signing in both ways,// several accounts at once, writing and reading back, refreshing, signing out.
const scratch = FS.mkdtempSync(PATH.join(OS.tmpdir(), 'atcli-e2e-'));let mock: Mock;let main: typeof import('../src/cli.ts').main;
before(async () => { mock = await startMock([{ handle: 'alice.test', password: 'alice-pw' }, { handle: 'bob.test', password: 'bob-pw' }]); // The only environment variable atcli reads: where its config is. The rest is settings. process.env['ATCLI_CONFIG_DIR'] = scratch; process.env['NO_COLOR'] = '1'; ({ main } = await import('../src/cli.ts')); for (const [key, value] of [['secret-store', 'file'], ['plc-directory', mock.url], ['appview', 'did:web:appview.test#bsky_appview'], ['public-appview', mock.url], ['browser', 'never'], ['oauth-client', 'loopback']]) { const out = await run('config', 'set', key!, value!); assert.equal(out.code, 0, out.stdout); }});
after(async () => { await mock.close(); FS.rmSync(scratch, { recursive: true, force: true });});
async function run(...argv: string[]): Promise<{ code: number; stdout: string; stderr: string; json: any }> { let stdout = ''; let stderr = ''; const code = await main(argv, { stdout: (text) => { stdout += text; }, stderr: (text) => { stderr += text; } }); let json: any; try { json = JSON.parse(stdout); } catch { json = undefined; } return { code, stdout, stderr, json };}
/** atcli as its own process, for what needs stdin: a password piped in. */function child(input: string, ...argv: string[]): Promise<{ code: number; stdout: string; json: any }> { return new Promise((resolve) => { const proc = spawn(process.execPath, ['--no-warnings', PATH.join(import.meta.dirname, '..', 'src', 'main.ts'), ...argv], { env: { ...process.env } }); let stdout = ''; proc.stdout.on('data', (chunk) => { stdout += chunk; }); proc.on('close', (code) => { let json: any; try { json = JSON.parse(stdout); } catch { json = undefined; } resolve({ code: code ?? 1, stdout, json }); }); proc.stdin.end(input); });}
const did = (handle: string) => mock.accounts.find((account) => account.handle === handle)!.did;
test('signed out, commands that need an account say so with exit 3', async () => { const out = await run('whoami', '--json'); assert.equal(out.code, 3); assert.equal(out.json.error.code, 'NotSignedIn');});
test('password login stores the session outside the config file', async () => { const out = await child('alice-pw\n', 'login', did('alice.test'), '--password-stdin', '--pds', mock.url, '--json'); assert.equal(out.code, 0, out.stdout); assert.equal(out.json.did, did('alice.test')); assert.equal(out.json.default, true); const config = FS.readFileSync(PATH.join(scratch, 'config.json'), 'utf-8'); assert.doesNotMatch(config, /Jwt|refresh|access/i, 'no tokens in the config file'); assert.match(FS.readFileSync(PATH.join(scratch, 'secrets.json'), 'utf-8'), new RegExp(did('alice.test')));});
test('a wrong password fails as an auth error', async () => { const out = await child('nope', 'login', did('bob.test'), '--password-stdin', '--pds', mock.url, '--json'); assert.equal(out.code, 3); assert.equal(out.json.error.status, 401);});
test('whoami checks the session with the PDS', async () => { const out = await run('whoami', '--json'); assert.equal(out.code, 0, out.stderr + out.stdout); assert.equal(out.json.handle, 'alice.test'); assert.equal(out.json.session, 'valid'); assert.equal(out.json.default, true);});
test('OAuth in two steps: --start, then --finish with the callback address', async () => { const started = await run('login', did('bob.test'), '--start', '--json'); assert.equal(started.code, 0, started.stderr + started.stdout); assert.match(started.json.url, /\/oauth\/authorize\?client_id=/); const par = (mock as any).lastPar as { requestUri: string; state: string; redirectUri: string }; assert.equal(par.state, started.json.state); const code = (mock as any).authorize(par.requestUri) as string; const callback = `http://127.0.0.1/callback?state=${encodeURIComponent(par.state)}&iss=${encodeURIComponent(mock.url)}&code=${encodeURIComponent(code)}`;
const finished = await run('login', '--finish', callback, '--json'); assert.equal(finished.code, 0, finished.stderr); assert.equal(finished.json.did, did('bob.test')); assert.equal(finished.json.method, 'oauth'); assert.equal(finished.json.default, false, 'the first account stays the default');
const again = await run('login', '--finish', callback, '--json'); assert.equal(again.code, 3, 'a finished sign-in cannot be finished twice');});
test('a hosted OAuth client is used when its document passes, and refused by name when not', async () => { const { checkHostedClient, DEFAULT_SCOPE } = await import('../src/oauth.ts'); assert.equal(await checkHostedClient(`${mock.url}/clients/good.json`, DEFAULT_SCOPE), undefined); assert.match(await checkHostedClient(`${mock.url}/clients/plain.json`, DEFAULT_SCOPE) ?? '', /served as text\/plain/); assert.match(await checkHostedClient(`${mock.url}/clients/elsewhere.json`, DEFAULT_SCOPE) ?? '', /calls itself/); assert.match(await checkHostedClient(`${mock.url}/clients/missing.json`, DEFAULT_SCOPE) ?? '', /not there/);
await run('config', 'set', 'oauth-client', `${mock.url}/clients/good.json`); const started = await run('login', did('bob.test'), '--start', '--json'); assert.equal(started.code, 0, started.stdout); assert.equal((mock as any).lastClientId, `${mock.url}/clients/good.json`);
await run('config', 'set', 'oauth-client', 'loopback');});
test('clients are tried in order: the first whose document passes, else loopback', async () => { const { chooseClient, DEFAULT_SCOPE } = await import('../src/oauth.ts'); const at = (name: string) => `${mock.url}/clients/${name}`; const chosen = await chooseClient(DEFAULT_SCOPE, [at('missing.json'), at('plain.json'), at('good.json'), at('elsewhere.json')]); assert.equal(chosen.clientId, at('good.json')); assert.equal(chosen.hosted, true); assert.deepEqual(chosen.skipped.map((each) => each.clientId), [at('missing.json'), at('plain.json')]); const fallback = await chooseClient(DEFAULT_SCOPE, [at('missing.json'), at('plain.json')]); assert.equal(fallback.hosted, false); assert.match(fallback.clientId, /^http:\/\/localhost\?/); assert.equal((await chooseClient(DEFAULT_SCOPE, [])).hosted, false);});
test('OAuth refuses a callback from another issuer', async () => { const started = await run('login', did('bob.test'), '--start', '--json'); const par = (mock as any).lastPar as { requestUri: string; state: string }; const code = (mock as any).authorize(par.requestUri) as string; const out = await run('login', '--finish', `http://127.0.0.1/callback?state=${par.state}&iss=https://evil.example&code=${code}`, '--json'); assert.equal(started.code, 0); assert.equal(out.code, 3); assert.equal(out.json.error.code, 'OAuthWrongIssuer');});
test('OAuth in one step: the loopback server takes the browser coming back', async () => { const { loginOAuth } = await import('../src/oauth.ts'); const signed = await loginOAuth(did('bob.test'), { open: (url) => { assert.match(url, /request_uri=/); // What the browser does once the person has signed in. const par = (mock as any).lastPar as { requestUri: string; state: string; redirectUri: string }; const code = (mock as any).authorize(par.requestUri) as string; void fetch(`${par.redirectUri}?state=${encodeURIComponent(par.state)}&iss=${encodeURIComponent(mock.url)}&code=${encodeURIComponent(code)}`); }, timeout: 5000, }); assert.equal(signed.identity.did, did('bob.test')); assert.equal(signed.secret.kind, 'oauth'); assert.ok(signed.secret.refreshToken);});
test('several accounts at once: list, --account, and switching the default', async () => { const list = await run('account', 'list', '--json'); assert.deepEqual(list.json.accounts.map((account: any) => account.handle).sort(), ['alice.test', 'bob.test']); assert.equal(list.json.default, did('alice.test'));
const asBob = await run('whoami', '--account', 'bob.test', '--json'); assert.equal(asBob.code, 0, asBob.stderr); assert.equal(asBob.json.did, did('bob.test')); assert.equal(asBob.json.method, 'oauth');
const switched = await run('account', 'default', 'bob.test', '--json'); assert.equal(switched.code, 0); assert.equal((await run('whoami', '--offline', '--json')).json.handle, 'bob.test'); await run('account', 'default', 'alice.test'); assert.equal((await run('whoami', '--offline', '--json')).json.handle, 'alice.test');
const unknown = await run('whoami', '--account', 'carol.test', '--json'); assert.equal(unknown.code, 3);});
test('a post carries facets for mentions, links and tags', async () => { const out = await run('post', 'Hi @bob.test — see https://example.com/page. #atproto', '--no-card', '--json'); assert.equal(out.code, 0, out.stderr + out.stdout); const record = mock.records.get(out.json.uri)!; assert.equal(record.value['$type'], 'app.bsky.feed.post'); const features = record.value['facets'].map((facet: any) => facet.features[0]); assert.deepEqual(features.map((feature: any) => feature.$type.split('#')[1]), ['mention', 'link', 'tag']); assert.equal(features[0].did, did('bob.test')); assert.equal(features[1].uri, 'https://example.com/page'); assert.equal(features[2].tag, 'atproto'); const text = Buffer.from(record.value['text']); const mention = record.value['facets'][0].index; assert.equal(text.subarray(mention.byteStart, mention.byteEnd).toString(), '@bob.test'); const link = record.value['facets'][1].index; assert.equal(text.subarray(link.byteStart, link.byteEnd).toString(), 'https://example.com/page');});
test('like and unlike a post, idempotently', async () => { const posted = await run('post', 'something to like', '--json'); const liked = await run('like', posted.json.uri, '--json'); assert.equal(liked.code, 0, liked.stderr); assert.equal(liked.json.changed, true); assert.equal(mock.records.get(liked.json.uri)?.value['subject'].uri, posted.json.uri); assert.equal((await run('like', posted.json.uri, '--json')).json.changed, false, 'liking twice changes nothing'); const unliked = await run('unlike', posted.json.uri, '--json'); assert.equal(unliked.json.changed, true); assert.equal(mock.records.has(liked.json.uri), false);});
test('a thread: each argument its own post, replying to the one before', async () => { const out = await run('post', '--thread', 'one', 'two', 'three', '--json'); assert.equal(out.code, 0, out.stderr + out.stdout); const [first, second, third] = out.json.thread.map((ref: any) => mock.records.get(ref.uri)!.value); assert.equal(first.reply, undefined); assert.equal(second.reply.parent.uri, out.json.thread[0].uri); assert.equal(third.reply.parent.uri, out.json.thread[1].uri); assert.equal(third.reply.root.uri, out.json.thread[0].uri);});
test('a reply gate and no quotes write a threadgate and a postgate', async () => { const out = await run('post', 'gated', '--reply-gate', 'following', '--reply-gate', 'mentioned', '--no-quotes', '--json'); assert.equal(out.code, 0, out.stderr + out.stdout); const rkey = out.json.uri.split('/').pop(); const threadgate = mock.records.get(`at://${did('alice.test')}/app.bsky.feed.threadgate/${rkey}`)!; assert.deepEqual(threadgate.value['allow'].map((rule: any) => rule.$type.split('#')[1]), ['followingRule', 'mentionRule']); assert.ok(mock.records.get(`at://${did('alice.test')}/app.bsky.feed.postgate/${rkey}`));});
test('text over 300 characters is refused, or split with --split', async () => { const long = Array.from({ length: 80 }, (_, i) => `word${i}.`).join(' '); const refused = await run('post', long, '--json'); assert.equal(refused.code, 2); const split = await run('post', long, '--split', '--dry-run', '--json'); assert.equal(split.code, 0, split.stderr); assert.ok(split.json.records.length >= 2); for (const record of split.json.records) assert.ok([...new Intl.Segmenter().segment(record.text)].length <= 300);});
test('follow is recorded once, and unfollow removes it', async () => { const followed = await run('follow', 'bob.test', '--json'); assert.equal(followed.code, 0, followed.stderr); assert.equal(mock.records.get(followed.json.uri)?.value['subject'], did('bob.test')); assert.equal((await run('follow', 'bob.test', '--json')).json.changed, false); assert.equal((await run('unfollow', 'bob.test', '--json')).json.changed, true);});
test('verify names the current handle and display name, replaces out-of-date ones, and unverify removes them', async () => { const profile = `at://${did('bob.test')}/app.bsky.actor.profile/self`; const name = (displayName: string) => mock.records.set(profile, { uri: profile, cid: 'bafyprofile', value: { $type: 'app.bsky.actor.profile', displayName } }); name('Bob'); const untrusted = await run('verify', 'bob.test'); assert.equal(untrusted.code, 0, untrusted.stderr); assert.match(untrusted.stdout, /not list @alice\.test as a trusted verifier/);
mock.trustedVerifiers.add(did('alice.test')); const again = await run('verify', 'bob.test', '--json'); assert.equal(again.json.trustedVerifier, 'valid'); assert.equal(again.json.verified[0].changed, false); const first = again.json.verified[0].uri; assert.deepEqual({ ...mock.records.get(first)?.value, createdAt: undefined }, { $type: 'app.bsky.graph.verification', subject: did('bob.test'), handle: 'bob.test', displayName: 'Bob', createdAt: undefined });
name('Robert'); const renamed = await run('verify', 'bob.test', '--json'); assert.equal(renamed.json.verified[0].changed, true); assert.deepEqual(renamed.json.verified[0].replaced, [first]); assert.equal(mock.records.has(first), false); assert.equal(mock.records.get(renamed.json.verified[0].uri)?.value['displayName'], 'Robert');
assert.equal((await run('verify', 'alice.test', '--json')).code, 2, 'not yourself'); assert.equal((await run('unverify', 'bob.test', '--json')).json.unverified[0].changed, true); assert.equal(mock.records.has(renamed.json.verified[0].uri), false); assert.equal((await run('unverify', 'bob.test', '--json')).json.unverified[0].changed, false); mock.records.delete(profile); mock.trustedVerifiers.clear();});
test('delete takes posts by URI, and refuses other people’s', async () => { const posted = await run('post', 'to delete', '--json'); const deleted = await run('delete', posted.json.uri, '--json'); assert.equal(deleted.code, 0, deleted.stderr); assert.equal(mock.records.has(posted.json.uri), false); const theirs = await run('delete', `at://${did('bob.test')}/app.bsky.feed.post/3abc`, '--json'); assert.equal(theirs.code, 2);});
test('an expired access token is refreshed, and the new one kept', async () => { mock.expireNextAccessToken = true; await child('alice-pw', 'login', did('alice.test'), '--password-stdin', '--pds', mock.url); const before = FS.readFileSync(PATH.join(scratch, 'secrets.json'), 'utf-8'); const out = await run('post', 'after refresh', '--json'); assert.equal(out.code, 0, out.stderr + out.stdout); assert.notEqual(FS.readFileSync(PATH.join(scratch, 'secrets.json'), 'utf-8'), before, 'the refreshed tokens are stored'); const spent = [...mock.refreshTokens.values()].filter((token) => token.spent).length; assert.ok(spent >= 1);});
test('parallel commands refreshing one session spend its refresh token once', async () => { mock.expireNextAccessToken = true; await child('alice-pw', 'login', did('alice.test'), '--password-stdin', '--pds', mock.url); const results = await Promise.all([run('whoami', '--json'), run('whoami', '--json'), run('whoami', '--json')]); for (const out of results) assert.equal(out.code, 0, out.stdout);});
test('unroll: from any post, the author’s whole thread, without others’ replies or numbering', async () => { const thread = await run('post', '--thread', '1/6 First, the start.', '2/6 Then the middle.', '3/6 More middle.', '4/6 Further.', '5/6 Nearly.', '6/6 The end.', '--json'); assert.equal(thread.code, 0, thread.stdout); const uris = thread.json.thread.map((ref: any) => ref.uri); // Someone else replies in the middle; that is not part of the thread. await run('reply', uris[2], 'an interjection', '--account', 'bob.test', '--json'); const out = await run('unroll', uris[3], '--json'); assert.equal(out.code, 0, out.stdout); assert.equal(out.json.count, 6); assert.deepEqual(out.json.posts.map((post: any) => post.uri), uris); assert.equal(out.json.text, 'First, the start.\n\nThen the middle.\n\nMore middle.\n\nFurther.\n\nNearly.\n\nThe end.'); const kept = await run('thread', uris[0], '--unroll', '--keep-numbering', '--json'); assert.match(kept.json.text, /^1\/6 First/); const markdown = await run('unroll', uris[5], '--markdown'); assert.match(markdown.stdout, /^# Thread by @alice\.test/); assert.match(markdown.stdout, /\n\nFirst, the start\.\n\nThen the middle\./); const html = await run('unroll', uris[5], '--format', 'html'); assert.match(html.stdout, /^<!doctype html>/); assert.match(html.stdout, /<title>Thread by @alice\.test<\/title>/); assert.match(html.stdout, /<p>The end\.<\/p>/);});
test('a saved format preference applies until a flag says otherwise; scripts stay raw', async () => { assert.equal((await run('config', 'set', 'format', 'markdown')).code, 0); const shown = await run('show', [...mock.records.keys()].find((uri) => uri.includes('app.bsky.feed.post'))!); assert.match(shown.stdout, /^# atcli show\n/); assert.ok((await run('show', [...mock.records.keys()].find((uri) => uri.includes('app.bsky.feed.post'))!, '--json')).json.posts); assert.match((await run('completion', 'bash')).stdout, /^# atcli completion for bash/); assert.match((await run('post', '--help')).stdout, /^# atcli post\n/); assert.equal((await run('config', 'set', 'format', 'yaml')).code, 2); await run('config', 'unset', 'format'); assert.doesNotMatch((await run('post', '--help')).stdout, /^#/);});
test('feeds and lists by name: your saved ones first, then yours, then a search', async () => { const { resolveFeed } = await import('../src/feedref.ts'); const { Client } = await import('../src/api.ts'); const alice = did('alice.test'); const bob = did('bob.test'); const feed = (owner: string, rkey: string, displayName: string, likeCount = 0) => ({ uri: `at://${owner}/app.bsky.feed.generator/${rkey}`, displayName, likeCount, creator: { did: owner, handle: owner === alice ? 'alice.test' : 'bob.test' } }); (mock as any).feeds = [ feed(bob, 'mutuals', 'Mutuals'), feed(bob, 'mutuals-plus', 'Mutuals+', 900), feed(alice, 'vienna-cats', 'Cats of Vienna'), feed(bob, 'whats-hot', 'Discover', 5000), feed(bob, 'discover-art', 'Discover Art', 10), ]; (mock as any).lists = [ { uri: `at://${alice}/app.bsky.graph.list/jstswien`, name: 'JS/TS Wien', purpose: 'app.bsky.graph.defs#curatelist', creator: { did: alice, handle: 'alice.test' } }, { uri: `at://${alice}/app.bsky.graph.list/rust`, name: 'Rust Wien', purpose: 'app.bsky.graph.defs#curatelist', creator: { did: alice, handle: 'alice.test' } }, ]; (mock as any).preferences = [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2', items: [ { id: '1', type: 'timeline', value: 'following', pinned: true }, { id: '2', type: 'feed', value: `at://${bob}/app.bsky.feed.generator/mutuals`, pinned: true }, ] }]; const client = new Client('alice.test'); const find = async (ref: string, options = {}) => (await resolveFeed(client, ref, { quiet: true, ...options })).uri;
// Saved: by name, by record key, in any case; "following" is the timeline. assert.equal(await find('mutuals'), `at://${bob}/app.bsky.feed.generator/mutuals`); assert.equal(await find('MUTUALS'), `at://${bob}/app.bsky.feed.generator/mutuals`); assert.equal(await find('following'), 'following'); // Yours: a list by its name, punctuation and case aside, and by part of it. assert.equal(await find('JS/TS Wien'), `at://${alice}/app.bsky.graph.list/jstswien`); assert.equal(await find('js ts wien'), `at://${alice}/app.bsky.graph.list/jstswien`); assert.equal(await find('jsts'), `at://${alice}/app.bsky.graph.list/jstswien`); assert.equal(await find('cats'), `at://${alice}/app.bsky.feed.generator/vienna-cats`); // Two of your own that fit equally: say so, rather than guess. await assert.rejects(find('wien'), /fits 2 of your feeds/); // Not yours: searched, and the most liked of the best matches. assert.equal(await find('discover'), `at://${bob}/app.bsky.feed.generator/whats-hot`); // Only lists, when only a list will do. await assert.rejects(find('discover', { kinds: ['list'] }), /no list called 'discover'/); // An address is taken as it is. assert.equal(await find(`bob.test/whats-hot`), `at://${bob}/app.bsky.feed.generator/whats-hot`);
const shown = await run('feed', 'show', 'Following', '--json'); assert.equal(shown.code, 0, shown.stdout); assert.equal(shown.json.source.kind, 'timeline'); const text = await run('feed', 'show', 'mut'); assert.match(text.stdout, /^Mutuals by @bob\.test\n/); const nothing = await run('feed', 'show', 'zzzz qqqq', '--json'); assert.equal(nothing.code, 4); assert.equal(nothing.json.error.code, 'FeedNotFound'); (mock as any).preferences = [];});
test('chat watch prints messages as they arrive, filters them, and resumes', async () => { const alice = did('alice.test'); const bob = did('bob.test'); const log = (mock as any).chatLog as Array<Record<string, unknown>>; let rev = 1000; const message = (from: string, text: string) => log.push({ $type: 'chat.bsky.convo.defs#logCreateMessage', rev: String(++rev), convoId: 'convo1', message: { $type: 'chat.bsky.convo.defs#messageView', id: `m${rev}`, rev: String(rev), text, sender: { did: from }, sentAt: new Date().toISOString() }, }); message(bob, 'from before the watch');
// Two messages arrive while it watches; one of them is our own, from another device. setTimeout(() => message(alice, 'mine, from my phone'), 150); setTimeout(() => message(bob, 'hello there'), 300); const watched = await run('chat', 'watch', '--incoming', '--count', '1', '--interval', '100ms', '--timeout', '5s', '--json', '--resume'); assert.equal(watched.code, 0, watched.stdout); const lines = watched.stdout.trim().split('\n').map((line) => JSON.parse(line)); assert.equal(lines.length, 1, 'only the incoming message, and the one before the watch is not new'); assert.equal(lines[0].kind, 'message'); assert.equal(lines[0].message.text, 'hello there');
// What arrived after it stopped is picked up by the next --resume. message(bob, 'while nobody watched'); const resumed = await run('chat', 'watch', '--resume', '--once', '--json'); assert.deepEqual(resumed.stdout.trim().split('\n').map((line) => JSON.parse(line).message.text), ['while nobody watched']); assert.equal((await run('chat', 'watch', '--resume', '--once', '--json')).stdout, '', 'and nothing twice');
const text = await run('chat', 'watch', '--cursor', '1000', '--once', '--messages'); assert.match(text.stdout, /@bob\.test: from before the watch/); assert.match(text.stdout, /you: mine, from my phone/); assert.equal((await run('chat', 'watch', '--markdown', '--once')).code, 2, 'a watch is text or JSON lines');});
test('notifications watch prints new notifications once each', async () => { const list = (mock as any).notifications as Array<Record<string, unknown>>; const notification = (reason: string, minutesAgo: number, n: number) => list.push({ uri: `at://${did('bob.test')}/app.bsky.feed.like/n${n}`, cid: 'bafy', reason, isRead: false, author: { did: did('bob.test'), handle: 'bob.test' }, record: {}, indexedAt: new Date(Date.now() - minutesAgo * 60_000).toISOString(), }); notification('like', 120, 1); notification('follow', 30, 2); setTimeout(() => notification('mention', 0, 3), 150); const watched = await run('notifications', 'watch', '--count', '1', '--interval', '100ms', '--timeout', '5s', '--json'); assert.equal(watched.code, 0, watched.stdout); assert.deepEqual(watched.stdout.trim().split('\n').map((line) => JSON.parse(line).reason), ['mention']);
const since = await run('notifications', 'watch', '--since', '1h', '--once', '--json'); assert.deepEqual(since.stdout.trim().split('\n').map((line) => JSON.parse(line).reason), ['follow', 'mention'], 'oldest first, from an hour back'); const mentions = await run('notifications', 'watch', '--since', '1d', '--reason', 'like', '--once', '--json'); assert.deepEqual(mentions.stdout.trim().split('\n').map((line) => JSON.parse(line).reason), ['like']);});
test('--new shows what was not shown before, and with -n reads the new ones from the oldest', async () => { const timeline = (mock as any).timeline as Array<Record<string, unknown>>; let n = 0; const post = () => { n++; timeline.push({ post: { uri: `at://${did('bob.test')}/app.bsky.feed.post/p${n}`, cid: 'bafy', author: { did: did('bob.test'), handle: 'bob.test' }, record: { text: `post ${n}`, createdAt: new Date(Date.UTC(2026, 9, 15, 9, n)).toISOString() }, indexedAt: new Date(Date.UTC(2026, 9, 15, 9, n)).toISOString(), } }); }; const texts = async (...args: string[]) => { const out = await run('timeline', '--new', '--json', ...args); assert.equal(out.code, 0, out.stdout); return out.json.feed.map((item: any) => item.post.record.text); }; await run('config', 'forget', '--new'); post(); post(); post(); assert.deepEqual(await texts(), ['post 3', 'post 2', 'post 1'], 'the first time: the newest, as usual'); assert.deepEqual(await texts(), [], 'then nothing that was shown'); post(); post(); post(); assert.deepEqual(await texts('-n', '1'), ['post 4'], 'with -n, the oldest of what is new'); assert.deepEqual(await texts('-n', '1'), ['post 5']); assert.deepEqual(await texts(), ['post 6']); // The same listing by another name shares what was seen. post(); const viaFeed = await run('feed', 'show', 'following', '--new', '--json'); assert.deepEqual(viaFeed.json.feed.map((item: any) => item.post.record.text), ['post 7']); // Without --new, everything as usual; and forgetting starts again. assert.equal((await run('timeline', '--json')).json.feed.length, 7); assert.equal((await run('config', 'forget', '--new', '--json')).json.forgotten, 1); assert.equal((await texts()).length, 7); timeline.length = 0;});
test('requests in flight together over OAuth all get the new DPoP nonce', async () => { const bob = did('bob.test'); (mock as any).feeds = [{ uri: `at://${bob}/app.bsky.feed.generator/news-2-0`, displayName: '📰 Trending News', likeCount: 1, creator: { did: bob, handle: 'bob.test' } }]; (mock as any).lists = []; (mock as any).preferences = [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2', items: [{ id: '1', type: 'feed', value: `at://${bob}/app.bsky.feed.generator/news-2-0`, pinned: true }] }]; // The server rotates its nonce: every request bob's session sends next is refused once. (mock as any).rotateNonce(); const { resolveFeed } = await import('../src/feedref.ts'); const { Client } = await import('../src/api.ts'); const found = await resolveFeed(new Client('bob.test'), 'news-2-0', { quiet: true }); assert.equal(found.uri, `at://${bob}/app.bsky.feed.generator/news-2-0`); assert.equal(found.via, 'saved', 'found among the saved feeds, not by a search after a lost request'); (mock as any).preferences = []; (mock as any).feeds = [];});
test('records: create, get and delete raw records', async () => { const created = await run('record', 'create', 'com.example.note', '{"text":"hi"}', '--json'); assert.equal(created.code, 0, created.stderr); const got = await run('record', 'get', created.json.uri, '--json'); assert.equal(got.json.value.text, 'hi'); assert.equal(got.json.value.$type, 'com.example.note'); assert.equal((await run('record', 'delete', created.json.uri, '--json')).code, 0);});
test('saved feeds round-trip through preferences', async () => { const feed = `at://${did('bob.test')}/app.bsky.feed.generator/cats`; const saved = await run('feed', 'pin', feed, '--json'); assert.equal(saved.code, 0, saved.stderr); const prefs = (mock as any).preferences as any[]; const items = prefs.find((pref) => pref.$type === 'app.bsky.actor.defs#savedFeedsPrefV2').items; assert.ok(items.some((item: any) => item.value === feed && item.pinned)); await run('feed', 'unsave', feed); assert.ok(!((mock as any).preferences as any[])[0].items.some((item: any) => item.value === feed));});
test('logout of one account leaves the other, and moves the default', async () => { const out = await run('logout', 'alice.test', '--json'); assert.equal(out.code, 0, out.stderr + out.stdout); assert.equal(out.json.default, 'bob.test'); const list = await run('account', '--json'); assert.deepEqual(list.json.accounts.map((account: any) => account.handle), ['bob.test']); const secrets = JSON.parse(FS.readFileSync(PATH.join(scratch, 'secrets.json'), 'utf-8')); assert.equal(secrets[did('alice.test')], undefined);});
test('usage errors exit 2, and say so in JSON', async () => { const out = await run('post', '--no-such-option', '--json'); assert.equal(out.code, 2); assert.equal(out.json.error.code, 'UsageError'); const missing = await run('reply', '--json'); assert.equal(missing.code, 2);});