@rfany/rf 0.3.0 → 0.5.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/package.json +1 -1
- package/rf.mjs +129 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rfany/rf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
@@ -17,6 +17,14 @@ rf wake PATH AT REASON [--label LABEL] AT now, +<n>m|h|d or a date; PATH an op
|
|
|
17
17
|
rf agents [WORKSPACE] [--set FILE|-] the company's AGENTS.md, or a workspace's; setting the company's is for admins
|
|
18
18
|
rf skills
|
|
19
19
|
rf websites
|
|
20
|
+
rf status [--long MIN] [--json] what goes on and what's stuck: runs going, flagged past MIN minutes (30), and queued, with why;
|
|
21
|
+
what waits on whom since when; open instances whose last run failed
|
|
22
|
+
rf runs [PATH] [--since DATE] [--failed] [--running] [--long MIN] [--json]
|
|
23
|
+
a line per run: start, path, run, outcome, time, cost, what started it, summary or error
|
|
24
|
+
rf costs [PATH] [--since DATE] [--by workspace|series|person|day] [--json] totals, runs and cost per run; person is the runner
|
|
25
|
+
rf people WORKSPACE [--set FILE|-] [-n] [--force] its owners and admins: its people as lines of owner|member EMAIL-OR-GROUP,
|
|
26
|
+
each with a comment; --set replaces them, -n shows the changes, --force hides open instances
|
|
27
|
+
from their runners
|
|
20
28
|
|
|
21
29
|
Publishers and admins:
|
|
22
30
|
rf skill push DIR DIR/SKILL.md and the rest become the newest version of the skill named DIR's name, live at once
|
|
@@ -24,21 +32,26 @@ rf skill versions ID
|
|
|
24
32
|
rf skill rollback ID N pushes version N again, as the newest
|
|
25
33
|
|
|
26
34
|
Admins:
|
|
27
|
-
rf
|
|
28
|
-
|
|
35
|
+
rf people [--set FILE|-] [-n] [--force] everyone as lines of ROLE EMAIL, each with a comment; --set sets the roles of the people
|
|
36
|
+
listed and adds new ones, removing no one
|
|
37
|
+
rf user add EMAIL [admin|publisher] | role EMAIL admin|publisher|user [--force] | disable EMAIL | enable EMAIL | signout EMAIL | remove EMAIL
|
|
29
38
|
rf website set ID URL [--name NAME] [--hosts HOST,...] a website people sign in to at URL, and the other hosts its pages need,
|
|
30
39
|
such as sso.example.com or *.example.com; a run's browser on it reaches only those
|
|
31
|
-
rf website remove ID
|
|
40
|
+
rf website remove ID
|
|
41
|
+
rf brand [--text TEXT] [--logo FILE] [--clear] the deployment's text and logo, a .png, .jpg, .gif, .webp or .svg, which top
|
|
42
|
+
the sidebar and are the page's title and icon; --clear goes back to the plain look, and with
|
|
43
|
+
no option it prints them`;
|
|
32
44
|
|
|
33
45
|
const GUIDE = `rollforward runs a team's recurring work, such as a weekly borrowing base or a month-end reconciliation, with an agent
|
|
34
46
|
that does each period's work and people who check it. rf does what its page does, as you.
|
|
35
47
|
|
|
36
48
|
What there is
|
|
37
49
|
- A workspace holds series, and its people. A series is one recurring job; an instance is one period of it, labelled such as
|
|
38
|
-
2026-08-23. PATH is workspace/series/label, as rf ls prints it; for rf pull it may go deeper, into an instance's files.
|
|
50
|
+
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.
|
|
39
51
|
- An instance's screen is its items: inputs, steps and outputs, each pointing at a file, with what it's made from. People upload
|
|
40
|
-
files and comment
|
|
41
|
-
|
|
52
|
+
files and comment. The runner is the person whose logins the runs use, and who closes the instance once they stand by it. Their
|
|
53
|
+
comments and uploads start a run of the agent, which reads them and carries on, as do everyone's where the runner shares the
|
|
54
|
+
series, and a reply from whom an ask names; anyone else's is a note, which the agent takes up once the runner does.
|
|
42
55
|
- 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
|
|
43
56
|
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
|
|
44
57
|
asks wait in each instance.
|
|
@@ -60,9 +73,24 @@ Usual work
|
|
|
60
73
|
- What waits on me: rf ls, then the asks in that instance's log.jsonl, ask true, whose thread has nothing after them.
|
|
61
74
|
- Answer an ask: rf comment PATH TEXT --thread T --wait, which prints what the agent says back.
|
|
62
75
|
- Give an input or a corrected output: rf upload PATH FILE [--onto ITEM] [--output].
|
|
63
|
-
- Start an instance: rf wake SERIES now REASON --label LABEL
|
|
76
|
+
- Start an instance: rf wake SERIES now REASON --label LABEL, which starts it as whoever shares the series, else asks on the page for
|
|
77
|
+
someone to start it; take one you run up later: rf wake PATH +2h REASON.
|
|
64
78
|
- 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
|
|
65
|
-
every step
|
|
79
|
+
every step.
|
|
80
|
+
|
|
81
|
+
Watching over it, for admins and power users; each sees what the caller sees, and takes --json
|
|
82
|
+
- What's stuck: rf status lists runs going, flagging those past --long MIN (30); queued runs, since when and why: their series is
|
|
83
|
+
running, the RF_MAX_RUNS cap, or memory; what waits on whom since when: asks, notes for runners, expired logins and the instances
|
|
84
|
+
they hold, and requests to start; and open instances whose last run failed.
|
|
85
|
+
- History: rf runs [PATH] [--since DATE] [--failed] [--running] [--long MIN] prints a line per run: its start, path, run number,
|
|
86
|
+
outcome, time taken, cost, what started it and by whom, and its summary or error. rf costs [PATH] [--since DATE] [--by
|
|
87
|
+
workspace|series|person|day] totals them, by the runner for person.
|
|
88
|
+
- People: rf people (admins) prints everyone as ROLE EMAIL lines, and rf people WORKSPACE (its owners and admins) its owners and
|
|
89
|
+
members as owner|member lines; a comment after each says when they last signed in and which open instances they run, and for
|
|
90
|
+
everyone those they run in workspaces they aren't in. Edit the lines, see the changes with --set FILE -n (FILE may be - for
|
|
91
|
+
stdin), then --set FILE. For everyone it sets the roles of the people listed and adds new ones, removing no one (rf user remove
|
|
92
|
+
does); for a workspace it replaces its people. A change that would hide an open instance from the person who runs it is refused,
|
|
93
|
+
naming them, unless --force: have someone who stays take those over first.`;
|
|
66
94
|
|
|
67
95
|
const config = join(homedir(), '.config', 'rollforward.json');
|
|
68
96
|
const [cmd, ...argv] = process.argv.slice(2);
|
|
@@ -71,7 +99,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
71
99
|
const flag = argv[i] === '-n' ? 'dry-run' : argv[i].match(/^--(.+)$/)?.[1];
|
|
72
100
|
if (!flag) args.push(argv[i]);
|
|
73
101
|
else if (flag === 'exclude') excludes.push(argv[++i]);
|
|
74
|
-
else flags[flag] = ['wait', 'output', 'delete', 'dry-run'].includes(flag) ? true : argv[++i];
|
|
102
|
+
else flags[flag] = ['wait', 'output', 'delete', 'dry-run', 'force', 'json', 'failed', 'running', 'clear'].includes(flag) ? true : argv[++i];
|
|
75
103
|
}
|
|
76
104
|
if (cmd === 'help') {
|
|
77
105
|
console.log(GUIDE);
|
|
@@ -98,6 +126,13 @@ async function find(path ) {
|
|
|
98
126
|
return { series, inst, at: inst ? `/api/instances/${inst.id}` : '' };
|
|
99
127
|
}
|
|
100
128
|
|
|
129
|
+
// Times as this machine's clock reads them, and lengths of time and costs, for the views.
|
|
130
|
+
const when = (iso ) => new Date(iso).toLocaleString('sv-SE', { dateStyle: 'short', timeStyle: 'short' });
|
|
131
|
+
const took = (s ) => s < 60 ? `${Math.round(s)}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.floor(s / 3600)}h${String(Math.round((s % 3600) / 60)).padStart(2, '0')}`;
|
|
132
|
+
const usd = (n ) => `$${n.toFixed(2)}`;
|
|
133
|
+
const runsIn = async (path = '') => (await get(`/api/runs?since=${encodeURIComponent(String(flags.since ?? ''))}`))
|
|
134
|
+
.filter(r => !path || r.path === path.replace(/\/+$/, '') || r.path.startsWith(path.replace(/\/+$/, '') + '/'));
|
|
135
|
+
|
|
101
136
|
function write(path , data , mtime ) {
|
|
102
137
|
mkdirSync(dirname(path), { recursive: true });
|
|
103
138
|
rmSync(path, { force: true });
|
|
@@ -106,8 +141,15 @@ function write(path , data , mtime ) {
|
|
|
106
141
|
chmodSync(path, 0o444);
|
|
107
142
|
}
|
|
108
143
|
|
|
109
|
-
// A path the server named, under `base`, or undefined: on Windows a
|
|
110
|
-
|
|
144
|
+
// 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
|
|
145
|
+
// 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
|
|
146
|
+
// in a dot or a space, which Windows drops.
|
|
147
|
+
const under = (base , rel ) => {
|
|
148
|
+
const names = rel.split('/');
|
|
149
|
+
if (names.some(n => !n || n === '.' || n === '..' || basename(n) !== n
|
|
150
|
+
|| (process.platform === 'win32' && (/[<>:"|?*\x00-\x1f]|[. ]$/.test(n) || /^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(n))))) return undefined;
|
|
151
|
+
return join(base, ...names);
|
|
152
|
+
};
|
|
111
153
|
|
|
112
154
|
// rsync's --exclude, simply: a pattern without / matches any one name on the path, one with / the path from the pull's top; * and ?
|
|
113
155
|
// stop at /, ** doesn't. A folder it matches takes what's in it along.
|
|
@@ -120,12 +162,13 @@ async function pull(path , dir ) {
|
|
|
120
162
|
const at = path.replace(/^\/+/, ''), from = at.replace(/\/+$/, ''), dry = !!flags['dry-run'];
|
|
121
163
|
const list = await get(`/api/files?path=${encodeURIComponent(from)}`);
|
|
122
164
|
if (!list.length) throw new Error(`nothing you can see at ${path}`);
|
|
123
|
-
const into = !from || at.endsWith('/') ? resolve(dir) :
|
|
165
|
+
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);
|
|
166
|
+
if (!into) throw new Error(`${from} can't be a name here: pull ${from}/ into a folder of your choosing`);
|
|
124
167
|
let fetched = 0, same = 0, deleted = 0, failed = 0;
|
|
125
168
|
for (const f of list.filter(f => !excluded(rel(f)))) {
|
|
126
169
|
const local = rel(f) ? under(into, rel(f)) : into, st = local && existsSync(local) ? statSync(local) : undefined;
|
|
127
170
|
if (!local) {
|
|
128
|
-
console.error(`skipped ${f.path}: it
|
|
171
|
+
console.error(`skipped ${f.path}: it can't be a file's name here`);
|
|
129
172
|
continue;
|
|
130
173
|
}
|
|
131
174
|
if (st && st.size === f.size && Math.floor(st.mtimeMs / 1000) === Math.floor(Date.parse(f.mtime) / 1000)) {
|
|
@@ -171,8 +214,9 @@ if (cmd === 'login' && url && token) {
|
|
|
171
214
|
else if (cmd === 'comment' && args.length === 2) {
|
|
172
215
|
const { at } = await find(args[0]);
|
|
173
216
|
await post(`${at}/comment`, { text: args[1], item: flags.item, ref: flags.ref, thread: flags.thread });
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
217
|
+
const { log, runner } = await get(at), { seq, note } = log.at(-1);
|
|
218
|
+
if (note) console.log(`a note for the runner, ${runner.by}, seq ${seq}: it starts no run until they act`);
|
|
219
|
+
else if (!flags.wait) console.log(`commented, seq ${seq}`);
|
|
176
220
|
else for (;;) {
|
|
177
221
|
// The run that saw the comment, and what it said from then on: a run ending as it came may not have.
|
|
178
222
|
await new Promise(r => setTimeout(r, 5000));
|
|
@@ -219,12 +263,78 @@ else if (cmd === 'comment' && args.length === 2) {
|
|
|
219
263
|
} else if (cmd === 'website' && args[0] === 'remove' && args[1]) {
|
|
220
264
|
await api(`/websites/${encodeURIComponent(args[1])}`, { method: 'DELETE' });
|
|
221
265
|
console.log('removed');
|
|
222
|
-
} else if (cmd === '
|
|
223
|
-
|
|
266
|
+
} else if (cmd === 'status' && !args.length) {
|
|
267
|
+
const s = await get('/api/status'), long = Number(flags.long ?? 30);
|
|
268
|
+
if (flags.json) console.log(JSON.stringify(s, null, 1));
|
|
269
|
+
else {
|
|
270
|
+
const sections = [
|
|
271
|
+
[`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
|
+
[`Queued`, s.queued.map(q => `${q.path} since ${when(q.since)}: ${q.why}`)],
|
|
273
|
+
[`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)}`),
|
|
275
|
+
...s.expired.map(e => `${e.who} their ${e.login} login expired${e.holding.length ? `, holding ${e.holding.join(', ')}` : ''}`),
|
|
276
|
+
...s.requests.map(r => `${r.path} ${r.from} asks to start ${r.label}, since ${when(r.at)}: ${r.reason}`),
|
|
277
|
+
]],
|
|
278
|
+
[`Last run failed`, s.failed.map(f => `${f.path} run ${f.run} at ${when(f.at)}: ${f.error}`)],
|
|
279
|
+
].filter(([, rows]) => rows.length);
|
|
280
|
+
console.log(sections.map(([title, rows]) => `${title} (${rows.length})\n${rows.map(r => ` ${r}`).join('\n')}`).join('\n\n') || 'Nothing is running, queued, waiting or failed.');
|
|
281
|
+
}
|
|
282
|
+
} else if (cmd === 'runs' && args.length <= 1) {
|
|
283
|
+
const rows = (await runsIn(args[0])).filter(r => (!flags.failed || r.outcome === 'failed') && (!flags.running || r.outcome === 'running') && (!flags.long || r.seconds >= Number(flags.long) * 60));
|
|
284
|
+
if (flags.json) console.log(JSON.stringify(rows, null, 1));
|
|
285
|
+
else for (const r of rows) console.log(`${when(r.at)} ${r.path} run ${r.run} ${r.outcome} ${took(r.seconds)} ${usd(r.cost)} ${r.started ? `${r.started.op} by ${r.started.by}` : '-'} ${r.error ?? r.summary ?? ''}`);
|
|
286
|
+
} else if (cmd === 'costs' && args.length <= 1) {
|
|
287
|
+
const by = String(flags.by ?? 'series'), key = { workspace: r => r.path.split('/')[0], series: r => r.path.split('/').slice(0, 2).join('/'), person: r => r.runner, day: r => when(r.at).slice(0, 10) }[by];
|
|
288
|
+
if (!key) throw new Error(USAGE);
|
|
289
|
+
const sums = new Map (), rows = await runsIn(args[0]);
|
|
290
|
+
for (const r of rows) sums.set(key(r), { runs: (sums.get(key(r))?.runs ?? 0) + 1, cost: (sums.get(key(r))?.cost ?? 0) + r.cost });
|
|
291
|
+
const out = [...sums].map(([k, v]) => ({ [by]: k, runs: v.runs, cost: +v.cost.toFixed(4), per_run: +(v.cost / v.runs).toFixed(4) }))
|
|
292
|
+
.sort((a, b) => by === 'day' ? a.day.localeCompare(b.day) : b.cost - a.cost);
|
|
293
|
+
if (flags.json) console.log(JSON.stringify(out, null, 1));
|
|
294
|
+
else {
|
|
295
|
+
for (const o of out) console.log(`${usd(o.cost).padStart(10)} ${String(o.runs).padStart(5)} runs ${usd(o.per_run).padStart(8)} a run ${o[by]}`);
|
|
296
|
+
console.log(`${usd(rows.reduce((t, r) => t + r.cost, 0)).padStart(10)} ${String(rows.length).padStart(5)} runs in all`);
|
|
297
|
+
}
|
|
298
|
+
} else if (cmd === 'people' && args.length <= 1) {
|
|
299
|
+
// People as lines to edit and set: ROLE EMAIL for everyone, owner|member EMAIL-OR-GROUP for a workspace, each with a comment.
|
|
300
|
+
const ws = args[0] === undefined ? undefined : (await get('/api')).find(w => w.name === args[0]);
|
|
301
|
+
if (args[0] !== undefined && !ws) throw new Error(`no workspace ${args[0]}`);
|
|
302
|
+
const last = (iso ) => iso ? `last in ${when(iso)}` : 'never in';
|
|
303
|
+
const now = ws ? (await get(`/api/workspaces/${ws.id}/people`)).map(p => ({ kind: p.as, who: p.who, note: [p.who.includes('@') ? last(p.last_login) : 'a group', p.runs.length && `runs ${p.runs.join(', ')}`] }))
|
|
304
|
+
: (await get('/users')).map(u => ({ kind: u.role, who: u.email, note: [u.disabled && 'disabled', last(u.last_login), `in ${u.workspaces.join(', ') || 'none'}`, u.outside.length && `runs outside them: ${u.outside.join(', ')}`] }));
|
|
305
|
+
if (typeof flags.set !== 'string') {
|
|
306
|
+
const w = Math.max(0, ...now.map(p => p.who.length));
|
|
307
|
+
for (const p of now) console.log(`${p.kind.padEnd(9)} ${p.who.padEnd(w)} # ${p.note.filter(Boolean).join('; ')}`);
|
|
308
|
+
if (!now.length) console.log('# no one is in it: only admins see it');
|
|
309
|
+
} else {
|
|
310
|
+
const kinds = ws ? ['owner', 'member'] : ['admin', 'publisher', 'user'];
|
|
311
|
+
const want = readFileSync(flags.set === '-' ? 0 : flags.set, 'utf8').split('\n').map(l => l.replace(/#.*/, '').trim()).filter(Boolean).map(l => l.split(/\s+/));
|
|
312
|
+
const bad = want.find(([kind, who, more]) => !kinds.includes(kind) || !who || more);
|
|
313
|
+
if (bad) throw new Error(`each line is ${kinds.join('|')} ${ws ? 'EMAIL-OR-GROUP' : 'EMAIL'}, not: ${bad.join(' ')}`);
|
|
314
|
+
const had = new Map(now.map(p => [p.who, p.kind])), wanted = new Map(want.map(([kind, who]) => [who.toLowerCase(), kind]));
|
|
315
|
+
const set = [...wanted].filter(([who, kind]) => had.get(who) !== kind), gone = ws ? [...had].filter(([who]) => !wanted.has(who)) : [];
|
|
316
|
+
const changes = [...set.map(([who, kind]) => had.has(who) ? `${who}: ${had.get(who)} -> ${kind}` : `+ ${kind} ${who}`), ...gone.map(([who, kind]) => `- ${kind} ${who}`)];
|
|
317
|
+
console.log(changes.join('\n') || 'no changes');
|
|
318
|
+
if (changes.length && !flags['dry-run']) {
|
|
319
|
+
const ofKind = (kind ) => want.filter(([k]) => k === kind).map(([, who]) => who);
|
|
320
|
+
await (ws ? post(`/api/workspaces/${ws.id}`, { owners: ofKind('owner'), members: ofKind('member'), force: !!flags.force }, 'PUT')
|
|
321
|
+
: post('/users', { roles: Object.fromEntries(set), force: !!flags.force }, 'PUT'));
|
|
322
|
+
console.log('saved');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} else if (cmd === 'brand' && !args.length) {
|
|
326
|
+
if (flags.clear || flags.text !== undefined || flags.logo !== undefined) {
|
|
327
|
+
const logo = typeof flags.logo === 'string' ? { name: basename(flags.logo), data: readFileSync(flags.logo).toString('base64') } : undefined;
|
|
328
|
+
await post('/brand', { clear: flags.clear, text: flags.text, logo }, 'PUT');
|
|
329
|
+
console.log('saved');
|
|
330
|
+
} else {
|
|
331
|
+
const { text, logo } = (await get('/me')).brand;
|
|
332
|
+
console.log(text || logo ? `${text ?? 'no text'}, ${logo ? 'with a logo' : 'no logo'}` : 'no brand: the plain look');
|
|
333
|
+
}
|
|
224
334
|
} else if (cmd === 'user' && args.length >= 2) {
|
|
225
335
|
const [what, email, role] = args, at = `/users/${encodeURIComponent(email)}`;
|
|
226
|
-
if (what === 'add') await post('/users', { email
|
|
227
|
-
else if (what === 'role' && role) await post(
|
|
336
|
+
if (what === 'add') await post('/users', { roles: { [email]: role ?? 'user' } }, 'PUT');
|
|
337
|
+
else if (what === 'role' && role) await post('/users', { roles: { [email]: role }, force: !!flags.force }, 'PUT');
|
|
228
338
|
else if (what === 'disable' || what === 'enable') await post(at, { disabled: what === 'disable' }, 'PUT');
|
|
229
339
|
else if (what === 'signout') await post(at, { signout: true }, 'PUT');
|
|
230
340
|
else if (what === 'remove') await api(at, { method: 'DELETE' });
|