@rfany/rf 0.4.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 +35 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rfany/rf",
3
- "version": "0.4.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
@@ -37,7 +37,10 @@ rf people [--set FILE|-] [-n] [--force] everyone as lines of ROLE EMAIL, each
37
37
  rf user add EMAIL [admin|publisher] | role EMAIL admin|publisher|user [--force] | disable EMAIL | enable EMAIL | signout EMAIL | remove EMAIL
38
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,
39
39
  such as sso.example.com or *.example.com; a run's browser on it reaches only those
40
- 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`;
41
44
 
42
45
  const GUIDE = `rollforward runs a team's recurring work, such as a weekly borrowing base or a month-end reconciliation, with an agent
43
46
  that does each period's work and people who check it. rf does what its page does, as you.
@@ -46,8 +49,9 @@ What there is
46
49
  - A workspace holds series, and its people. A series is one recurring job; an instance is one period of it, labelled such as
47
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.
48
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
49
- 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
50
- 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.
51
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
52
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
53
57
  asks wait in each instance.
@@ -69,14 +73,15 @@ Usual work
69
73
  - What waits on me: rf ls, then the asks in that instance's log.jsonl, ask true, whose thread has nothing after them.
70
74
  - Answer an ask: rf comment PATH TEXT --thread T --wait, which prints what the agent says back.
71
75
  - 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; 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.
73
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
74
79
  every step.
75
80
 
76
81
  Watching over it, for admins and power users; each sees what the caller sees, and takes --json
77
82
  - What's stuck: rf status lists runs going, flagging those past --long MIN (30); queued runs, since when and why: their series is
78
83
  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, and shared series with nothing open and no wake set; and open instances whose last run failed.
84
+ they hold, and requests to start; and open instances whose last run failed.
80
85
  - History: rf runs [PATH] [--since DATE] [--failed] [--running] [--long MIN] prints a line per run: its start, path, run number,
81
86
  outcome, time taken, cost, what started it and by whom, and its summary or error. rf costs [PATH] [--since DATE] [--by
82
87
  workspace|series|person|day] totals them, by the runner for person.
@@ -94,7 +99,7 @@ for (let i = 0; i < argv.length; i++) {
94
99
  const flag = argv[i] === '-n' ? 'dry-run' : argv[i].match(/^--(.+)$/)?.[1];
95
100
  if (!flag) args.push(argv[i]);
96
101
  else if (flag === 'exclude') excludes.push(argv[++i]);
97
- else flags[flag] = ['wait', 'output', 'delete', 'dry-run', 'force', 'json', 'failed', 'running'].includes(flag) ? true : argv[++i];
102
+ else flags[flag] = ['wait', 'output', 'delete', 'dry-run', 'force', 'json', 'failed', 'running', 'clear'].includes(flag) ? true : argv[++i];
98
103
  }
99
104
  if (cmd === 'help') {
100
105
  console.log(GUIDE);
@@ -136,8 +141,15 @@ function write(path , data , mtime ) {
136
141
  chmodSync(path, 0o444);
137
142
  }
138
143
 
139
- // A path the server named, under `base`, or undefined: on Windows a file's name may hold \ and lead out of it.
140
- 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
+ };
141
153
 
142
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 ?
143
155
  // stop at /, ** doesn't. A folder it matches takes what's in it along.
@@ -150,12 +162,13 @@ async function pull(path , dir ) {
150
162
  const at = path.replace(/^\/+/, ''), from = at.replace(/\/+$/, ''), dry = !!flags['dry-run'];
151
163
  const list = await get(`/api/files?path=${encodeURIComponent(from)}`);
152
164
  if (!list.length) throw new Error(`nothing you can see at ${path}`);
153
- 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`);
154
167
  let fetched = 0, same = 0, deleted = 0, failed = 0;
155
168
  for (const f of list.filter(f => !excluded(rel(f)))) {
156
169
  const local = rel(f) ? under(into, rel(f)) : into, st = local && existsSync(local) ? statSync(local) : undefined;
157
170
  if (!local) {
158
- 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`);
159
172
  continue;
160
173
  }
161
174
  if (st && st.size === f.size && Math.floor(st.mtimeMs / 1000) === Math.floor(Date.parse(f.mtime) / 1000)) {
@@ -201,8 +214,9 @@ if (cmd === 'login' && url && token) {
201
214
  else if (cmd === 'comment' && args.length === 2) {
202
215
  const { at } = await find(args[0]);
203
216
  await post(`${at}/comment`, { text: args[1], item: flags.item, ref: flags.ref, thread: flags.thread });
204
- const seq = (await get(at)).log.at(-1).seq;
205
- 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}`);
206
220
  else for (;;) {
207
221
  // The run that saw the comment, and what it said from then on: a run ending as it came may not have.
208
222
  await new Promise(r => setTimeout(r, 5000));
@@ -260,7 +274,6 @@ else if (cmd === 'comment' && args.length === 2) {
260
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)}`),
261
275
  ...s.expired.map(e => `${e.who} their ${e.login} login expired${e.holding.length ? `, holding ${e.holding.join(', ')}` : ''}`),
262
276
  ...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
277
  ]],
265
278
  [`Last run failed`, s.failed.map(f => `${f.path} run ${f.run} at ${when(f.at)}: ${f.error}`)],
266
279
  ].filter(([, rows]) => rows.length);
@@ -309,6 +322,15 @@ else if (cmd === 'comment' && args.length === 2) {
309
322
  console.log('saved');
310
323
  }
311
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
+ }
312
334
  } else if (cmd === 'user' && args.length >= 2) {
313
335
  const [what, email, role] = args, at = `/users/${encodeURIComponent(email)}`;
314
336
  if (what === 'add') await post('/users', { roles: { [email]: role ?? 'user' } }, 'PUT');