@rfany/rf 0.5.0 → 0.6.0

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 (3) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/rf.mjs +33 -11
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  People's command line for [rollforward](https://github.com/spoj/rollforward): what its page does, from a shell or a desktop agent, as you.
4
4
 
5
5
  npm i -g @rfany/rf
6
- rf login https://rollforward.example.com TOKEN
6
+ rf login https://rollforward.example.com
7
7
 
8
- Make the token on the page, under Tokens. `rf` alone lists the commands; `rf help` is the guide for an agent, read before it first uses rf.
8
+ rf opens the page, which asks you to allow it if it shows the same code as rf; or make a token on the page, under Tokens, and
9
+ `rf login URL TOKEN`. `rf` alone lists the commands; `rf help` is the guide for an agent, read before it first uses rf.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rfany/rf",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "rollforward's command line for people: what its page does, from a shell or a desktop agent, as you",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/spoj/rollforward.git", "directory": "cli" },
package/rf.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  // rf for people: what the page does, from a shell or a desktop agent, as you, with a personal token made on the page (Tokens).
3
3
  // Published to npm as @rfany/rf, from cli/. PATH is workspace/series/label, as `rf ls` prints it.
4
4
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
5
- import { homedir } from 'node:os';
5
+ import { spawn } from 'node:child_process';
6
+ import { homedir, hostname } from 'node:os';
6
7
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
7
8
 
8
- const USAGE = `rf login URL TOKEN
9
+ const USAGE = `rf login URL [TOKEN] without a TOKEN, through the browser: allow it on the page, where it shows rf's code
9
10
  rf ls
10
11
  rf pull PATH DIR [--delete] [-n|--dry-run] [--exclude PATTERN]...
11
12
  PATH's files into DIR as rsync copies them, whole, if their size or time differ: PATH/ fills DIR,
@@ -54,15 +55,17 @@ What there is
54
55
  series, and a reply from whom an ask names; anyone else's is a note, which the agent takes up once the runner does.
55
56
  - A comment starts a thread, on the instance or on an item, at a cell such as Base!C25, and others reply in it. An ask is the
56
57
  agent's comment that waits on someone, the one it names or else the runner, until they reply or resolve it. rf ls says how many
57
- asks wait in each instance.
58
+ asks wait in each instance. A sign-off, an ask with signoff true, waits on the runner, who approves or rejects it on the page:
59
+ either commits the instance's folder to its history, .git in it, as closing does.
58
60
 
59
61
  What rf pull copies, the folders as the agent sees them
60
62
  AGENTS.md the company's conventions; <workspace>/AGENTS.md the workspace's
61
63
  <workspace>/<series>/<label>/
62
64
  workspace.json the screen: {rev, status, items: [{id, name, kind, file, from, headline, source}]}
63
65
  log.jsonl every event, by seq: by is a person's email, "server" or "agent"; op is upload, comment (thread, item,
