@rfany/rf 0.2.0 → 0.4.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 +1 -1
  2. package/package.json +1 -1
  3. package/rf.mjs +187 -50
package/README.md CHANGED
@@ -5,4 +5,4 @@ People's command line for [rollforward](https://github.com/spoj/rollforward): wh
5
5
  npm i -g @rfany/rf
6
6
  rf login https://rollforward.example.com TOKEN
7
7
 
8
- Make the token on the page, under Tokens. `rf` alone lists the commands.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rfany/rf",
3
- "version": "0.2.0",
3
+ "version": "0.4.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
@@ -1,19 +1,30 @@
1
1
  #!/usr/bin/env node
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
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
7
7
 
8
8
  const USAGE = `rf login URL TOKEN
9
9
  rf ls
10
- rf pull DIR a read-only mirror of what you can see, in the layout agents see
10
+ rf pull PATH DIR [--delete] [-n|--dry-run] [--exclude PATTERN]...
11
+ PATH's files into DIR as rsync copies them, whole, if their size or time differ: PATH/ fills DIR,
12
+ PATH makes DIR/<its name>, / is all you see; --delete removes what the server no longer has
13
+ rf help how rollforward works and how to use rf, for an agent
11
14
  rf comment PATH TEXT [--item ID] [--ref REF] [--thread T] [--wait] a new thread, or a reply in T, as to an agent's ask
12
15
  rf upload PATH FILE [--onto ITEM] [--output]
13
16
  rf wake PATH AT REASON [--label LABEL] AT now, +<n>m|h|d or a date; PATH an open instance, or a series with LABEL
14
17
  rf agents [WORKSPACE] [--set FILE|-] the company's AGENTS.md, or a workspace's; setting the company's is for admins
15
18
  rf skills
16
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
17
28
 
18
29
  Publishers and admins:
19
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
@@ -21,19 +32,73 @@ rf skill versions ID
21
32
  rf skill rollback ID N pushes version N again, as the newest
22
33
 
23
34
  Admins:
24
- rf users
25
- 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
26
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,
27
39
  such as sso.example.com or *.example.com; a run's browser on it reaches only those
28
40
  rf website remove ID`;
29
41
 
42
+ const GUIDE = `rollforward runs a team's recurring work, such as a weekly borrowing base or a month-end reconciliation, with an agent
43
+ that does each period's work and people who check it. rf does what its page does, as you.
44
+
45
+ What there is
46
+ - A workspace holds series, and its people. A series is one recurring job; an instance is one period of it, labelled such as
47
+ 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
+ - 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.
51
+ - 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
+ 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.
54
+
55
+ What rf pull copies, the folders as the agent sees them
56
+ AGENTS.md the company's conventions; <workspace>/AGENTS.md the workspace's
57
+ <workspace>/<series>/<label>/
58
+ workspace.json the screen: {rev, status, items: [{id, name, kind, file, from, headline, source}]}
59
+ 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, point and item (the screen changing), run, report (the agent's account of
61
+ a run), done (its cost, summary or error), close, and the rest
62
+ upload/ what people uploaded, their comments' attachments in upload/attachments/
63
+ working/ what the agent made, its scripts, and process.md, how it does this series
64
+ session/ the agent's own session: every message and tool call of every run
65
+ browser/ each run's browser requests, and its screenshots
66
+ The copy is read-only, and only rf changes what's on the server: comment, upload and wake.
67
+
68
+ Usual work
69
+ - What waits on me: rf ls, then the asks in that instance's log.jsonl, ask true, whose thread has nothing after them.
70
+ - Answer an ask: rf comment PATH TEXT --thread T --wait, which prints what the agent says back.
71
+ - 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.
73
+ - 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
+ every step.
75
+
76
+ Watching over it, for admins and power users; each sees what the caller sees, and takes --json
77
+ - What's stuck: rf status lists runs going, flagging those past --long MIN (30); queued runs, since when and why: their series is
78
+ 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.
80
+ - History: rf runs [PATH] [--since DATE] [--failed] [--running] [--long MIN] prints a line per run: its start, path, run number,
81
+ outcome, time taken, cost, what started it and by whom, and its summary or error. rf costs [PATH] [--since DATE] [--by
82
+ workspace|series|person|day] totals them, by the runner for person.
83
+ - People: rf people (admins) prints everyone as ROLE EMAIL lines, and rf people WORKSPACE (its owners and admins) its owners and
84
+ members as owner|member lines; a comment after each says when they last signed in and which open instances they run, and for
85
+ everyone those they run in workspaces they aren't in. Edit the lines, see the changes with --set FILE -n (FILE may be - for
86
+ stdin), then --set FILE. For everyone it sets the roles of the people listed and adds new ones, removing no one (rf user remove
87
+ does); for a workspace it replaces its people. A change that would hide an open instance from the person who runs it is refused,
88
+ naming them, unless --force: have someone who stays take those over first.`;
89
+
30
90
  const config = join(homedir(), '.config', 'rollforward.json');
