@defdone/annon 0.1.0-alpha.2 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/CLI.md +58 -4
  3. package/README.md +6 -1
  4. package/dist/browser/login-bundle.js +51 -51
  5. package/dist/browser/login-dom.d.ts +1 -1
  6. package/dist/browser/login-dom.js +5 -1
  7. package/dist/browser/login-status.d.ts +1 -1
  8. package/dist/browser/login-status.js +1 -1
  9. package/dist/browser/login-steps.d.ts +6 -0
  10. package/dist/browser/login-steps.js +35 -0
  11. package/dist/browser/login.js +41 -4
  12. package/dist/node/adapter-checks.d.ts +3 -1
  13. package/dist/node/adapter-checks.js +3 -1
  14. package/dist/node/adapter.js +5 -1
  15. package/dist/node/agent.js +2 -1
  16. package/dist/node/browser-login.js +28 -3
  17. package/dist/node/buy-commands.js +9 -8
  18. package/dist/node/buy-flow.d.ts +18 -0
  19. package/dist/node/buy-flow.js +89 -0
  20. package/dist/node/cli-flags.d.ts +10 -0
  21. package/dist/node/cli-flags.js +68 -0
  22. package/dist/node/cli.d.ts +9 -1
  23. package/dist/node/cli.js +68 -92
  24. package/dist/node/config.d.ts +2 -0
  25. package/dist/node/config.js +3 -1
  26. package/dist/node/core-adapter.js +4 -0
  27. package/dist/node/login-page.d.ts +2 -2
  28. package/dist/node/login-page.js +7 -1
  29. package/dist/node/main.js +20 -2
  30. package/dist/node/output/discovery.d.ts +23 -0
  31. package/dist/node/output/discovery.js +52 -0
  32. package/dist/node/output/human.d.ts +10 -0
  33. package/dist/node/output/human.js +46 -0
  34. package/dist/node/output/purchase.d.ts +10 -0
  35. package/dist/node/output/purchase.js +78 -0
  36. package/dist/node/output/text.d.ts +10 -0
  37. package/dist/node/output/text.js +43 -0
  38. package/dist/node/purchase-commands.js +4 -0
  39. package/dist/node/purchase-ref.d.ts +15 -0
  40. package/dist/node/purchase-ref.js +93 -0
  41. package/dist/node/shell/complete.d.ts +2 -0
  42. package/dist/node/shell/complete.js +17 -0
  43. package/dist/node/shell/session.d.ts +20 -0
  44. package/dist/node/shell/session.js +219 -0
  45. package/dist/node/shell/welcome.d.ts +9 -0
  46. package/dist/node/shell/welcome.js +39 -0
  47. package/dist/node/shell/words.d.ts +1 -0
  48. package/dist/node/shell/words.js +36 -0
  49. package/dist/node/shops.d.ts +5 -0
  50. package/dist/node/shops.js +22 -0
  51. package/package.json +1 -1
