@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/rf.mjs +129 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rfany/rf",
3
- "version": "0.3.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 users
28
- rf user add EMAIL [admin|publisher] | role EMAIL admin|publisher|user | disable EMAIL | enable EMAIL | signout EMAIL | remove EMAIL
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; each comment or upload starts a run of the agent, which reads it and carries on. The runner is the person whose
41
- logins the runs use, and who closes the instance once they stand by it.
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; take one up later: rf wake PATH +2h REASON.
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 file's name may hold \ and lead out of it.
110
- const under = (base , rel ) => resolve(base, rel).startsWith(resolve(base) + sep) ? resolve(base, rel) : undefined;
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) : join(resolve(dir), basename(from)), rel = (f ) => f.path.slice(from ? from.length + 1 : 0);
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 leads out of ${into}`);
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 seq = (await get(at)).log.at(-1).seq;
175
- if (!flags.wait) console.log(`commented, seq ${seq}`);
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 === 'users') {
223
- for (const u of await get('/users')) console.log([u.email, u.role, u.disabled ? 'disabled' : '', u.last_login ? `last in ${u.last_login}` : 'never in', u.workspaces.join(', ')].filter(Boolean).join('\t'));
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, role: role ?? 'user' });
227
- else if (what === 'role' && role) await post(at, { role }, 'PUT');
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' });