64
- ref, text, ask, to), resolve, point and item (the screen changing), run, report (the agent's account of
65
- a run), done (its cost, summary or error), close, and the rest
66
+ ref, text, ask, to, signoff), resolve, signoff (the runner's answer, with its commit), commit, point and
67
+ item (the screen changing), run, report (the agent's account of a run), done (its cost, summary or
68
+ error), close, and the rest
66
69
  upload/ what people uploaded, their comments' attachments in upload/attachments/
67
70
  working/ what the agent made, its scripts, and process.md, how it does this series
68
71
  session/ the agent's own session: every message and tool call of every run
@@ -98,18 +101,22 @@ const flags = {}, excludes = [], args
98
101
  for (let i = 0; i < argv.length; i++) {
99
102
  const flag = argv[i] === '-n' ? 'dry-run' : argv[i].match(/^--(.+)$/)?.[1];
100
103
  if (!flag) args.push(argv[i]);
101
- else if (flag === 'exclude') excludes.push(argv[++i]);
102
- else flags[flag] = ['wait', 'output', 'delete', 'dry-run', 'force', 'json', 'failed', 'running', 'clear'].includes(flag) ? true : argv[++i];
104
+ else if (['wait', 'output', 'delete', 'dry-run', 'force', 'json', 'failed', 'running', 'clear'].includes(flag)) flags[flag] = true;
105
+ else if (argv[i + 1] === undefined) {
106
+ console.error(`--${flag} takes a value\n\n${USAGE}`);
107
+ process.exit(2);
108
+ } else if (flag === 'exclude') excludes.push(argv[++i]);
109
+ else flags[flag] = argv[++i];
103
110
  }
104
111
  if (cmd === 'help') {
105
112
  console.log(GUIDE);
106
113
  process.exit();
107
114
  }
108
115
  if (cmd !== 'login' && !existsSync(config)) {
109
- console.error(`Sign in first, with a token made on the page under Tokens: rf login URL TOKEN\n\n${USAGE}`);
116
+ console.error(`Sign in first: rf login URL\n\n${USAGE}`);
110
117
  process.exit(2);
111
118
  }
112
- const { url, token } = cmd === 'login' ? { url: args[0], token: args[1] } : JSON.parse(readFileSync(config, 'utf8'));
119
+ let { url, token } = cmd === 'login' ? { url: args[0]?.replace(/\/$/, ''), token: args[1] } : JSON.parse(readFileSync(config, 'utf8'));
113
120
 
114
121
  async function api(path , init = {}) {
115
122
  const r = await fetch(url + path, { ...init, headers: { authorization: `Bearer ${token}` } });
@@ -200,7 +207,22 @@ async function pull(path , dir ) {
200
207
  if (failed) process.exitCode = 1;
201
208
  }
202
209
 
203
- if (cmd === 'login' && url && token) {
210
+ // The browser's sign-in: the server makes a request, the person allows it on the page, which shows its code, and rf collects the token.
211
+ async function browserToken() {
212
+ const { id, code } = await (await fetch(`${url}/auth/cli`, { method: 'POST', body: JSON.stringify({ name: `rf on ${hostname()}` }) })).json();
213
+ const link = `${url}/cli?code=${code}`;
214
+ console.log(`Open ${link}\nand allow rf there if the page shows the code ${code}.`);
215
+ spawn(process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open', [link], { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
216
+ for (;;) {
217
+ await new Promise(ok => setTimeout(ok, 2000));
218
+ const r = await fetch(`${url}/auth/cli/poll`, { method: 'POST', body: JSON.stringify({ id }) });
219
+ if (r.status === 200) return (await r.json()).token;
220
+ if (r.status !== 202) throw new Error(await r.text());
221
+ }
222
+ }
223
+
224
+ if (cmd === 'login' && url) {
225
+ token ??= await browserToken();
204
226
  const me = await get('/me');
205
227
  mkdirSync(dirname(config), { recursive: true });
206
228
  writeFileSync(config, JSON.stringify({ url, token }), { mode: 0o600 });
@@ -271,7 +293,7 @@ else if (cmd === 'comment' && args.length === 2) {
271
293
  [`Running`, s.running.map(r => `${r.path} run ${r.run} for ${took((Date.now() - Date.parse(r.at)) / 1000)} ${usd(r.cost)}${Date.now() - Date.parse(r.at) > long * 60e3 ? ` LONG, past ${long}m` : ''}`)],
272
294
  [`Queued`, s.queued.map(q => `${q.path} since ${when(q.since)}: ${q.why}`)],
273
295
  [`Waiting`, [
274
- ...s.asks.sort((a, b) => a.who.localeCompare(b.who) || a.since.localeCompare(b.since)).map(a => `${a.who} ${a.note ? 'a note' : 'an ask'} in ${a.path} since ${when(a.since)}: ${a.text.split('\n')[0].slice(0, 80)}`),
296
+ ...s.asks.sort((a, b) => a.who.localeCompare(b.who) || a.since.localeCompare(b.since)).map(a => `${a.who} ${a.signoff ? 'a sign-off' : a.note ? 'a note' : 'an ask'} in ${a.path} since ${when(a.since)}: ${a.text.split('\n')[0].slice(0, 80)}`),
275
297
  ...s.expired.map(e => `${e.who} their ${e.login} login expired${e.holding.length ? `, holding ${e.holding.join(', ')}` : ''}`),
276
298
  ...s.requests.map(r => `${r.path} ${r.from} asks to start ${r.label}, since ${when(r.at)}: ${r.reason}`),
277
299
  ]],