@@ -0,0 +1,43 @@
1
+ /** Shared pieces of the human output: safe text, short IDs, aligned tables and field lists. */
2
+ /**
3
+ * Text from a shop, a catalog or a host is untrusted. Control characters (terminal escapes) and
4
+ * bidirectional overrides are replaced before it reaches a terminal; `lines` keeps newlines.
5
+ */
6
+ const BIDI = /[\u202a-\u202e\u2066-\u2069]/g;
7
+ export function cleanText(value, lines = false) {
8
+ const text = String(value).replace(BIDI, '');
9
+ return lines
10
+ ? // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are the target
11
+ text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, ' ')
12
+ : // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are the target
13
+ text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
14
+ }
15
+ /** `0x1234abcd...ef01` for a long ID or address; short values are returned whole. */
16
+ export const short = (id) => (id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-4)}` : id);
17
+ export const truncate = (text, max) => text.length > max ? `${text.slice(0, max - 3)}...` : text;
18
+ /** Left-aligned columns, two spaces apart; the last column is not padded. */
19
+ export function table(rows) {
20
+ const widths = [];
21
+ for (const row of rows)
22
+ row.forEach((cell, i) => {
23
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
24
+ });
25
+ return rows
26
+ .map((row) => row
27
+ .map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)))
28
+ .join(' ')
29
+ .trimEnd())
30
+ .join('\n');
31
+ }
32
+ /** Every field of a result as `name: value`, so a command without its own layout hides nothing. */
33
+ export function fields(result) {
34
+ return Object.entries(result)
35
+ .filter(([, value]) => value !== undefined)
36
+ .map(([key, value]) => `${key}: ${cleanText(value !== null && typeof value === 'object' ? JSON.stringify(value) : value)}`)
37
+ .join('\n');
38
+ }
39
+ export const networkName = (network) => network === 'near:mainnet'
40
+ ? 'NEAR mainnet'
41
+ : network === 'near:testnet'
42
+ ? 'NEAR testnet'
43
+ : network;
@@ -12,6 +12,7 @@ import { backupPath, fail, isId } from './adapter-checks.js';
12
12
  import { AdapterError, publicError, usage } from './errors.js';
13
13
  import { fileSource, newOutput, withOutput, withSpool } from './io.js';
14
14
  import { resourceHeader } from './offers.js';
15
+ import { rememberPurchase } from './purchase-ref.js';
15
16
  import { resourceAllowed } from './resources.js';
16
17
  import { retryGate } from './retry-gate.js';
17
18
  import { readBackupSecret } from './wallets.js';
@@ -94,6 +95,9 @@ async function recover(ctx) {
94
95
  wallet: await wallet(),
95
96
  ...(o.purchase ? { purchaseId: o.purchase } : {}),
96
97
  });
98
+ // Recovered purchases can be named by an ID prefix under this contract; the latest stays as is.
99
+ for (const r of recovered)
100
+ await rememberPurchase(c, r.purchase_id, false);
97
101
  return {
98
102
  errors,
99
103
  recovered: recovered.map((r) => ({
@@ -0,0 +1,15 @@
1
+ import type { Input } from './cli.js';
2
+ import type { Config } from './config.js';
3
+ /** IDs of the records saved in a store directory. They are stored in the clear; no secret is read. */
4
+ export declare function savedRecordIds(storeDir: string): Promise<string[]>;
5
+ /**
6
+ * Lists a purchase ID under the configured deployment, and with `latest` makes it the purchase
7
+ * `status` and `open` mean without an ID. `buy` calls it before the purchase record is saved, so a
8
+ * paid purchase is always listed and the latest; `recover` lists what it recovered.
9
+ */
10
+ export declare function rememberPurchase(c: Config, purchaseId: string, latest?: boolean): Promise<void>;
11
+ /**
12
+ * Fills in the purchase `status` or `open` refers to. A name of 8 to 63 hex digits is always read
13
+ * as an ID prefix; write a file with such a name as `./name`.
14
+ */
15
+ export declare function withPurchaseRef(o: Input, c: Config): Promise<Input>;
@@ -0,0 +1,93 @@
1
+ /** How `status` and `open` name a saved purchase without its full ID: the latest one, or a prefix. */
2
+ import { mkdir, readdir, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { fileStore } from '../pay/store-node.js';
5
+ import { fail, isId } from './adapter-checks.js';
6
+ const LATEST = 'latest-purchase';
7
+ const latestStore = (c) => fileStore(join(c.stateDir, LATEST));
8
+ /** One entry per deployment, so another contract's or network's purchase is never picked up. */
9
+ const latestKey = (c) => JSON.stringify([c.network, c.contractId]);
10
+ /** IDs of the records saved in a store directory. They are stored in the clear; no secret is read. */
11
+ export async function savedRecordIds(storeDir) {
12
+ let names;
13
+ try {
14
+ names = await readdir(storeDir);
15
+ }
16
+ catch (error) {
17
+ if (error.code === 'ENOENT')
18
+ return [];
19
+ throw error;
20
+ }
21
+ const ids = [];
22
+ for (const name of names) {
23
+ if (!/^[0-9a-f]{64}\.json$/.test(name))
24
+ continue;
25
+ ids.push(JSON.parse(await readFile(join(storeDir, name), 'utf8')).id);
26
+ }
27
+ return ids;
28
+ }
29
+ /**
30
+ * Lists a purchase ID under the configured deployment, and with `latest` makes it the purchase
31
+ * `status` and `open` mean without an ID. `buy` calls it before the purchase record is saved, so a
32
+ * paid purchase is always listed and the latest; `recover` lists what it recovered.
33
+ */
34
+ export async function rememberPurchase(c, purchaseId, latest = true) {
35
+ await mkdir(join(c.stateDir, LATEST), { recursive: true, mode: 0o700 });
36
+ const store = latestStore(c);
37
+ const id = latestKey(c);
38
+ // Another command of the same deployment may write in between; adding an ID is safe to repeat.
39
+ for (let attempt = 0; attempt < 5; attempt++) {
40
+ const before = await store.load(id);
41
+ const ids = before?.ids ?? [];
42
+ const pointer = latest ? purchaseId : before?.purchaseId;
43
+ if (ids.includes(purchaseId) && pointer === before?.purchaseId)
44
+ return;
45
+ const next = {
46
+ id,
47
+ revision: (before?.revision ?? -1) + 1,
48
+ ...(pointer === undefined ? {} : { purchaseId: pointer }),
49
+ ids: ids.includes(purchaseId) ? ids : [...ids, purchaseId],
50
+ };
51
+ if (await store.compareAndSet(id, before, next))
52
+ return;
53
+ }
54
+ throw new Error('latest purchase conflict');
55
+ }
56
+ const PREFIX = /^(0x)?[0-9a-f]{8,63}$/;
57
+ async function resolve(c, ref) {
58
+ if (ref === undefined) {
59
+ const latest = await latestStore(c).load(latestKey(c));
60
+ return (latest?.purchaseId ??
61
+ fail('purchase_required', 'No purchase has been saved by buy for this contract yet. Pass a purchase ID.', 2));
62
+ }
63
+ if (isId(ref) || !PREFIX.test(ref))
64
+ return ref;
65
+ const prefix = ref.startsWith('0x') ? ref : `0x${ref}`;
66
+ const saved = new Set((await savedRecordIds(join(c.stateDir, 'records')))
67
+ .map((id) => id.replace(/^recovery:/, ''))
68
+ .filter((id) => isId(id) && id.startsWith(prefix)));
69
+ // A saved record does not say in the clear which deployment it belongs to, and an ID says
70
+ // nothing about it. Only the IDs listed for this network and contract can be shortened.
71
+ const listed = new Set((await latestStore(c).load(latestKey(c)))?.ids ?? []);
72
+ const matches = [...saved].filter((id) => listed.has(id));
73
+ if (matches.length > 1)
74
+ return fail('purchase_required', 'This ID prefix matches more than one saved purchase. Use more digits.', 2);
75
+ const [only] = matches;
76
+ if (only)
77
+ return only;
78
+ return fail('purchase_required', saved.size > 0
79
+ ? 'A saved purchase starts with this ID prefix, but it is not listed for this contract and network. It was saved by an older version or for another contract or network. Use the full purchase ID.'
80
+ : 'No saved purchase starts with this ID prefix.', 2);
81
+ }
82
+ /**
83
+ * Fills in the purchase `status` or `open` refers to. A name of 8 to 63 hex digits is always read
84
+ * as an ID prefix; write a file with such a name as `./name`.
85
+ */
86
+ export async function withPurchaseRef(o, c) {
87
+ if (o.command === 'open' && o.purchase !== undefined) {
88
+ const purchase = await resolve(c, o.purchase);
89
+ return purchase === undefined ? o : { ...o, purchase };
90
+ }
91
+ const target = await resolve(c, o.target);
92
+ return target === undefined ? o : { ...o, target };
93
+ }
@@ -0,0 +1,2 @@
1
+ export declare const sessionWords: string[];
2
+ export declare function complete(line: string): [string[], string];
@@ -0,0 +1,17 @@
1
+ import { commandFlags, commandNames } from '../cli-flags.js';
2
+ export const sessionWords = ['help', 'exit', 'quit'];
3
+ export function complete(line) {
4
+ const words = line.trimStart().split(/\s+/);
5
+ const last = words.at(-1) ?? '';
6
+ const [first = ''] = words[0] === 'annon' ? words.slice(1) : words;
7
+ const typingCommand = words.length === (words[0] === 'annon' ? 2 : 1);
8
+ const choices = typingCommand
9
+ ? [...commandNames, ...sessionWords]
10
+ : first === 'shop' && words.at(-2) === 'shop'
11
+ ? ['add']
12
+ : last.startsWith('-') && commandNames.includes(first)
13
+ ? commandFlags(first)
14
+ : [];
15
+ const hits = choices.filter((choice) => choice.startsWith(last)).sort();
16
+ return [hits.map((hit) => `${hit} `), last];
17
+ }
@@ -0,0 +1,20 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ import { type Input } from '../cli.js';
3
+ import type { Dependencies } from '../command-context.js';
4
+ type Execute = (input: Input) => Promise<Record<string, unknown>>;
5
+ export interface SessionOptions {
6
+ input: Readable;
7
+ output: Writable;
8
+ /** Line editing and Tab completion; off for a plain stream. */
9
+ terminal?: boolean;
10
+ welcome: string;
11
+ /** Builds the adapter with the session's prompt and notices. */
12
+ adapter: (hooks: Pick<Dependencies, 'prompt' | 'notify'>) => Execute;
13
+ wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
14
+ }
15
+ export declare function createSession(o: SessionOptions): {
16
+ /** Resolves with the exit code once the person leaves: 0, or 130 after a confirmed Ctrl-C. */
17
+ run(): Promise<number>;
18
+ interrupt: () => Promise<void>;
19
+ };
20
+ export {};
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The interactive session: one prompt, the one-shot commands without the `annon` prefix. Every
3
+ * line goes through `runCli` and the adapter exactly as a one-shot command does; the session adds
4
+ * the prompt, a memory of the last catalog listing, and Ctrl-C handling. It keeps no config, wallet,
5
+ * secret or purchase between commands: `status` and `open` without an ID reach the adapter as
6
+ * typed, and it finds the latest purchase saved for the contract and network of that line.
7
+ */
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { isRow } from '../adapter-checks.js';
10
+ import { runCli } from '../cli.js';
11
+ import { AdapterError, publicError } from '../errors.js';
12
+ import { rowNumber } from '../output/discovery.js';
13
+ import { humanError } from '../output/human.js';
14
+ import { cleanText } from '../output/text.js';
15
+ import { complete } from './complete.js';
16
+ import { splitWords } from './words.js';
17
+ /** Commands that can move money or send a transaction; leaving in the middle needs a yes. */
18
+ const paying = ['buy', 'fund', 'sell', 'share', 'stop', 'recover'];
19
+ /** Commands that only read: Ctrl-C drops their result and returns to the prompt. */
20
+ const reading = ['shops', 'catalog', 'status', 'verify', 'budget'];
21
+ const HELP = `Commands work as on the command line, without the annon prefix:
22
+ login sign in with a browser wallet
23
+ shops, shop add <url> list and add shops
24
+ catalog [--shop <id>] list a shop's files, numbered
25
+ buy <#|url> [--open <file>] buy a row of the last catalog, or a URL
26
+ status [id] check a purchase (default: the latest saved for this contract)
27
+ open [id] --output <file> save a bought file (default: the same latest purchase)
28
+ init, verify, recover, sell, share, fund, budget, stop
29
+ <command> --help every command and option
30
+ exit, quit or Ctrl-D leave
31
+ Ctrl-C answers a prompt with no, drops a listing or status result, stops
32
+ a wait for acceptance, and asks before leaving a running payment`;
33
+ export function createSession(o) {
34
+ const rl = createInterface({
35
+ input: o.input,
36
+ output: o.output,
37
+ terminal: o.terminal === true,
38
+ completer: complete,
39
+ prompt: 'annon> ',
40
+ });
41
+ const say = (text) => o.output.write(`${text}\n`);
42
+ const queue = [];
43
+ /** The catalog listing on screen, by row number; none until `catalog` has run. */
44
+ let rows;
45
+ let question;
46
+ let running;
47
+ let busy = false;
48
+ let closed = false;
49
+ let finish = () => { };
50
+ const ended = new Promise((resolve) => {
51
+ finish = resolve;
52
+ });
53
+ /** One question at a time. Ctrl-C or a closed input answers nothing, which every caller reads as no. */
54
+ let asking = Promise.resolve();
55
+ const ask = (message) => {
56
+ const answer = asking.then(async () => {
57
+ question = new AbortController();
58
+ try {
59
+ return (await rl.question(message, { signal: question.signal })).trim();
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ finally {
65
+ question = undefined;
66
+ }
67
+ });
68
+ asking = answer;
69
+ return answer;
70
+ };
71
+ const adapter = o.adapter({
72
+ prompt: async (message) => (await ask(message))?.toLowerCase() === 'yes',
73
+ notify: say,
74
+ });
75
+ /** The adapter call of a session line, with a row number of the listing on screen filled in. */
76
+ const execute = async (input) => {
77
+ let next = input;
78
+ // A number means a row of the listing on screen. Without one, or with --shop, the adapter
79
+ // reads the catalog itself, as for a one-shot `buy <row> --shop <id>`.
80
+ if (input.command === 'buy' && isRow(input.target) && input.shop === undefined && rows) {
81
+ const row = rows.get(Number(input.target));
82
+ if (!row)
83
+ throw new AdapterError('invalid_arguments', `Row ${input.target} is not in the last catalog listing. Run catalog again.`, 2);
84
+ say(`Row ${input.target}: ${cleanText(row.title)} (${row.url})`);
85
+ const { search: _search, ...rest } = input;
86
+ next = { ...rest, target: row.url };
87
+ }
88
+ const owner = running;
89
+ const result = await adapter(next);
90
+ // A command dropped with Ctrl-C printed nothing, so it must not change what a row number means.
91
+ if (!owner?.live)
92
+ return result;
93
+ if (input.command === 'catalog' && Array.isArray(result.items))
94
+ rows = new Map(result.items.map((entry, i) => [rowNumber(input, i), entry]));
95
+ return result;
96
+ };
97
+ async function command(words) {
98
+ const current = {
99
+ command: words.find((word) => !word.startsWith('-')) ?? '',
100
+ waiting: false,
101
+ live: true,
102
+ stop: new AbortController(),
103
+ drop: () => { },
104
+ };
105
+ const dropped = new Promise((resolve) => {
106
+ current.drop = resolve;
107
+ });
108
+ running = current;
109
+ const done = runCli(words, execute, (text) => current.live && say(text), {
110
+ human: true,
111
+ notify: (message) => current.live && say(message),
112
+ signal: current.stop.signal,
113
+ onWaiting: (waiting) => {
114
+ current.waiting = waiting;
115
+ },
116
+ savePath: async () => (await ask('Save the file now? Path (Enter to skip): ')) || undefined,
117
+ ...(o.wait ? { wait: o.wait } : {}),
118
+ });
119
+ await Promise.race([done, dropped]);
120
+ running = undefined;
121
+ // An exit question left open by Ctrl-C is moot once the command is over.
122
+ question?.abort();
123
+ }
124
+ async function line(text) {
125
+ let words;
126
+ try {
127
+ words = splitWords(text);
128
+ }
129
+ catch (error) {
130
+ say(humanError(publicError(error)));
131
+ return false;
132
+ }
133
+ if (words[0] === 'annon')
134
+ words.shift();
135
+ if (words.length === 0)
136
+ return false;
137
+ if (words.length === 1 && ['exit', 'quit'].includes(words[0]))
138
+ return true;
139
+ if (words.length === 1 && words[0] === 'help')
140
+ say(HELP);
141
+ else if (words[0] === 'shell')
142
+ say('This is the session already.');
143
+ else
144
+ await command(words);
145
+ return false;
146
+ }
147
+ async function pump() {
148
+ if (busy)
149
+ return;
150
+ busy = true;
151
+ let leave = false;
152
+ while (!leave && queue.length > 0)
153
+ leave = await line(queue.shift());
154
+ busy = false;
155
+ if (leave || closed) {
156
+ rl.close();
157
+ finish(0);
158
+ }
159
+ else
160
+ rl.prompt();
161
+ }
162
+ /** Ctrl-C. Never ends a paying command without a yes, and never touches saved purchase state. */
163
+ async function interrupt() {
164
+ if (question) {
165
+ question.abort();
166
+ say('');
167
+ return;
168
+ }
169
+ if (!running) {
170
+ // Drop what was typed; a plain stream has no line to edit.
171
+ if (o.terminal)
172
+ rl.write(null, { ctrl: true, name: 'u' });
173
+ say('\n(type exit to leave)');
174
+ rl.prompt();
175
+ return;
176
+ }
177
+ const current = running;
178
+ if (current.waiting) {
179
+ say('Stopped waiting. The purchase is saved; check it with status.');
180
+ current.stop.abort();
181
+ }
182
+ else if (reading.includes(current.command)) {
183
+ current.live = false;
184
+ say('Cancelled.');
185
+ current.drop();
186
+ }
187
+ else {
188
+ const answer = await ask(paying.includes(current.command)
189
+ ? 'A payment may be in progress. Exit anyway? (y/N) '
190
+ : `${current.command} is still running. Exit anyway? (y/N) `);
191
+ if (running === current && /^y(es)?$/i.test(answer ?? '')) {
192
+ if (paying.includes(current.command))
193
+ say('Leaving. Nothing was removed: check the purchase with annon status before paying again.');
194
+ rl.close();
195
+ finish(130);
196
+ }
197
+ else if (running === current)
198
+ say('Still running.');
199
+ }
200
+ }
201
+ rl.on('line', (text) => {
202
+ queue.push(text);
203
+ void pump();
204
+ });
205
+ rl.on('close', () => {
206
+ closed = true;
207
+ void pump();
208
+ });
209
+ rl.on('SIGINT', () => void interrupt());
210
+ return {
211
+ /** Resolves with the exit code once the person leaves: 0, or 130 after a confirmed Ctrl-C. */
212
+ run() {
213
+ say(o.welcome);
214
+ rl.prompt();
215
+ return ended;
216
+ },
217
+ interrupt,
218
+ };
219
+ }
@@ -0,0 +1,9 @@
1
+ import type { Environment } from '../wallets.js';
2
+ /**
3
+ * A session needs a person at a terminal. Pipes, agent mode and a config that cannot say whether
4
+ * it is an agent config all keep the one-shot behavior.
5
+ */
6
+ export declare function sessionAllowed(argv: string[], terminal: boolean, env: Environment): Promise<boolean>;
7
+ /** Human text only for a person at a terminal outside agent mode. */
8
+ export declare const humanOutput: (terminal: boolean, env: Environment) => Promise<boolean>;
9
+ export declare function welcome(env: Environment): Promise<string>;
@@ -0,0 +1,39 @@
1
+ /** The session's opening lines. Read-only: no prompt, no network, no secret, nothing written. */
2
+ import { join } from 'node:path';
3
+ import { fileStore } from '../../pay/store-node.js';
4
+ import { agentPolicy } from '../agent.js';
5
+ import { loadConfig, trustId } from '../config.js';
6
+ import { cleanText, networkName } from '../output/text.js';
7
+ import { version } from '../version.js';
8
+ const probe = { command: 'shops' };
9
+ /**
10
+ * A session needs a person at a terminal. Pipes, agent mode and a config that cannot say whether
11
+ * it is an agent config all keep the one-shot behavior.
12
+ */
13
+ export async function sessionAllowed(argv, terminal, env) {
14
+ if (!terminal || !(argv.length === 0 || (argv.length === 1 && argv[0] === 'shell')))
15
+ return false;
16
+ try {
17
+ return !(await agentPolicy(probe, env));
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Human text only for a person at a terminal outside agent mode. */
24
+ export const humanOutput = async (terminal, env) => terminal &&
25
+ (await agentPolicy(probe, env).then((agent) => !agent, () => false));
26
+ export async function welcome(env) {
27
+ const lines = [`Annon ${version}`];
28
+ try {
29
+ const c = await loadConfig({ command: 'login' }, env, true);
30
+ const trusted = c.trustContract === true ||
31
+ (await fileStore(join(c.stateDir, 'trust')).load(trustId(c))) !== null;
32
+ lines.push(`Network: ${networkName(c.network)}`, `Account: ${c.signer ? cleanText(c.signer.identity.id) : 'not signed in (run login)'}`, `Contract: ${c.contractId ? `${c.contractId} (${trusted ? 'trusted' : 'not trusted yet'})` : 'not configured'}`);
33
+ }
34
+ catch {
35
+ lines.push('Config: could not be read. Run a command to see why.');
36
+ }
37
+ lines.push('Type help for commands, exit to leave. Tab completes.');
38
+ return lines.join('\n');
39
+ }
@@ -0,0 +1 @@
1
+ export declare function splitWords(line: string): string[];
@@ -0,0 +1,36 @@
1
+ /** Splits a session line into arguments: spaces separate, quotes and backslashes keep them. */
2
+ import { AdapterError } from '../errors.js';
3
+ export function splitWords(line) {
4
+ const words = [];
5
+ let word;
6
+ let quote;
7
+ for (let i = 0; i < line.length; i++) {
8
+ const ch = line.charAt(i);
9
+ if (quote) {
10
+ if (ch === quote)
11
+ quote = undefined;
12
+ else if (ch === '\\' && quote === '"' && i + 1 < line.length)
13
+ word += line.charAt(++i);
14
+ else
15
+ word += ch;
16
+ }
17
+ else if (ch === '"' || ch === "'") {
18
+ quote = ch;
19
+ word ??= '';
20
+ }
21
+ else if (ch === '\\' && i + 1 < line.length)
22
+ word = (word ?? '') + line.charAt(++i);
23
+ else if (/\s/.test(ch)) {
24
+ if (word !== undefined)
25
+ words.push(word);
26
+ word = undefined;
27
+ }
28
+ else
29
+ word = (word ?? '') + ch;
30
+ }
31
+ if (quote)
32
+ throw new AdapterError('invalid_arguments', 'A quote is not closed.', 2);
33
+ if (word !== undefined)
34
+ words.push(word);
35
+ return words;
36
+ }
@@ -15,3 +15,8 @@ export declare function selectedShopRules(config: DiscoveryConfig, id: string |
15
15
  };
16
16
  rules: import("../shops/discovery.js").ShopRules;
17
17
  }>;
18
+ /**
19
+ * `buy <row>`: the resource URL of that row in the listing `catalog` prints for the same `--shop`
20
+ * and `--search`. The buy then checks the file and its terms as for a URL typed by hand.
21
+ */
22
+ export declare function catalogRow(input: Input, env: Environment, notify: ((message: string) => void) | undefined, fetcher?: typeof fetch): Promise<Input>;
@@ -4,6 +4,8 @@ import { dirname } from 'node:path';
4
4
  import { AnnonError } from '../errors.js';
5
5
  import { canonicalManifestUrl, catalog, listShops, readShop, validateDiscoveryConfig, } from '../shops/discovery.js';
6
6
  import { configPath } from './agent.js';
7
+ import { AdapterError } from './errors.js';
8
+ import { cleanText } from './output/text.js';
7
9
  export const isDiscovery = (input) => ['shops', 'shop', 'catalog'].includes(input.command);
8
10
  async function readConfig(path) {
9
11
  try {
@@ -72,3 +74,23 @@ export async function selectedShopRules(config, id, fetcher) {
72
74
  ? { shop: { manifest: shop.manifest, name: shop.info.name }, rules: shop.info.rules }
73
75
  : {};
74
76
  }
77
+ /**
78
+ * `buy <row>`: the resource URL of that row in the listing `catalog` prints for the same `--shop`
79
+ * and `--search`. The buy then checks the file and its terms as for a URL typed by hand.
80
+ */
81
+ export async function catalogRow(input, env, notify, fetcher = globalThis.fetch) {
82
+ const row = Number(input.target);
83
+ const config = validateDiscoveryConfig(await readConfig(configPath(input, env)));
84
+ const listing = await catalog(config, {
85
+ offset: row - 1,
86
+ limit: 1,
87
+ ...(input.search !== undefined ? { search: input.search } : {}),
88
+ ...(input.shop !== undefined ? { shop: input.shop } : {}),
89
+ }, fetcher);
90
+ const [entry] = listing.items;
91
+ if (!entry)
92
+ throw new AdapterError('invalid_arguments', `The catalog has no row ${row}. Run annon catalog to list the rows.`, 2);
93
+ notify?.(`Row ${row}: ${cleanText(entry.title)} (${entry.url})`);
94
+ const { shop: _shop, search: _search, ...rest } = input;
95
+ return { ...rest, target: entry.url };
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defdone/annon",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "Files only the buyer can open: encrypted on the client, the key released by NEAR once the payment is verified.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://annon.dev",