@rfany/rf 0.4.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.
- package/README.md +3 -2
- package/package.json +1 -1
- package/rf.mjs +67 -23
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
|
|
6
|
+
rf login https://rollforward.example.com
|
|
7
7
|
|
|
8
|
-
|
|
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.
|
|
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 {
|
|
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,
|
|
@@ -37,7 +38,10 @@ rf people [--set FILE|-] [-n] [--force] everyone as lines of ROLE EMAIL, each
|
|
|
37
38
|
rf user add EMAIL [admin|publisher] | role EMAIL admin|publisher|user [--force] | disable EMAIL | enable EMAIL | signout EMAIL | remove EMAIL
|
|
38
39
|
rf website set ID URL [--name NAME] [--hosts HOST,...] a website people sign in to at URL, and the other hosts its pages need,
|
|
39
40
|
such as sso.example.com or *.example.com; a run's browser on it reaches only those
|
|
40
|
-
rf website remove ID
|
|
41
|
+
rf website remove ID
|
|
42
|
+
rf brand [--text TEXT] [--logo FILE] [--clear] the deployment's text and logo, a .png, .jpg, .gif, .webp or .svg, which top
|
|
43
|
+
the sidebar and are the page's title and icon; --clear goes back to the plain look, and with
|
|
44
|
+
no option it prints them`;
|
|
41
45
|
|
|
42
46
|
const GUIDE = `rollforward runs a team's recurring work, such as a weekly borrowing base or a month-end reconciliation, with an agent
|
|
43
47
|
that does each period's work and people who check it. rf does what its page does, as you.
|
|
@@ -46,19 +50,22 @@ What there is
|
|
|
46
50
|
- A workspace holds series, and its people. A series is one recurring job; an instance is one period of it, labelled such as
|
|
47
51
|
2026-08-23. A task is a one-off: a series of one instance, named by its label, until someone makes it a series. PATH is workspace/series/label, as rf ls prints it; for rf pull it may go deeper, into an instance's files.
|
|
48
52
|
- An instance's screen is its items: inputs, steps and outputs, each pointing at a file, with what it's made from. People upload
|
|
49
|
-
files and comment
|
|
50
|
-
|
|
53
|
+
files and comment. The runner is the person whose logins the runs use, and who closes the instance once they stand by it. Their
|
|
54
|
+
comments and uploads start a run of the agent, which reads them and carries on, as do everyone's where the runner shares the
|
|
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.
|
|
51
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
|
|
52
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
|
|
53
|
-
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.
|
|
54
60
|
|
|
55
61
|
What rf pull copies, the folders as the agent sees them
|
|
56
62
|
AGENTS.md the company's conventions; <workspace>/AGENTS.md the workspace's
|
|
57
63
|
<workspace>/<series>/<label>/
|
|
58
64
|
workspace.json the screen: {rev, status, items: [{id, name, kind, file, from, headline, source}]}
|
|
59
65
|
log.jsonl every event, by seq: by is a person's email, "server" or "agent"; op is upload, comment (thread, item,
|
|
60
|
-
ref, text, ask, to), resolve,
|
|
61
|
-
a run), done (its cost, summary or
|
|
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
|
|
62
69
|
upload/ what people uploaded, their comments' attachments in upload/attachments/
|
|
63
70
|
working/ what the agent made, its scripts, and process.md, how it does this series
|
|
64
71
|
session/ the agent's own session: every message and tool call of every run
|
|
@@ -69,14 +76,15 @@ Usual work
|
|
|
69
76
|
- What waits on me: rf ls, then the asks in that instance's log.jsonl, ask true, whose thread has nothing after them.
|
|
70
77
|
- Answer an ask: rf comment PATH TEXT --thread T --wait, which prints what the agent says back.
|
|
71
78
|
- Give an input or a corrected output: rf upload PATH FILE [--onto ITEM] [--output].
|
|
72
|
-
- Start an instance: rf wake SERIES now REASON --label LABEL
|
|
79
|
+
- Start an instance: rf wake SERIES now REASON --label LABEL, which starts it as whoever shares the series, else asks on the page for
|
|
80
|
+
someone to start it; take one you run up later: rf wake PATH +2h REASON.
|
|
73
81
|
- Read what a run did: rf pull PATH/ DIR, then its report and done entries in log.jsonl, the files in working/, and session/ for
|
|
74
82
|
every step.
|
|
75
83
|
|
|
76
84
|
Watching over it, for admins and power users; each sees what the caller sees, and takes --json
|
|
77
85
|
- What's stuck: rf status lists runs going, flagging those past --long MIN (30); queued runs, since when and why: their series is
|
|
78
86
|
running, the RF_MAX_RUNS cap, or memory; what waits on whom since when: asks, notes for runners, expired logins and the instances
|
|
79
|
-
they hold, requests to start
|
|
87
|
+
they hold, and requests to start; and open instances whose last run failed.
|
|
80
88
|
- History: rf runs [PATH] [--since DATE] [--failed] [--running] [--long MIN] prints a line per run: its start, path, run number,
|
|
81
89
|
outcome, time taken, cost, what started it and by whom, and its summary or error. rf costs [PATH] [--since DATE] [--by
|
|
82
90
|
workspace|series|person|day] totals them, by the runner for person.
|
|
@@ -93,18 +101,22 @@ const flags = {}, excludes = [], args
|
|
|
93
101
|
for (let i = 0; i < argv.length; i++) {
|
|
94
102
|
const flag = argv[i] === '-n' ? 'dry-run' : argv[i].match(/^--(.+)$/)?.[1];
|
|
95
103
|
if (!flag) args.push(argv[i]);
|
|
96
|
-
else if (
|
|
97
|
-
else
|
|
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];
|
|
98
110
|
}
|
|
99
111
|
if (cmd === 'help') {
|
|
100
112
|
console.log(GUIDE);
|
|
101
113
|
process.exit();
|
|
102
114
|
}
|
|
103
115
|
if (cmd !== 'login' && !existsSync(config)) {
|
|
104
|
-
console.error(`Sign in first
|
|
116
|
+
console.error(`Sign in first: rf login URL\n\n${USAGE}`);
|
|
105
117
|
process.exit(2);
|
|
106
118
|
}
|
|
107
|
-
|
|
119
|
+
let { url, token } = cmd === 'login' ? { url: args[0]?.replace(/\/$/, ''), token: args[1] } : JSON.parse(readFileSync(config, 'utf8'));
|
|
108
120
|
|
|
109
121
|
async function api(path , init = {}) {
|
|
110
122
|
const r = await fetch(url + path, { ...init, headers: { authorization: `Bearer ${token}` } });
|
|
@@ -136,8 +148,15 @@ function write(path , data , mtime ) {
|
|
|
136
148
|
chmodSync(path, 0o444);
|
|
137
149
|
}
|
|
138
150
|
|
|
139
|
-
// A path the server named, under `base`, or undefined: on Windows a
|
|
140
|
-
|
|
151
|
+
// A path the server named, under `base`, or undefined where a name in it can't be one here: on Windows a name may hold \ or :, which
|
|
152
|
+
// would put the file elsewhere, even in another workspace's folder, or another file's stream; or name a device, such as CON; or end
|
|
153
|
+
// in a dot or a space, which Windows drops.
|
|
154
|
+
const under = (base , rel ) => {
|
|
155
|
+
const names = rel.split('/');
|
|
156
|
+
if (names.some(n => !n || n === '.' || n === '..' || basename(n) !== n
|
|
157
|
+
|| (process.platform === 'win32' && (/[<>:"|?*\x00-\x1f]|[. ]$/.test(n) || /^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(n))))) return undefined;
|
|
158
|
+
return join(base, ...names);
|
|
159
|
+
};
|
|
141
160
|
|
|
142
161
|
// rsync's --exclude, simply: a pattern without / matches any one name on the path, one with / the path from the pull's top; * and ?
|
|
143
162
|
// stop at /, ** doesn't. A folder it matches takes what's in it along.
|
|
@@ -150,12 +169,13 @@ async function pull(path , dir ) {
|
|
|
150
169
|
const at = path.replace(/^\/+/, ''), from = at.replace(/\/+$/, ''), dry = !!flags['dry-run'];
|
|
151
170
|
const list = await get(`/api/files?path=${encodeURIComponent(from)}`);
|
|
152
171
|
if (!list.length) throw new Error(`nothing you can see at ${path}`);
|
|
153
|
-
const into = !from || at.endsWith('/') ? resolve(dir) :
|
|
172
|
+
const into = !from || at.endsWith('/') ? resolve(dir) : under(resolve(dir), from.slice(from.lastIndexOf('/') + 1)), rel = (f ) => f.path.slice(from ? from.length + 1 : 0);
|
|
173
|
+
if (!into) throw new Error(`${from} can't be a name here: pull ${from}/ into a folder of your choosing`);
|
|
154
174
|
let fetched = 0, same = 0, deleted = 0, failed = 0;
|
|
155
175
|
for (const f of list.filter(f => !excluded(rel(f)))) {
|
|
156
176
|
const local = rel(f) ? under(into, rel(f)) : into, st = local && existsSync(local) ? statSync(local) : undefined;
|
|
157
177
|
if (!local) {
|
|
158
|
-
console.error(`skipped ${f.path}: it
|
|
178
|
+
console.error(`skipped ${f.path}: it can't be a file's name here`);
|
|
159
179
|
continue;
|
|
160
180
|
}
|
|
161
181
|
if (st && st.size === f.size && Math.floor(st.mtimeMs / 1000) === Math.floor(Date.parse(f.mtime) / 1000)) {
|
|
@@ -187,7 +207,22 @@ async function pull(path , dir ) {
|
|
|
187
207
|
if (failed) process.exitCode = 1;
|
|
188
208
|
}
|
|
189
209
|
|
|
190
|
-
|
|
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();
|
|
191
226
|
const me = await get('/me');
|
|
192
227
|
mkdirSync(dirname(config), { recursive: true });
|
|
193
228
|
writeFileSync(config, JSON.stringify({ url, token }), { mode: 0o600 });
|
|
@@ -201,8 +236,9 @@ if (cmd === 'login' && url && token) {
|
|
|
201
236
|
else if (cmd === 'comment' && args.length === 2) {
|
|
202
237
|
const { at } = await find(args[0]);
|
|
203
238
|
await post(`${at}/comment`, { text: args[1], item: flags.item, ref: flags.ref, thread: flags.thread });
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
239
|
+
const { log, runner } = await get(at), { seq, note } = log.at(-1);
|
|
240
|
+
if (note) console.log(`a note for the runner, ${runner.by}, seq ${seq}: it starts no run until they act`);
|
|
241
|
+
else if (!flags.wait) console.log(`commented, seq ${seq}`);
|
|
206
242
|
else for (;;) {
|
|
207
243
|
// The run that saw the comment, and what it said from then on: a run ending as it came may not have.
|
|
208
244
|
await new Promise(r => setTimeout(r, 5000));
|
|
@@ -257,10 +293,9 @@ else if (cmd === 'comment' && args.length === 2) {
|
|
|
257
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` : ''}`)],
|
|
258
294
|
[`Queued`, s.queued.map(q => `${q.path} since ${when(q.since)}: ${q.why}`)],
|
|
259
295
|
[`Waiting`, [
|
|
260
|
-
...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)}`),
|
|
261
297
|
...s.expired.map(e => `${e.who} their ${e.login} login expired${e.holding.length ? `, holding ${e.holding.join(', ')}` : ''}`),
|
|
262
298
|
...s.requests.map(r => `${r.path} ${r.from} asks to start ${r.label}, since ${when(r.at)}: ${r.reason}`),
|
|
263
|
-
...s.broken.map(b => `${b.path} shared by ${b.shared_by}, but nothing is open and no wake is set`),
|
|
264
299
|
]],
|
|
265
300
|
[`Last run failed`, s.failed.map(f => `${f.path} run ${f.run} at ${when(f.at)}: ${f.error}`)],
|
|
266
301
|
].filter(([, rows]) => rows.length);
|
|
@@ -309,6 +344,15 @@ else if (cmd === 'comment' && args.length === 2) {
|
|
|
309
344
|
console.log('saved');
|
|
310
345
|
}
|
|
311
346
|
}
|
|
347
|
+
} else if (cmd === 'brand' && !args.length) {
|
|
348
|
+
if (flags.clear || flags.text !== undefined || flags.logo !== undefined) {
|
|
349
|
+
const logo = typeof flags.logo === 'string' ? { name: basename(flags.logo), data: readFileSync(flags.logo).toString('base64') } : undefined;
|
|
350
|
+
await post('/brand', { clear: flags.clear, text: flags.text, logo }, 'PUT');
|
|
351
|
+
console.log('saved');
|
|
352
|
+
} else {
|
|
353
|
+
const { text, logo } = (await get('/me')).brand;
|
|
354
|
+
console.log(text || logo ? `${text ?? 'no text'}, ${logo ? 'with a logo' : 'no logo'}` : 'no brand: the plain look');
|
|
355
|
+
}
|
|
312
356
|
} else if (cmd === 'user' && args.length >= 2) {
|
|
313
357
|
const [what, email, role] = args, at = `/users/${encodeURIComponent(email)}`;
|
|
314
358
|
if (what === 'add') await post('/users', { roles: { [email]: role ?? 'user' } }, 'PUT');
|