@sudosapient/cli 0.0.0-stage → 0.2.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 +110 -2
- package/bin/sudo-cli.js +4 -0
- package/package.json +28 -3
- package/src/cli.js +511 -0
- package/src/commands.js +1202 -0
package/README.md
CHANGED
|
@@ -1,3 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sudosapient/cli (`sudo-cli`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sudo Sapient from the terminal: everything you can do in the app, from capturing a task to sharing a
|
|
4
|
+
project, uploading a file or managing people, with `--json` for scripts. It talks only to the public API (`/api/v1`) with a personal API key, so it can do exactly
|
|
5
|
+
what you can do in the app, and its changes show in Activity as "you via CLI".
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
With Node 22 or later, run it straight from npm, or install it to get the `sudo-cli` command:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @sudosapient/cli login --url https://sudo.example.com
|
|
13
|
+
npm install -g @sudosapient/cli # then: sudo-cli …
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Mind the scope: the unscoped `sudo-cli` package on npm is **someone else's**; don't `npx sudo-cli`.
|
|
17
|
+
|
|
18
|
+
From a checkout of this repo instead:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm install
|
|
22
|
+
ln -s "$PWD/packages/cli/bin/sudo-cli.js" ~/.local/bin/sudo-cli # any directory on your PATH
|
|
23
|
+
# or run it in place: node packages/cli/bin/sudo-cli.js …
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Log in
|
|
27
|
+
|
|
28
|
+
Create a key in **Settings → API** (Read and write, unless you only want to look), then:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
sudo-cli login --url https://sudo.example.com # paste the key when asked
|
|
32
|
+
sudo-cli whoami
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Give the key at the prompt (it isn't echoed) or pipe it in (`printf '%s\n%s\n' "$URL" "$KEY" | sudo-cli
|
|
36
|
+
login`), or set `SUDO_API_KEY`. `--key` works too, but the key then lands in your shell history and
|
|
37
|
+
in the process list where other users of the machine can see it, so `sudo-cli` warns when it's used.
|
|
38
|
+
|
|
39
|
+
The URL and key are saved to `$XDG_CONFIG_HOME/sudo-sapient/config.json` (default `~/.config`), readable
|
|
40
|
+
only by you (the directory is kept owner-only too). `SUDO_URL` and `SUDO_API_KEY` override it (handy
|
|
41
|
+
in CI). `sudo-cli logout` deletes it and, when you answer yes (or pass `--revoke`), also revokes the
|
|
42
|
+
key on the server so it stops working everywhere; otherwise revoke it in Settings → API.
|
|
43
|
+
|
|
44
|
+
The key travels with every request, so `sudo-cli` refuses a plain `http://` URL unless it's this
|
|
45
|
+
machine (`localhost`, `127.0.0.1`). For a workspace you really reach over plain http, add
|
|
46
|
+
`--insecure`; `login --insecure` remembers that for the saved URL.
|
|
47
|
+
|
|
48
|
+
## Use
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
sudo-cli new "Fix login bug" -p WEB --due friday -a me --priority high # quick capture
|
|
52
|
+
sudo-cli tasks # my open tasks, newest first
|
|
53
|
+
sudo-cli tasks -p WEB --all # every task in WEB, done ones too
|
|
54
|
+
sudo-cli show WEB-212
|
|
55
|
+
sudo-cli comment WEB-212 "Repro'd on Safari"
|
|
56
|
+
sudo-cli assign WEB-212 grace@example.com # me, a name, an email, or none
|
|
57
|
+
sudo-cli done WEB-212 WEB-213
|
|
58
|
+
sudo-cli projects
|
|
59
|
+
sudo-cli open WEB-212 # in the browser (--print just prints the URL)
|
|
60
|
+
sudo-cli tasks --json | jq '.[].key'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Everything else
|
|
64
|
+
|
|
65
|
+
`sudo-cli help <command>` lists a command's subcommands and options (or `sudo-cli <command> --help`).
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
sudo-cli tasks edit WEB-212 --status review --label bug,frontend --repeat weekly --depends WEB-200
|
|
69
|
+
sudo-cli tasks archive WEB-212 · tasks restore · tasks dup · tasks fav · tasks rm · tasks undelete <id>
|
|
70
|
+
sudo-cli subtask add WEB-212 "Write tests" -a grace # ids come from sudo-cli show WEB-212
|
|
71
|
+
sudo-cli subtask done WEB-212 <id> · subtask edit · subtask rm · subtask promote
|
|
72
|
+
sudo-cli comment edit <id> "…" · comment rm <id> · comment undelete <id> · react <id> 👍 · unreact <id> 👍
|
|
73
|
+
sudo-cli projects new "Mobile App" --prefix MOB --private --members grace --perm edit
|
|
74
|
+
sudo-cli projects edit MOB --status risk --due 2026-12-01 --milestone "Beta=2026-11-20"
|
|
75
|
+
sudo-cli projects share MOB grace@example.com --perm comment · projects unshare MOB grace
|
|
76
|
+
sudo-cli projects show · archive · restore · dup · rm · request-access · fav · order WEB MOB
|
|
77
|
+
sudo-cli teams new Platform --members grace,ada · teams edit · teams rm
|
|
78
|
+
sudo-cli people invite cara@example.com --role Guest · people resend · people edit · people role grace Admin · people rm
|
|
79
|
+
sudo-cli files upload ./spec.pdf --task WEB-212 · files download <id> [-o path|-] · files attach <id> WEB-212
|
|
80
|
+
sudo-cli files rename · detach · dup · rm
|
|
81
|
+
sudo-cli events new "Launch" --date 2026-12-01 --time 09:30 -p WEB · events --from today --to +30d
|
|
82
|
+
sudo-cli views new "Urgent" -p WEB --type board --filter priority=urgent,high --filter assignee!=me
|
|
83
|
+
sudo-cli inbox --unread · inbox read <id> · inbox read --all · activity -p WEB · search "login"
|
|
84
|
+
sudo-cli me edit --title Engineer · prefs set theme=dark tz=Europe/Berlin · notify-prefs set email_comment=on
|
|
85
|
+
sudo-cli workspace · favorites
|
|
86
|
+
sudo-cli api GET /tasks -f project=WEB # any endpoint; -f sends strings, -F typed values, --data JSON
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- **Admin commands** (`people role`, `people rm`, others' names and titles, `workspace edit`,
|
|
90
|
+
`workspace invite-link`, `bots …`) work when the key's person is an Owner or Admin, as in the app.
|
|
91
|
+
For anyone else they fail with "Only owners and admins can do this".
|
|
92
|
+
- **Deleting** a project, person, bot, team or file asks first; `--yes` (`-y`) skips the question,
|
|
93
|
+
and without a terminal to ask at (a script) `--yes` is required. Tasks and comments don't ask:
|
|
94
|
+
`tasks rm` and `comment rm` print how to undo them, which works for 10 minutes.
|
|
95
|
+
- **Downloads** go to the file's own name in the current directory (reduced to a plain file name), or
|
|
96
|
+
`-o <path>`, or `-o -` for stdout. An existing file is never overwritten without `--force`.
|
|
97
|
+
- Bot keys are made in Settings → API → Bots only; `bots` can list, change, remove and revoke them.
|
|
98
|
+
- `none` clears a field: `--due none`, `--team none`, `--repeat none`, `--label none`.
|
|
99
|
+
|
|
100
|
+
Due dates: `today`, `tomorrow`, a weekday (`friday`, `fri`; today counts), `next week`, `+3d`, `+2w`, or
|
|
101
|
+
`2026-11-20`. Set `SUDO_PROJECT=WEB` to skip `-p`. Exit codes: `0` ok, `1` the API refused (the
|
|
102
|
+
message says why; with `--json` it's `{ error, code }` on stderr), `2` a usage error.
|
|
103
|
+
|
|
104
|
+
Your keys share one rate limit (120 requests a minute by default). When a bulk run reaches it,
|
|
105
|
+
`sudo-cli` waits until the server says it may go on (`rate limited; retrying in 53 s…` on stderr) and
|
|
106
|
+
then carries on, up to 3 times per request.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
UNLICENSED, all rights reserved. The package is public on npm so anyone can install and run it, but
|
|
111
|
+
no license to copy, modify or redistribute the code is granted.
|
package/bin/sudo-cli.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sudosapient/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Sudo Sapient from the terminal: capture, list, assign and complete tasks.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/sudosapient-labs/project-tracker.git",
|
|
8
|
+
"directory": "packages/cli"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/sudosapient-labs/project-tracker/tree/prod/packages/cli#readme",
|
|
11
|
+
"bugs": "https://github.com/sudosapient-labs/project-tracker/issues",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"sudo-cli": "./bin/sudo-cli.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@sudosapient/client": "0.2.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"license": "UNLICENSED",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
}
|
|
6
31
|
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/* sudo-cli: Sudo Sapient from the terminal, over the public API (/api/v1) with a personal key.
|
|
2
|
+
`run(argv, io)` returns the exit code; io carries env, output streams and fetch, so tests can run
|
|
3
|
+
it without a terminal or a network. */
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { ApiError, createClient, plainHttpRemote, seg } from '@sudosapient/client';
|
|
9
|
+
import { clearConfig, configPath, loadConfig, saveConfig } from '@sudosapient/client/config';
|
|
10
|
+
import { ALIASES, NOUNS, PRIORITIES, STATUSES, nounHelp, rawApi, reaction } from './commands.js';
|
|
11
|
+
|
|
12
|
+
// Kept equal to package.json by release-please (extra-files in release-please-config.json).
|
|
13
|
+
export const VERSION = '0.2.0'; // x-release-please-version
|
|
14
|
+
|
|
15
|
+
export const HELP = `sudo-cli: Sudo Sapient from the terminal
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
sudo-cli login [--url <workspace url>] asks for the key (or pipe it in)
|
|
19
|
+
sudo-cli logout [--revoke] --revoke: also revoke the key on the server
|
|
20
|
+
sudo-cli whoami
|
|
21
|
+
sudo-cli tasks [list] [--mine] [-p <project>] [-s <status>] [--all] [-n <limit>]
|
|
22
|
+
sudo-cli tasks new "<title>" -p <project> [--due <date>] [-a <who>] [--priority <p>] [-d <text>]
|
|
23
|
+
sudo-cli tasks done <KEY>...
|
|
24
|
+
sudo-cli tasks assign <KEY> <who> who: me, a name, an email, or "none"
|
|
25
|
+
sudo-cli tasks show <KEY>
|
|
26
|
+
sudo-cli comment <KEY> "<text>"
|
|
27
|
+
sudo-cli projects [list]
|
|
28
|
+
sudo-cli open <KEY> opens a task or project in the browser (--print: just the URL)
|
|
29
|
+
sudo-cli search "<text>"
|
|
30
|
+
|
|
31
|
+
More (sudo-cli help <command> lists each one's subcommands)
|
|
32
|
+
tasks edit, rm, undelete, archive, restore, dup, fav, unfav
|
|
33
|
+
subtask add, done, undo, edit, rm, promote
|
|
34
|
+
comment edit, rm, undelete; react / unreact <comment id> <emoji>
|
|
35
|
+
projects show, new, edit, archive, restore, dup, rm, share, unshare, request-access, fav, unfav, order
|
|
36
|
+
teams, people, bots, files, events, views, inbox, activity, workspace, me, prefs, notify-prefs, favorites
|
|
37
|
+
api <METHOD> <path> [--data <json>] [-f key=value]... [-F key=value]... any endpoint, JSON out
|
|
38
|
+
|
|
39
|
+
Shortcuts: new, done, assign, show (= tasks …). Dates: today, tomorrow, friday, +3d, +2w, 2026-11-20.
|
|
40
|
+
Deleting a project, person, bot, team or file asks first (--yes to skip; required without a
|
|
41
|
+
terminal). Deleted tasks and comments can be undeleted for 10 minutes, so they don't ask.
|
|
42
|
+
Options: --json (machine-readable output), --url, --insecure (allow a plain http:// URL to another
|
|
43
|
+
machine; login remembers it). The key comes from login, else SUDO_API_KEY; --key works too, but it
|
|
44
|
+
lands in your shell history and the process list, so prefer the prompt or the variable.
|
|
45
|
+
Create a key in Settings → API. Config: ${'$'}XDG_CONFIG_HOME/sudo-sapient/config.json.`;
|
|
46
|
+
|
|
47
|
+
const OPTIONS = {
|
|
48
|
+
json: { type: 'boolean' },
|
|
49
|
+
url: { type: 'string' },
|
|
50
|
+
key: { type: 'string' },
|
|
51
|
+
insecure: { type: 'boolean' },
|
|
52
|
+
revoke: { type: 'boolean' },
|
|
53
|
+
project: { type: 'string', short: 'p' },
|
|
54
|
+
status: { type: 'string', short: 's' },
|
|
55
|
+
assignee: { type: 'string', short: 'a' },
|
|
56
|
+
due: { type: 'string' },
|
|
57
|
+
priority: { type: 'string' },
|
|
58
|
+
desc: { type: 'string', short: 'd' },
|
|
59
|
+
limit: { type: 'string', short: 'n' },
|
|
60
|
+
mine: { type: 'boolean' },
|
|
61
|
+
all: { type: 'boolean' },
|
|
62
|
+
print: { type: 'boolean' },
|
|
63
|
+
archived: { type: 'boolean' },
|
|
64
|
+
title: { type: 'string' },
|
|
65
|
+
start: { type: 'string' },
|
|
66
|
+
label: { type: 'string', multiple: true },
|
|
67
|
+
repeat: { type: 'string' },
|
|
68
|
+
estimate: { type: 'string' },
|
|
69
|
+
depends: { type: 'string' },
|
|
70
|
+
order: { type: 'string' },
|
|
71
|
+
yes: { type: 'boolean', short: 'y' },
|
|
72
|
+
force: { type: 'boolean' },
|
|
73
|
+
output: { type: 'string', short: 'o' },
|
|
74
|
+
task: { type: 'string' },
|
|
75
|
+
perm: { type: 'string' },
|
|
76
|
+
name: { type: 'string' },
|
|
77
|
+
lead: { type: 'string' },
|
|
78
|
+
team: { type: 'string' },
|
|
79
|
+
private: { type: 'boolean' },
|
|
80
|
+
public: { type: 'boolean' },
|
|
81
|
+
milestone: { type: 'string', multiple: true },
|
|
82
|
+
members: { type: 'string' },
|
|
83
|
+
projects: { type: 'string' },
|
|
84
|
+
role: { type: 'string' },
|
|
85
|
+
color: { type: 'string' },
|
|
86
|
+
icon: { type: 'string' },
|
|
87
|
+
prefix: { type: 'string' },
|
|
88
|
+
unread: { type: 'boolean' },
|
|
89
|
+
from: { type: 'string' },
|
|
90
|
+
to: { type: 'string' },
|
|
91
|
+
date: { type: 'string' },
|
|
92
|
+
time: { type: 'string' },
|
|
93
|
+
type: { type: 'string' },
|
|
94
|
+
filter: { type: 'string', multiple: true },
|
|
95
|
+
brand: { type: 'string' },
|
|
96
|
+
data: { type: 'string' },
|
|
97
|
+
field: { type: 'string', short: 'F', multiple: true },
|
|
98
|
+
'raw-field': { type: 'string', short: 'f', multiple: true },
|
|
99
|
+
help: { type: 'boolean', short: 'h' },
|
|
100
|
+
version: { type: 'boolean', short: 'v' },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/* Everyday dates to YYYY-MM-DD, in local time. A weekday is its next occurrence (today counts). */
|
|
104
|
+
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
105
|
+
export function parseDue(s, now = new Date()) {
|
|
106
|
+
const v = String(s).trim().toLowerCase();
|
|
107
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
|
|
108
|
+
const inDays = n => {
|
|
109
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n);
|
|
110
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
111
|
+
};
|
|
112
|
+
if (v === 'today') return inDays(0);
|
|
113
|
+
if (v === 'tomorrow') return inDays(1);
|
|
114
|
+
const rel = /^\+?(\d{1,3})\s*(d|w)$/.exec(v);
|
|
115
|
+
if (rel) return inDays(Number(rel[1]) * (rel[2] === 'w' ? 7 : 1));
|
|
116
|
+
if (v === 'next week') return inDays((8 - now.getDay()) % 7 || 7);
|
|
117
|
+
const day = DAYS.findIndex(n => n === v || n.slice(0, 3) === v);
|
|
118
|
+
if (day >= 0) return inDays((day - now.getDay() + 7) % 7);
|
|
119
|
+
throw new UsageError(`Can't read the date “${s}”. Try today, tomorrow, friday, +3d or 2026-11-20.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class UsageError extends Error {}
|
|
123
|
+
|
|
124
|
+
/* `link` is on the same site as the workspace (scheme, host and port), so nothing the server sends
|
|
125
|
+
can make the CLI open some other page or program. */
|
|
126
|
+
export function sameOrigin(link, workspace) {
|
|
127
|
+
try {
|
|
128
|
+
return new URL(link).origin === new URL(workspace).origin;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Text from the workspace is printed to a terminal: drop control characters (escape sequences could
|
|
135
|
+
rewrite the screen or the clipboard), keeping newlines and tabs. */
|
|
136
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
137
|
+
const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
138
|
+
export const clean = v =>
|
|
139
|
+
typeof v === 'string'
|
|
140
|
+
? v.replace(CONTROL, '')
|
|
141
|
+
: Array.isArray(v)
|
|
142
|
+
? v.map(clean)
|
|
143
|
+
: v && typeof v === 'object'
|
|
144
|
+
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clean(x)]))
|
|
145
|
+
: v;
|
|
146
|
+
|
|
147
|
+
export async function run(argv, io = {}) {
|
|
148
|
+
const env = io.env || process.env;
|
|
149
|
+
const out = io.stdout || process.stdout;
|
|
150
|
+
const err = io.stderr || process.stderr;
|
|
151
|
+
const print = s => out.write(s + '\n');
|
|
152
|
+
const color = !env.NO_COLOR && out.isTTY;
|
|
153
|
+
const dim = s => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
154
|
+
const bold = s => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
155
|
+
|
|
156
|
+
let o, pos;
|
|
157
|
+
try {
|
|
158
|
+
({ values: o, positionals: pos } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true }));
|
|
159
|
+
} catch (e) {
|
|
160
|
+
err.write(`sudo-cli: ${e.message}\nRun sudo-cli --help for usage.\n`);
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
const noun = n => (NOUNS[n] ? n : ALIASES[n]);
|
|
164
|
+
// `sudo-cli help tasks` and `sudo-cli tasks --help` list a command's subcommands.
|
|
165
|
+
if (pos[0] === 'help' || (o.help && noun(pos[0]))) {
|
|
166
|
+
const n = noun(pos[0] === 'help' ? pos[1] : pos[0]);
|
|
167
|
+
print(n ? nounHelp(n) : HELP);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
if (o.version || o.help || !pos.length) {
|
|
171
|
+
print(o.version ? VERSION : HELP);
|
|
172
|
+
return o.version || o.help ? 0 : 2;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// `tasks new …` and `new …` are the same command. Subcommands with a handler in commands.js
|
|
176
|
+
// (`tasks edit`, `projects share`, `teams`, …) run from its table.
|
|
177
|
+
let [cmd, ...args] = pos;
|
|
178
|
+
let table = null;
|
|
179
|
+
const verbOf = (n, v) => (NOUNS[n].verbs[v]?.run ? NOUNS[n].verbs[v] : null);
|
|
180
|
+
const n0 = noun(cmd);
|
|
181
|
+
if (n0 === 'tasks') {
|
|
182
|
+
const sub = args[0];
|
|
183
|
+
if (verbOf('tasks', sub)) table = { noun: 'tasks', verb: sub, args: args.slice(1) };
|
|
184
|
+
else [cmd, ...args] = ['new', 'done', 'assign', 'show', 'list'].includes(sub) ? [sub === 'list' ? 'ls' : sub, ...args.slice(1)] : ['ls', ...args];
|
|
185
|
+
} else if (n0 === 'projects') {
|
|
186
|
+
if (verbOf('projects', args[0])) table = { noun: 'projects', verb: args[0], args: args.slice(1) };
|
|
187
|
+
else [cmd, args] = ['projects', args[0] === 'list' ? args.slice(1) : args];
|
|
188
|
+
} else if (n0 === 'comment') {
|
|
189
|
+
if (verbOf('comment', args[0])) table = { noun: 'comment', verb: args[0], args: args.slice(1) };
|
|
190
|
+
else cmd = 'comment';
|
|
191
|
+
} else if (n0 === 'me' && !args.length) cmd = 'whoami';
|
|
192
|
+
else if (n0) {
|
|
193
|
+
const v = verbOf(n0, args[0]) ? args[0] : !args.length || args[0].startsWith('-') ? NOUNS[n0].default : null;
|
|
194
|
+
if (!v || !verbOf(n0, v)) {
|
|
195
|
+
err.write(`sudo-cli: Unknown command “${[cmd, args[0]].filter(Boolean).join(' ')}”. Run sudo-cli help ${n0}.\n`);
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
198
|
+
table = { noun: n0, verb: v, args: v === args[0] ? args.slice(1) : args };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const cfg = loadConfig(env);
|
|
202
|
+
const url = (o.url || cfg.url || '').replace(/\/+$/, '');
|
|
203
|
+
const key = o.key || cfg.key;
|
|
204
|
+
if (o.key)
|
|
205
|
+
err.write('sudo-cli: warning: --key leaves the key in your shell history and the process list. Prefer the prompt (sudo-cli login) or SUDO_API_KEY.\n');
|
|
206
|
+
// The key goes in a header on every request: never in the clear to another machine unless asked.
|
|
207
|
+
const insecureOk = o.insecure || (!o.url && cfg.insecure);
|
|
208
|
+
const needSecure = (u, ok = insecureOk) => {
|
|
209
|
+
if (plainHttpRemote(u) && !ok)
|
|
210
|
+
throw new UsageError(`${u} is plain http, so your API key would cross the network unencrypted. Use https, or add --insecure if you mean it.`);
|
|
211
|
+
};
|
|
212
|
+
// The client waits out rate limits (Retry-After) by itself; say so, or a bulk run looks stuck.
|
|
213
|
+
const rateLimited = ({ delay }) => err.write(`sudo-cli: rate limited; retrying in ${Math.ceil(delay / 1000)} s…\n`);
|
|
214
|
+
const api = () => {
|
|
215
|
+
if (!url || !key) throw new UsageError('Not logged in. Run: sudo-cli login --url https://your-workspace');
|
|
216
|
+
needSecure(url);
|
|
217
|
+
const a = createClient({ url, key, client: 'CLI', fetch: io.fetch, sleep: io.sleep, onRetry: rateLimited });
|
|
218
|
+
// JSON output escapes control characters itself; human output gets them stripped.
|
|
219
|
+
if (o.json) return a;
|
|
220
|
+
const wrap =
|
|
221
|
+
f =>
|
|
222
|
+
async (...args) =>
|
|
223
|
+
clean(await f(...args));
|
|
224
|
+
return { ...a, get: wrap(a.get), post: wrap(a.post), patch: wrap(a.patch), put: wrap(a.put), del: wrap(a.del), upload: wrap(a.upload), all: wrap(a.all) };
|
|
225
|
+
};
|
|
226
|
+
const json = v => print(JSON.stringify(v, null, 2));
|
|
227
|
+
const who = async a => new Map((await a.all('/members')).map(m => [m.id, m.name]));
|
|
228
|
+
const pad = (s, n) => String(s ?? '').padEnd(n);
|
|
229
|
+
const row = (t, names) =>
|
|
230
|
+
`${bold(pad(t.key, 9))} ${pad(t.status, 8)} ${pad(t.priority === 'none' ? '' : t.priority, 6)} ${pad(names.get(t.assignee) || dim('—'), 14)} ${pad(t.due ? t.due.slice(0, 10) : '', 10)} ${t.title}`;
|
|
231
|
+
const need = (v, msg) => {
|
|
232
|
+
if (!v) throw new UsageError(msg);
|
|
233
|
+
return v;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/* What commands.js handlers get. */
|
|
237
|
+
const x = {
|
|
238
|
+
o,
|
|
239
|
+
io: { ...io, stdout: out },
|
|
240
|
+
url,
|
|
241
|
+
api,
|
|
242
|
+
print,
|
|
243
|
+
json,
|
|
244
|
+
bold,
|
|
245
|
+
dim,
|
|
246
|
+
pad,
|
|
247
|
+
need,
|
|
248
|
+
names: who,
|
|
249
|
+
usage: msg => {
|
|
250
|
+
throw new UsageError(msg);
|
|
251
|
+
},
|
|
252
|
+
date: v => (/^none$/i.test(String(v)) ? null : parseDue(v)),
|
|
253
|
+
emit: (data, human) => (o.json ? json(data) : human(data)),
|
|
254
|
+
cancelled: () => (err.write('sudo-cli: cancelled.\n'), 1),
|
|
255
|
+
/* A yes at the terminal, or --yes. Without a terminal to ask at, --yes is required. */
|
|
256
|
+
async confirm(question) {
|
|
257
|
+
if (o.yes) return true;
|
|
258
|
+
if (!(io.stdin || process.stdin).isTTY) throw new UsageError(`${question} Pass --yes to go ahead (there's no terminal to ask at).`);
|
|
259
|
+
return /^y(es)?$/i.test((await ask(io, `${question} [y/N] `)).trim());
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
if (table) {
|
|
265
|
+
x.args = table.args;
|
|
266
|
+
return (await NOUNS[table.noun].verbs[table.verb].run(x)) || 0;
|
|
267
|
+
}
|
|
268
|
+
switch (cmd) {
|
|
269
|
+
case 'login': {
|
|
270
|
+
const u = (o.url || (await ask(io, `Workspace URL${cfg.url ? ` (${cfg.url})` : ''}: `)) || cfg.url || '').trim().replace(/\/+$/, '');
|
|
271
|
+
need(/^https?:\/\//.test(u), 'Give the workspace URL, e.g. https://sudo.example.com');
|
|
272
|
+
// Logging in again asks again: the saved consent was for the saved URL.
|
|
273
|
+
needSecure(u, o.insecure);
|
|
274
|
+
const k = (o.key || (await ask(io, 'API key (Settings → API): ', { hidden: true }))).trim();
|
|
275
|
+
need(k, 'Paste an API key from Settings → API.');
|
|
276
|
+
const me = clean(await createClient({ url: u, key: k, client: 'CLI', fetch: io.fetch, sleep: io.sleep, onRetry: rateLimited }).get('/me'));
|
|
277
|
+
const file = saveConfig({ url: u, key: k, ...(o.insecure && plainHttpRemote(u) ? { insecure: true } : {}) }, env);
|
|
278
|
+
if (o.json) json({ ok: true, user: me, config: file });
|
|
279
|
+
else print(`Logged in to ${me.workspace || u} as ${me.name} (${me.key.scope} key “${me.key.name}”). Saved to ${file}`);
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
case 'logout': {
|
|
283
|
+
// Revoke the key on the server too: when asked (--revoke), or after a yes at the terminal.
|
|
284
|
+
const tty = (io.stdin || process.stdin).isTTY;
|
|
285
|
+
const revoke =
|
|
286
|
+
!!(url && key) &&
|
|
287
|
+
(o.revoke ||
|
|
288
|
+
(tty && !o.json && /^y(es)?$/i.test((await ask(io, 'Also revoke this key on the server, so it stops working everywhere? [y/N] ')).trim())));
|
|
289
|
+
let revoked = false;
|
|
290
|
+
if (revoke)
|
|
291
|
+
try {
|
|
292
|
+
await api().request('DELETE', '/me/key');
|
|
293
|
+
revoked = true;
|
|
294
|
+
} catch (e) {
|
|
295
|
+
// Already revoked or expired: nothing left to do on the server.
|
|
296
|
+
if (!(e instanceof ApiError && e.status === 401)) throw e;
|
|
297
|
+
revoked = true;
|
|
298
|
+
}
|
|
299
|
+
clearConfig(env);
|
|
300
|
+
if (o.json) json({ ok: true, revoked });
|
|
301
|
+
else
|
|
302
|
+
print(
|
|
303
|
+
`Removed ${configPath(env)}.${revoked ? ' The key is revoked.' : ' The key still works until you revoke it in Settings → API (logout --revoke does both).'}`,
|
|
304
|
+
);
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
case 'whoami': {
|
|
308
|
+
const me = await api().get('/me');
|
|
309
|
+
if (o.json) json(me);
|
|
310
|
+
else
|
|
311
|
+
print(`${me.name}${me.kind === 'bot' ? ' (bot)' : ` <${me.email}>`} · ${me.role} in ${me.workspace || url} · ${me.key.scope} key “${me.key.name}”`);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
case 'ls': {
|
|
315
|
+
const a = api();
|
|
316
|
+
const project = o.project || env.SUDO_PROJECT;
|
|
317
|
+
const mine = o.mine || !project;
|
|
318
|
+
const status = o.status || (o.all ? undefined : 'open');
|
|
319
|
+
const n = Math.min(Math.max(Number(o.limit) || 50, 1), 500);
|
|
320
|
+
const query = { project, status, assignee: mine ? 'me' : undefined, ...(o.archived ? { includeArchived: 'true' } : {}) };
|
|
321
|
+
const [tasks, names] = await Promise.all([a.all('/tasks', query, n), who(a)]);
|
|
322
|
+
if (o.json) {
|
|
323
|
+
json(tasks);
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
if (!tasks.length) print(dim(mine ? 'Nothing assigned to you here.' : 'No tasks.'));
|
|
327
|
+
for (const t of tasks) print(row(t, names));
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
case 'new': {
|
|
331
|
+
const title = need(args.join(' ').trim(), 'Give the task a title: sudo-cli new "Fix login bug" -p WEB');
|
|
332
|
+
const project = need(o.project || env.SUDO_PROJECT, 'Say which project: -p <project key> (or set SUDO_PROJECT).');
|
|
333
|
+
if (o.priority && !PRIORITIES.includes(o.priority)) throw new UsageError(`Priority is one of ${PRIORITIES.join(', ')}.`);
|
|
334
|
+
if (o.status && !STATUSES.includes(o.status)) throw new UsageError(`Status is one of ${STATUSES.join(', ')}.`);
|
|
335
|
+
const body = {
|
|
336
|
+
project,
|
|
337
|
+
title,
|
|
338
|
+
...(o.due ? { due: parseDue(o.due) } : {}),
|
|
339
|
+
...(o.assignee ? { assignee: o.assignee } : {}),
|
|
340
|
+
...(o.priority ? { priority: o.priority } : {}),
|
|
341
|
+
...(o.status ? { status: o.status } : {}),
|
|
342
|
+
...(o.desc ? { description: o.desc } : {}),
|
|
343
|
+
};
|
|
344
|
+
// Safe to retry: the server replays the first response for the same Idempotency-Key.
|
|
345
|
+
const t = await api().post('/tasks', body, { idempotencyKey: randomUUID() });
|
|
346
|
+
if (o.json) json(t);
|
|
347
|
+
else print(`Created ${bold(t.key)} ${t.title}${t.due ? dim(` · due ${t.due.slice(0, 10)}`) : ''}\n${dim(t.url)}`);
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
case 'done': {
|
|
351
|
+
need(args.length, 'Which task? sudo-cli done WEB-12');
|
|
352
|
+
const a = api();
|
|
353
|
+
const done = [];
|
|
354
|
+
for (const k of args) done.push(await a.post(`/tasks/${seg(k)}/complete`));
|
|
355
|
+
if (o.json) json(done.length === 1 ? done[0] : done);
|
|
356
|
+
else for (const t of done) print(`Done ${bold(t.key)} ${t.title}`);
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
case 'assign': {
|
|
360
|
+
const [k, ...rest] = args;
|
|
361
|
+
const to = rest.join(' ').trim();
|
|
362
|
+
need(k && to, 'sudo-cli assign WEB-12 <me | name | email | none>');
|
|
363
|
+
const a = api();
|
|
364
|
+
const t = await a.patch(`/tasks/${seg(k)}`, { assignee: to === 'none' ? null : to });
|
|
365
|
+
if (o.json) {
|
|
366
|
+
json(t);
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
const names = await who(a);
|
|
370
|
+
print(t.assignee ? `Assigned ${bold(t.key)} to ${names.get(t.assignee)}` : `Unassigned ${bold(t.key)}`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
case 'show': {
|
|
374
|
+
const k = need(args[0], 'Which task? sudo-cli show WEB-12');
|
|
375
|
+
const a = api();
|
|
376
|
+
const [t, comments, names] = await Promise.all([a.get(`/tasks/${seg(k)}`), a.get(`/tasks/${seg(k)}/comments`), who(a)]);
|
|
377
|
+
if (o.json) {
|
|
378
|
+
json({ ...t, comments: comments.data });
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
print(`${bold(t.key)} ${t.title}`);
|
|
382
|
+
print(dim(`${t.status} · ${t.priority} · ${names.get(t.assignee) || 'unassigned'}${t.due ? ` · due ${t.due.slice(0, 10)}` : ''}`));
|
|
383
|
+
if (t.description) print('\n' + t.description);
|
|
384
|
+
for (const s of t.subtasks)
|
|
385
|
+
print(
|
|
386
|
+
` [${s.done ? 'x' : ' '}] ${s.title}${s.assignee ? ` · ${names.get(s.assignee) || '?'}` : ''}${s.due ? ` · ${s.due.slice(0, 10)}` : ''} ${dim(s.id)}`,
|
|
387
|
+
);
|
|
388
|
+
for (const c of comments.data)
|
|
389
|
+
print(
|
|
390
|
+
`\n${bold(names.get(c.author) || '?')} ${dim(new Date(c.createdAt).toLocaleString())} ${dim(c.id)}\n${c.text}${Object.entries(c.reactions || {})
|
|
391
|
+
.map(([e, w]) => ` ${e}${w.length}`)
|
|
392
|
+
.join('')
|
|
393
|
+
.replace(/^ /, '\n')}`,
|
|
394
|
+
);
|
|
395
|
+
print('\n' + dim(t.url));
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
case 'comment': {
|
|
399
|
+
const [k, ...rest] = args;
|
|
400
|
+
const text = rest.join(' ').trim();
|
|
401
|
+
need(k && text, 'sudo-cli comment WEB-12 "Looks good"');
|
|
402
|
+
const c = await api().post(`/tasks/${seg(k)}/comments`, { text }, { idempotencyKey: randomUUID() });
|
|
403
|
+
if (o.json) json(c);
|
|
404
|
+
else print(`Commented on ${bold(k.toUpperCase())}`);
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
case 'projects': {
|
|
408
|
+
const ps = await api().all('/projects', o.archived ? { includeArchived: 'true' } : {});
|
|
409
|
+
if (o.json) {
|
|
410
|
+
json(ps);
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
for (const p of ps)
|
|
414
|
+
print(
|
|
415
|
+
`${bold(pad(p.key, 8))} ${pad(p.name, 28)} ${dim(`${p.openTasks} open · ${p.doneTasks} done${p.private ? ' · private' : ''}${p.archived ? ' · archived' : ''}`)}`,
|
|
416
|
+
);
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
case 'open': {
|
|
420
|
+
const k = need(args[0], 'What to open? sudo-cli open WEB-12');
|
|
421
|
+
need(url, 'Not logged in. Run: sudo-cli login');
|
|
422
|
+
// Keys look like WEB-12 (a task) or WEB (a project); resolve through the API so typos fail here.
|
|
423
|
+
const target = /-\d+$/.test(k) ? (await api().get(`/tasks/${seg(k)}`)).url : (await api().get(`/projects/${seg(k)}`)).url;
|
|
424
|
+
// The link comes from the server and goes to xdg-open / open / explorer, which would run or
|
|
425
|
+
// open anything: only a page of the workspace itself is opened (or printed).
|
|
426
|
+
if (!sameOrigin(target, url)) {
|
|
427
|
+
err.write(`sudo-cli: the workspace answered with a link outside ${new URL(url).origin}; not opening it.\n`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
if (o.json) {
|
|
431
|
+
json({ url: target });
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
if (o.print || !out.isTTY) {
|
|
435
|
+
print(target);
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
const bin = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
439
|
+
spawn(bin, [target], { stdio: 'ignore', detached: true })
|
|
440
|
+
.on('error', () => print(target))
|
|
441
|
+
.unref();
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
case 'react':
|
|
445
|
+
case 'unreact': {
|
|
446
|
+
const [id, e] = args;
|
|
447
|
+
need(id && e, `sudo-cli ${cmd} <comment id> 👍 (comment ids are in sudo-cli show <KEY>)`);
|
|
448
|
+
const path = `/comments/${seg(id)}/reactions/${seg(reaction(x, e))}`;
|
|
449
|
+
const c = cmd === 'react' ? await api().put(path) : await api().del(path);
|
|
450
|
+
x.emit(c, () => print(`${cmd === 'react' ? 'Reacted' : 'Took back'} ${reaction(x, e)}`));
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
case 'search': {
|
|
454
|
+
const q = need(args.join(' ').trim(), 'What to search for? sudo-cli search "login bug"');
|
|
455
|
+
const a = api();
|
|
456
|
+
const [r, names] = await Promise.all([a.get('/search', { q }), who(a)]);
|
|
457
|
+
if (o.json) {
|
|
458
|
+
json(r);
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
for (const t of r.tasks) print(row(t, names));
|
|
462
|
+
for (const p of r.projects) print(`${bold(pad(p.key, 9))} ${dim('project')} ${p.name}`);
|
|
463
|
+
for (const m of r.members) print(`${bold(pad(m.name, 22))} ${dim(m.email || m.kind)}`);
|
|
464
|
+
if (!r.tasks.length && !r.projects.length && !r.members.length) print(dim('No matches.'));
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
case 'api':
|
|
468
|
+
x.args = args;
|
|
469
|
+
await rawApi(x);
|
|
470
|
+
return 0;
|
|
471
|
+
default:
|
|
472
|
+
throw new UsageError(`Unknown command “${cmd}”. Run sudo-cli --help.`);
|
|
473
|
+
}
|
|
474
|
+
} catch (e) {
|
|
475
|
+
if (e instanceof UsageError) {
|
|
476
|
+
err.write(`sudo-cli: ${e.message}\n`);
|
|
477
|
+
return 2;
|
|
478
|
+
}
|
|
479
|
+
if (e instanceof ApiError) {
|
|
480
|
+
// The message is the server's text: stripped of control characters like everything else it sends.
|
|
481
|
+
const msg = clean(e.message);
|
|
482
|
+
if (o.json) err.write(JSON.stringify(clean({ error: msg, code: e.code, ...(e.field ? { field: e.field } : {}) })) + '\n');
|
|
483
|
+
else err.write(`sudo-cli: ${msg}${e.status === 401 ? ' Run sudo-cli login.' : ''}\n`);
|
|
484
|
+
return 1;
|
|
485
|
+
}
|
|
486
|
+
throw e;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* A line from the terminal (hidden for keys), or the next line of stdin when it's piped (read once,
|
|
491
|
+
so `printf 'url\nkey' | sudo-cli login` answers both questions). */
|
|
492
|
+
const piped = new WeakMap();
|
|
493
|
+
async function ask(io, question, { hidden = false } = {}) {
|
|
494
|
+
const input = io.stdin || process.stdin;
|
|
495
|
+
const output = io.stderr || process.stderr;
|
|
496
|
+
if (!input.isTTY) {
|
|
497
|
+
if (!piped.has(input)) {
|
|
498
|
+
let s = '';
|
|
499
|
+
for await (const ch of input) s += ch;
|
|
500
|
+
piped.set(input, s.split(/\r?\n/));
|
|
501
|
+
}
|
|
502
|
+
return piped.get(input).shift() || '';
|
|
503
|
+
}
|
|
504
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
505
|
+
if (hidden) rl._writeToOutput = s => output.write(s.startsWith(question) ? question : s.includes('\n') ? '\n' : '');
|
|
506
|
+
try {
|
|
507
|
+
return await new Promise(resolve => rl.question(question, resolve));
|
|
508
|
+
} finally {
|
|
509
|
+
rl.close();
|
|
510
|
+
}
|
|
511
|
+
}
|