31
91
  const [cmd, ...argv] = process.argv.slice(2);
32
- const flags = {}, args = [];
92
+ const flags = {}, excludes = [], args = [];
33
93
  for (let i = 0; i < argv.length; i++) {
34
- const flag = argv[i].match(/^--(.+)$/)?.[1];
94
+ const flag = argv[i] === '-n' ? 'dry-run' : argv[i].match(/^--(.+)$/)?.[1];
35
95
  if (!flag) args.push(argv[i]);
36
- else flags[flag] = flag === 'wait' || flag === 'output' ? true : argv[++i];
96
+ 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];
98
+ }
99
+ if (cmd === 'help') {
100
+ console.log(GUIDE);
101
+ process.exit();
37
102
  }
38
103
  if (cmd !== 'login' && !existsSync(config)) {
39
104
  console.error(`Sign in first, with a token made on the page under Tokens: rf login URL TOKEN\n\n${USAGE}`);
@@ -56,56 +121,70 @@ async function find(path ) {
56
121
  return { series, inst, at: inst ? `/api/instances/${inst.id}` : '' };
57
122
  }
58
123
 
59
- function write(path , data ) {
124
+ // Times as this machine's clock reads them, and lengths of time and costs, for the views.
125
+ const when = (iso ) => new Date(iso).toLocaleString('sv-SE', { dateStyle: 'short', timeStyle: 'short' });
126
+ 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')}`;
127
+ const usd = (n ) => `$${n.toFixed(2)}`;
128
+ const runsIn = async (path = '') => (await get(`/api/runs?since=${encodeURIComponent(String(flags.since ?? ''))}`))
129
+ .filter(r => !path || r.path === path.replace(/\/+$/, '') || r.path.startsWith(path.replace(/\/+$/, '') + '/'));
130
+
131
+ function write(path , data , mtime ) {
60
132
  mkdirSync(dirname(path), { recursive: true });
61
133
  rmSync(path, { force: true });
62
134
  writeFileSync(path, data);
135
+ utimesSync(path, new Date(mtime), new Date(mtime));
63
136
  chmodSync(path, 0o444);
64
137
  }
65
138
 
66
139
  // A path the server named, under `base`, or undefined: on Windows a file's name may hold \ and lead out of it.
67
140
  const under = (base , rel ) => resolve(base, rel).startsWith(resolve(base) + sep) ? resolve(base, rel) : undefined;
68
141
 
69
- // Pulls what changed since the last pull, by each instance's files' sizes and times, and removes what moved or went. A file that fails
70
- // is said and fetched again next time.
71
- async function pull(dir ) {
72
- const manifest = join(dir, '.rf-pull.json'), had = existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : {};
73
- const now = {}, tree = await get('/api');
74
- write(join(dir, 'AGENTS.md'), (await get('/agents')).text);
75
- for (const w of tree) {
76
- write(join(dir, w.name, 'AGENTS.md'), w.agents);
77
- for (const s of w.series) for (const i of s.instances) now[i.id] = { path: join(w.name, s.name, i.label), files: {} };
78
- }
79
- for (const [id, old] of Object.entries (had)) if (now[id]?.path !== old.path) rmSync(join(dir, old.path), { recursive: true, force: true });
80
- let fetched = 0;
81
- for (const [id, mine] of Object.entries(now)) {
82
- const at = `/api/instances/${id}`, d = await get(at), was = had[id]?.path === mine.path ? had[id].files : {};
83
- write(join(dir, mine.path, 'screen.json'), JSON.stringify(d.doc, null, 1));
84
- write(join(dir, mine.path, 'log.jsonl'), d.log.map(e => JSON.stringify(e)).join('\n') + '\n');
85
- write(join(dir, mine.path, 'trace.json'), JSON.stringify(await get(`${at}/trace`), null, 1));
86
- for (const f of await get(`${at}/files`)) {
87
- const path = under(join(dir, mine.path), f.path);
88
- if (!path) {
89
- console.error(`skipped ${mine.path}/${f.path}: it leads out of its folder`);
90
- continue;
91
- }
92
- mine.files[f.path] = `${f.size} ${f.mtime}`;
93
- if (was[f.path] === mine.files[f.path]) continue;
94
- try {
95
- write(path, new Uint8Array(await (await api(`/files/${id}/${f.path.split('/').map(encodeURIComponent).join('/')}`)).arrayBuffer()));
96
- fetched++;
97
- } catch (e) {
98
- mine.files[f.path] = '';
99
- console.error(`${mine.path}/${f.path}: ${(e ).message}`);
100
- }
142
+ // 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
+ // stop at /, ** doesn't. A folder it matches takes what's in it along.
144
+ const globs = excludes.map(p => [p.replace(/^\/|\/$/g, '').includes('/'), new RegExp(`^${p.replace(/^\/|\/$/g, '').replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\0/g, '.*')}$`)] );
145
+ const excluded = (rel ) => globs.some(([whole, re]) => rel.split('/').some((name, k, all) => re.test(whole ? all.slice(0, k + 1).join('/') : name)));
146
+
147
+ // Copies PATH's files into DIR as rsync does, from the server only and whole: a file whose size or time differ from the server's is
148
+ // fetched, and given the server's time, so the next pull compares against it. Nothing is kept besides the files.
149
+ async function pull(path , dir ) {
150
+ const at = path.replace(/^\/+/, ''), from = at.replace(/\/+$/, ''), dry = !!flags['dry-run'];
151
+ const list = await get(`/api/files?path=${encodeURIComponent(from)}`);
152
+ 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);
154
+ let fetched = 0, same = 0, deleted = 0, failed = 0;
155
+ for (const f of list.filter(f => !excluded(rel(f)))) {
156
+ const local = rel(f) ? under(into, rel(f)) : into, st = local && existsSync(local) ? statSync(local) : undefined;
157
+ if (!local) {
158
+ console.error(`skipped ${f.path}: it leads out of ${into}`);
159
+ continue;
160
+ }
161
+ if (st && st.size === f.size && Math.floor(st.mtimeMs / 1000) === Math.floor(Date.parse(f.mtime) / 1000)) {
162
+ same++;
163
+ continue;
101
164
  }
102
- for (const gone of Object.keys(was).filter(p => !(p in mine.files))) {
103
- const path = under(join(dir, mine.path), gone);
104
- if (path) rmSync(path, { force: true });
165
+ console.log(`${st ? 'changed' : 'new'} ${f.path}`);
166
+ try {
167
+ if (!dry) write(local, f.text ?? new Uint8Array(await (await api(f.url )).arrayBuffer()), f.mtime);
168
+ fetched++;
169
+ } catch (e) {
170
+ failed++;
171
+ console.error(`${f.path}: ${(e ).message}`);
105
172
  }
106
173
  }
107
- writeFileSync(manifest, JSON.stringify(now));
108
- console.log(`${Object.keys(now).length} instances, ${fetched} files fetched, into ${dir}`);
174
+ if (flags.delete && list.some(rel) && existsSync(into)) {
175
+ const keep = new Set(list.map(rel)), mine = (d ) => readdirSync(d, { withFileTypes: true }).flatMap(e => e.isDirectory() ? mine(join(d, e.name)) : [join(d, e.name)]);
176
+ for (const p of mine(into)) {
177
+ const r = relative(into, p).split(sep).join('/');
178
+ if (keep.has(r) || excluded(r)) continue;
179
+ console.log(`deleted ${r}`);
180
+ deleted++;
181
+ if (!dry) rmSync(p, { force: true });
182
+ }
183
+ const prune = (d ) => { for (const e of readdirSync(d, { withFileTypes: true })) if (e.isDirectory()) prune(join(d, e.name)); if (d !== into && !readdirSync(d).length) rmdirSync(d); };
184
+ if (!dry) prune(into);
185
+ }
186
+ console.log(`${dry ? 'would fetch' : 'fetched'} ${fetched}, ${same} up to date${flags.delete ? `, ${dry ? 'would delete' : 'deleted'} ${deleted}` : ''}${failed ? `, ${failed} failed` : ''}, in ${into}`);
187
+ if (failed) process.exitCode = 1;
109
188
  }
110
189
 
111
190
  if (cmd === 'login' && url && token) {
@@ -118,7 +197,7 @@ if (cmd === 'login' && url && token) {
118
197
  console.log(`${w.name}/${s.name}${s.wake ? ` wakes ${s.wake.at} to start ${s.wake.label}: ${s.wake.reason}` : ''}${s.requests.length ? ` ${s.requests.length} asking to start` : ''}`);
119
198
  for (const i of s.instances) console.log(`${w.name}/${s.name}/${i.label} ${i.status}${i.running ? ', running' : ''}${i.asks.length ? `, ${i.asks.length} asks waiting` : ''}${i.wake ? `, wakes ${i.wake}` : ''}, runner ${i.runner}`);
120
199
  }
121
- } else if (cmd === 'pull' && args[0]) await pull(args[0]);
200
+ } else if (cmd === 'pull' && args.length === 2) await pull(args[0], args[1]);
122
201
  else if (cmd === 'comment' && args.length === 2) {
123
202
  const { at } = await find(args[0]);
124
203
  await post(`${at}/comment`, { text: args[1], item: flags.item, ref: flags.ref, thread: flags.thread });
@@ -170,12 +249,70 @@ else if (cmd === 'comment' && args.length === 2) {
170
249
  } else if (cmd === 'website' && args[0] === 'remove' && args[1]) {
171
250
  await api(`/websites/${encodeURIComponent(args[1])}`, { method: 'DELETE' });
172
251
  console.log('removed');
173
- } else if (cmd === 'users') {
174
- 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'));
252
+ } else if (cmd === 'status' && !args.length) {
253
+ const s = await get('/api/status'), long = Number(flags.long ?? 30);
254
+ if (flags.json) console.log(JSON.stringify(s, null, 1));
255
+ else {
256
+ const sections = [
257
+ [`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
+ [`Queued`, s.queued.map(q => `${q.path} since ${when(q.since)}: ${q.why}`)],
259
+ [`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)}`),
261
+ ...s.expired.map(e => `${e.who} their ${e.login} login expired${e.holding.length ? `, holding ${e.holding.join(', ')}` : ''}`),
262
+ ...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
+ ]],
265
+ [`Last run failed`, s.failed.map(f => `${f.path} run ${f.run} at ${when(f.at)}: ${f.error}`)],
266
+ ].filter(([, rows]) => rows.length);
267
+ 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.');
268
+ }
269
+ } else if (cmd === 'runs' && args.length <= 1) {
270
+ 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));
271
+ if (flags.json) console.log(JSON.stringify(rows, null, 1));
272
+ 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 ?? ''}`);
273
+ } else if (cmd === 'costs' && args.length <= 1) {
274
+ 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];
275
+ if (!key) throw new Error(USAGE);
276
+ const sums = new Map (), rows = await runsIn(args[0]);
277
+ 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 });
278
+ const out = [...sums].map(([k, v]) => ({ [by]: k, runs: v.runs, cost: +v.cost.toFixed(4), per_run: +(v.cost / v.runs).toFixed(4) }))
279
+ .sort((a, b) => by === 'day' ? a.day.localeCompare(b.day) : b.cost - a.cost);
280
+ if (flags.json) console.log(JSON.stringify(out, null, 1));
281
+ else {
282
+ 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]}`);
283
+ console.log(`${usd(rows.reduce((t, r) => t + r.cost, 0)).padStart(10)} ${String(rows.length).padStart(5)} runs in all`);
284
+ }
285
+ } else if (cmd === 'people' && args.length <= 1) {
286
+ // People as lines to edit and set: ROLE EMAIL for everyone, owner|member EMAIL-OR-GROUP for a workspace, each with a comment.
287
+ const ws = args[0] === undefined ? undefined : (await get('/api')).find(w => w.name === args[0]);
288
+ if (args[0] !== undefined && !ws) throw new Error(`no workspace ${args[0]}`);
289
+ const last = (iso ) => iso ? `last in ${when(iso)}` : 'never in';
290
+ 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(', ')}`] }))
291
+ : (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(', ')}`] }));
292
+ if (typeof flags.set !== 'string') {
293
+ const w = Math.max(0, ...now.map(p => p.who.length));
294
+ for (const p of now) console.log(`${p.kind.padEnd(9)} ${p.who.padEnd(w)} # ${p.note.filter(Boolean).join('; ')}`);
295
+ if (!now.length) console.log('# no one is in it: only admins see it');
296
+ } else {
297
+ const kinds = ws ? ['owner', 'member'] : ['admin', 'publisher', 'user'];
298
+ const want = readFileSync(flags.set === '-' ? 0 : flags.set, 'utf8').split('\n').map(l => l.replace(/#.*/, '').trim()).filter(Boolean).map(l => l.split(/\s+/));
299
+ const bad = want.find(([kind, who, more]) => !kinds.includes(kind) || !who || more);
300
+ if (bad) throw new Error(`each line is ${kinds.join('|')} ${ws ? 'EMAIL-OR-GROUP' : 'EMAIL'}, not: ${bad.join(' ')}`);
301
+ const had = new Map(now.map(p => [p.who, p.kind])), wanted = new Map(want.map(([kind, who]) => [who.toLowerCase(), kind]));
302
+ const set = [...wanted].filter(([who, kind]) => had.get(who) !== kind), gone = ws ? [...had].filter(([who]) => !wanted.has(who)) : [];
303
+ const changes = [...set.map(([who, kind]) => had.has(who) ? `${who}: ${had.get(who)} -> ${kind}` : `+ ${kind} ${who}`), ...gone.map(([who, kind]) => `- ${kind} ${who}`)];
304
+ console.log(changes.join('\n') || 'no changes');
305
+ if (changes.length && !flags['dry-run']) {
306
+ const ofKind = (kind ) => want.filter(([k]) => k === kind).map(([, who]) => who);
307
+ await (ws ? post(`/api/workspaces/${ws.id}`, { owners: ofKind('owner'), members: ofKind('member'), force: !!flags.force }, 'PUT')
308
+ : post('/users', { roles: Object.fromEntries(set), force: !!flags.force }, 'PUT'));
309
+ console.log('saved');
310
+ }
311
+ }
175
312
  } else if (cmd === 'user' && args.length >= 2) {
176
313
  const [what, email, role] = args, at = `/users/${encodeURIComponent(email)}`;
177
- if (what === 'add') await post('/users', { email, role: role ?? 'user' });
178
- else if (what === 'role' && role) await post(at, { role }, 'PUT');
314
+ if (what === 'add') await post('/users', { roles: { [email]: role ?? 'user' } }, 'PUT');
315
+ else if (what === 'role' && role) await post('/users', { roles: { [email]: role }, force: !!flags.force }, 'PUT');
179
316
  else if (what === 'disable' || what === 'enable') await post(at, { disabled: what === 'disable' }, 'PUT');
180
317
  else if (what === 'signout') await post(at, { signout: true }, 'PUT');
181
318
  else if (what === 'remove') await api(at, { method: 'DELETE' });