@sudosapient/cli 0.0.0-stage → 0.1.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 +69 -2
- package/bin/sudo-cli.js +4 -0
- package/package.json +28 -3
- package/src/cli.js +368 -0
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sudosapient/cli (`sudo-cli`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sudo Sapient from the terminal: capture, list, assign, comment on and complete tasks, with `--json`
|
|
4
|
+
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
|
+
Due dates: `today`, `tomorrow`, a weekday (`friday`, `fri`; today counts), `next week`, `+3d`, `+2w`, or
|
|
64
|
+
`2026-11-20`. Set `SUDO_PROJECT=WEB` to skip `-p`. Exit codes: `0` ok, `1` the API refused (the
|
|
65
|
+
message says why; with `--json` it's `{ error, code }` on stderr), `2` a usage error.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
UNLICENSED, all rights reserved. The package is public on npm so anyone can install and run it, but
|
|
70
|
+
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.1.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.1.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,368 @@
|
|
|
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
|
+
|
|
11
|
+
// Kept equal to package.json by release-please (extra-files in release-please-config.json).
|
|
12
|
+
export const VERSION = '0.1.0'; // x-release-please-version
|
|
13
|
+
const STATUSES = ['backlog', 'todo', 'progress', 'review', 'done'];
|
|
14
|
+
const PRIORITIES = ['urgent', 'high', 'medium', 'low', 'none'];
|
|
15
|
+
|
|
16
|
+
export const HELP = `sudo-cli: Sudo Sapient from the terminal
|
|
17
|
+
|
|
18
|
+
Usage
|
|
19
|
+
sudo-cli login [--url <workspace url>] asks for the key (or pipe it in)
|
|
20
|
+
sudo-cli logout [--revoke] --revoke: also revoke the key on the server
|
|
21
|
+
sudo-cli whoami
|
|
22
|
+
sudo-cli tasks [list] [--mine] [-p <project>] [-s <status>] [--all] [-n <limit>]
|
|
23
|
+
sudo-cli tasks new "<title>" -p <project> [--due <date>] [-a <who>] [--priority <p>] [-d <text>]
|
|
24
|
+
sudo-cli tasks done <KEY>...
|
|
25
|
+
sudo-cli tasks assign <KEY> <who> who: me, a name, an email, or "none"
|
|
26
|
+
sudo-cli tasks show <KEY>
|
|
27
|
+
sudo-cli comment <KEY> "<text>"
|
|
28
|
+
sudo-cli projects [list]
|
|
29
|
+
sudo-cli open <KEY> opens a task or project in the browser (--print: just the URL)
|
|
30
|
+
|
|
31
|
+
Shortcuts: new, done, assign, show (= tasks …). Dates: today, tomorrow, friday, +3d, +2w, 2026-11-20.
|
|
32
|
+
Options: --json (machine-readable output), --url, --insecure (allow a plain http:// URL to another
|
|
33
|
+
machine; login remembers it). The key comes from login, else SUDO_API_KEY; --key works too, but it
|
|
34
|
+
lands in your shell history and the process list, so prefer the prompt or the variable.
|
|
35
|
+
Create a key in Settings → API. Config: ${'$'}XDG_CONFIG_HOME/sudo-sapient/config.json.`;
|
|
36
|
+
|
|
37
|
+
const OPTIONS = {
|
|
38
|
+
json: { type: 'boolean' },
|
|
39
|
+
url: { type: 'string' },
|
|
40
|
+
key: { type: 'string' },
|
|
41
|
+
insecure: { type: 'boolean' },
|
|
42
|
+
revoke: { type: 'boolean' },
|
|
43
|
+
project: { type: 'string', short: 'p' },
|
|
44
|
+
status: { type: 'string', short: 's' },
|
|
45
|
+
assignee: { type: 'string', short: 'a' },
|
|
46
|
+
due: { type: 'string' },
|
|
47
|
+
priority: { type: 'string' },
|
|
48
|
+
desc: { type: 'string', short: 'd' },
|
|
49
|
+
limit: { type: 'string', short: 'n' },
|
|
50
|
+
mine: { type: 'boolean' },
|
|
51
|
+
all: { type: 'boolean' },
|
|
52
|
+
print: { type: 'boolean' },
|
|
53
|
+
help: { type: 'boolean', short: 'h' },
|
|
54
|
+
version: { type: 'boolean', short: 'v' },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/* Everyday dates to YYYY-MM-DD, in local time. A weekday is its next occurrence (today counts). */
|
|
58
|
+
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
59
|
+
export function parseDue(s, now = new Date()) {
|
|
60
|
+
const v = String(s).trim().toLowerCase();
|
|
61
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
|
|
62
|
+
const inDays = n => {
|
|
63
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n);
|
|
64
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
65
|
+
};
|
|
66
|
+
if (v === 'today') return inDays(0);
|
|
67
|
+
if (v === 'tomorrow') return inDays(1);
|
|
68
|
+
const rel = /^\+?(\d{1,3})\s*(d|w)$/.exec(v);
|
|
69
|
+
if (rel) return inDays(Number(rel[1]) * (rel[2] === 'w' ? 7 : 1));
|
|
70
|
+
if (v === 'next week') return inDays((8 - now.getDay()) % 7 || 7);
|
|
71
|
+
const day = DAYS.findIndex(n => n === v || n.slice(0, 3) === v);
|
|
72
|
+
if (day >= 0) return inDays((day - now.getDay() + 7) % 7);
|
|
73
|
+
throw new UsageError(`Can't read the date “${s}”. Try today, tomorrow, friday, +3d or 2026-11-20.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class UsageError extends Error {}
|
|
77
|
+
|
|
78
|
+
/* `link` is on the same site as the workspace (scheme, host and port), so nothing the server sends
|
|
79
|
+
can make the CLI open some other page or program. */
|
|
80
|
+
export function sameOrigin(link, workspace) {
|
|
81
|
+
try {
|
|
82
|
+
return new URL(link).origin === new URL(workspace).origin;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Text from the workspace is printed to a terminal: drop control characters (escape sequences could
|
|
89
|
+
rewrite the screen or the clipboard), keeping newlines and tabs. */
|
|
90
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
91
|
+
const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
92
|
+
export const clean = v =>
|
|
93
|
+
typeof v === 'string'
|
|
94
|
+
? v.replace(CONTROL, '')
|
|
95
|
+
: Array.isArray(v)
|
|
96
|
+
? v.map(clean)
|
|
97
|
+
: v && typeof v === 'object'
|
|
98
|
+
? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clean(x)]))
|
|
99
|
+
: v;
|
|
100
|
+
|
|
101
|
+
export async function run(argv, io = {}) {
|
|
102
|
+
const env = io.env || process.env;
|
|
103
|
+
const out = io.stdout || process.stdout;
|
|
104
|
+
const err = io.stderr || process.stderr;
|
|
105
|
+
const print = s => out.write(s + '\n');
|
|
106
|
+
const color = !env.NO_COLOR && out.isTTY;
|
|
107
|
+
const dim = s => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
108
|
+
const bold = s => (color ? `\x1b[1m${s}\x1b[0m` : s);
|
|
109
|
+
|
|
110
|
+
let o, pos;
|
|
111
|
+
try {
|
|
112
|
+
({ values: o, positionals: pos } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true }));
|
|
113
|
+
} catch (e) {
|
|
114
|
+
err.write(`sudo-cli: ${e.message}\nRun sudo-cli --help for usage.\n`);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
if (o.version || o.help || !pos.length) {
|
|
118
|
+
print(o.version ? VERSION : HELP);
|
|
119
|
+
return o.version || o.help ? 0 : 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `tasks new …` and `new …` are the same command.
|
|
123
|
+
let [cmd, ...args] = pos;
|
|
124
|
+
if (cmd === 'tasks' || cmd === 'task') {
|
|
125
|
+
const sub = args[0];
|
|
126
|
+
[cmd, ...args] = ['new', 'done', 'assign', 'show', 'list'].includes(sub) ? [sub === 'list' ? 'ls' : sub, ...args.slice(1)] : ['ls', ...args];
|
|
127
|
+
}
|
|
128
|
+
if (cmd === 'projects' || cmd === 'project') [cmd, args] = ['projects', args[0] === 'list' ? args.slice(1) : args];
|
|
129
|
+
|
|
130
|
+
const cfg = loadConfig(env);
|
|
131
|
+
const url = (o.url || cfg.url || '').replace(/\/+$/, '');
|
|
132
|
+
const key = o.key || cfg.key;
|
|
133
|
+
if (o.key)
|
|
134
|
+
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');
|
|
135
|
+
// The key goes in a header on every request: never in the clear to another machine unless asked.
|
|
136
|
+
const insecureOk = o.insecure || (!o.url && cfg.insecure);
|
|
137
|
+
const needSecure = (u, ok = insecureOk) => {
|
|
138
|
+
if (plainHttpRemote(u) && !ok)
|
|
139
|
+
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.`);
|
|
140
|
+
};
|
|
141
|
+
const api = () => {
|
|
142
|
+
if (!url || !key) throw new UsageError('Not logged in. Run: sudo-cli login --url https://your-workspace');
|
|
143
|
+
needSecure(url);
|
|
144
|
+
const a = createClient({ url, key, client: 'CLI', fetch: io.fetch });
|
|
145
|
+
// JSON output escapes control characters itself; human output gets them stripped.
|
|
146
|
+
if (o.json) return a;
|
|
147
|
+
const wrap =
|
|
148
|
+
f =>
|
|
149
|
+
async (...args) =>
|
|
150
|
+
clean(await f(...args));
|
|
151
|
+
return { ...a, get: wrap(a.get), post: wrap(a.post), patch: wrap(a.patch), all: wrap(a.all) };
|
|
152
|
+
};
|
|
153
|
+
const json = v => print(JSON.stringify(v, null, 2));
|
|
154
|
+
const who = async a => new Map((await a.all('/members')).map(m => [m.id, m.name]));
|
|
155
|
+
const pad = (s, n) => String(s ?? '').padEnd(n);
|
|
156
|
+
const row = (t, names) =>
|
|
157
|
+
`${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}`;
|
|
158
|
+
const need = (v, msg) => {
|
|
159
|
+
if (!v) throw new UsageError(msg);
|
|
160
|
+
return v;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
switch (cmd) {
|
|
165
|
+
case 'login': {
|
|
166
|
+
const u = (o.url || (await ask(io, `Workspace URL${cfg.url ? ` (${cfg.url})` : ''}: `)) || cfg.url || '').trim().replace(/\/+$/, '');
|
|
167
|
+
need(/^https?:\/\//.test(u), 'Give the workspace URL, e.g. https://sudo.example.com');
|
|
168
|
+
// Logging in again asks again: the saved consent was for the saved URL.
|
|
169
|
+
needSecure(u, o.insecure);
|
|
170
|
+
const k = (o.key || (await ask(io, 'API key (Settings → API): ', { hidden: true }))).trim();
|
|
171
|
+
need(k, 'Paste an API key from Settings → API.');
|
|
172
|
+
const me = clean(await createClient({ url: u, key: k, client: 'CLI', fetch: io.fetch }).get('/me'));
|
|
173
|
+
const file = saveConfig({ url: u, key: k, ...(o.insecure && plainHttpRemote(u) ? { insecure: true } : {}) }, env);
|
|
174
|
+
if (o.json) json({ ok: true, user: me, config: file });
|
|
175
|
+
else print(`Logged in to ${me.workspace || u} as ${me.name} (${me.key.scope} key “${me.key.name}”). Saved to ${file}`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
case 'logout': {
|
|
179
|
+
// Revoke the key on the server too: when asked (--revoke), or after a yes at the terminal.
|
|
180
|
+
const tty = (io.stdin || process.stdin).isTTY;
|
|
181
|
+
const revoke =
|
|
182
|
+
!!(url && key) &&
|
|
183
|
+
(o.revoke ||
|
|
184
|
+
(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())));
|
|
185
|
+
let revoked = false;
|
|
186
|
+
if (revoke)
|
|
187
|
+
try {
|
|
188
|
+
await api().request('DELETE', '/me/key');
|
|
189
|
+
revoked = true;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// Already revoked or expired: nothing left to do on the server.
|
|
192
|
+
if (!(e instanceof ApiError && e.status === 401)) throw e;
|
|
193
|
+
revoked = true;
|
|
194
|
+
}
|
|
195
|
+
clearConfig(env);
|
|
196
|
+
if (o.json) json({ ok: true, revoked });
|
|
197
|
+
else
|
|
198
|
+
print(
|
|
199
|
+
`Removed ${configPath(env)}.${revoked ? ' The key is revoked.' : ' The key still works until you revoke it in Settings → API (logout --revoke does both).'}`,
|
|
200
|
+
);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
case 'whoami': {
|
|
204
|
+
const me = await api().get('/me');
|
|
205
|
+
if (o.json) json(me);
|
|
206
|
+
else
|
|
207
|
+
print(`${me.name}${me.kind === 'bot' ? ' (bot)' : ` <${me.email}>`} · ${me.role} in ${me.workspace || url} · ${me.key.scope} key “${me.key.name}”`);
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
case 'ls': {
|
|
211
|
+
const a = api();
|
|
212
|
+
const project = o.project || env.SUDO_PROJECT;
|
|
213
|
+
const mine = o.mine || !project;
|
|
214
|
+
const status = o.status || (o.all ? undefined : 'open');
|
|
215
|
+
const n = Math.min(Math.max(Number(o.limit) || 50, 1), 500);
|
|
216
|
+
const [tasks, names] = await Promise.all([a.all('/tasks', { project, status, assignee: mine ? 'me' : undefined }, n), who(a)]);
|
|
217
|
+
if (o.json) {
|
|
218
|
+
json(tasks);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
if (!tasks.length) print(dim(mine ? 'Nothing assigned to you here.' : 'No tasks.'));
|
|
222
|
+
for (const t of tasks) print(row(t, names));
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
case 'new': {
|
|
226
|
+
const title = need(args.join(' ').trim(), 'Give the task a title: sudo-cli new "Fix login bug" -p WEB');
|
|
227
|
+
const project = need(o.project || env.SUDO_PROJECT, 'Say which project: -p <project key> (or set SUDO_PROJECT).');
|
|
228
|
+
if (o.priority && !PRIORITIES.includes(o.priority)) throw new UsageError(`Priority is one of ${PRIORITIES.join(', ')}.`);
|
|
229
|
+
if (o.status && !STATUSES.includes(o.status)) throw new UsageError(`Status is one of ${STATUSES.join(', ')}.`);
|
|
230
|
+
const body = {
|
|
231
|
+
project,
|
|
232
|
+
title,
|
|
233
|
+
...(o.due ? { due: parseDue(o.due) } : {}),
|
|
234
|
+
...(o.assignee ? { assignee: o.assignee } : {}),
|
|
235
|
+
...(o.priority ? { priority: o.priority } : {}),
|
|
236
|
+
...(o.status ? { status: o.status } : {}),
|
|
237
|
+
...(o.desc ? { description: o.desc } : {}),
|
|
238
|
+
};
|
|
239
|
+
// Safe to retry: the server replays the first response for the same Idempotency-Key.
|
|
240
|
+
const t = await api().post('/tasks', body, { idempotencyKey: randomUUID() });
|
|
241
|
+
if (o.json) json(t);
|
|
242
|
+
else print(`Created ${bold(t.key)} ${t.title}${t.due ? dim(` · due ${t.due.slice(0, 10)}`) : ''}\n${dim(t.url)}`);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
case 'done': {
|
|
246
|
+
need(args.length, 'Which task? sudo-cli done WEB-12');
|
|
247
|
+
const a = api();
|
|
248
|
+
const done = [];
|
|
249
|
+
for (const k of args) done.push(await a.post(`/tasks/${seg(k)}/complete`));
|
|
250
|
+
if (o.json) json(done.length === 1 ? done[0] : done);
|
|
251
|
+
else for (const t of done) print(`Done ${bold(t.key)} ${t.title}`);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
case 'assign': {
|
|
255
|
+
const [k, ...rest] = args;
|
|
256
|
+
const to = rest.join(' ').trim();
|
|
257
|
+
need(k && to, 'sudo-cli assign WEB-12 <me | name | email | none>');
|
|
258
|
+
const a = api();
|
|
259
|
+
const t = await a.patch(`/tasks/${seg(k)}`, { assignee: to === 'none' ? null : to });
|
|
260
|
+
if (o.json) {
|
|
261
|
+
json(t);
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
const names = await who(a);
|
|
265
|
+
print(t.assignee ? `Assigned ${bold(t.key)} to ${names.get(t.assignee)}` : `Unassigned ${bold(t.key)}`);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
case 'show': {
|
|
269
|
+
const k = need(args[0], 'Which task? sudo-cli show WEB-12');
|
|
270
|
+
const a = api();
|
|
271
|
+
const [t, comments, names] = await Promise.all([a.get(`/tasks/${seg(k)}`), a.get(`/tasks/${seg(k)}/comments`), who(a)]);
|
|
272
|
+
if (o.json) {
|
|
273
|
+
json({ ...t, comments: comments.data });
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
print(`${bold(t.key)} ${t.title}`);
|
|
277
|
+
print(dim(`${t.status} · ${t.priority} · ${names.get(t.assignee) || 'unassigned'}${t.due ? ` · due ${t.due.slice(0, 10)}` : ''}`));
|
|
278
|
+
if (t.description) print('\n' + t.description);
|
|
279
|
+
for (const s of t.subtasks) print(` [${s.done ? 'x' : ' '}] ${s.title}`);
|
|
280
|
+
for (const c of comments.data) print(`\n${bold(names.get(c.author) || '?')} ${dim(new Date(c.createdAt).toLocaleString())}\n${c.text}`);
|
|
281
|
+
print('\n' + dim(t.url));
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
case 'comment': {
|
|
285
|
+
const [k, ...rest] = args;
|
|
286
|
+
const text = rest.join(' ').trim();
|
|
287
|
+
need(k && text, 'sudo-cli comment WEB-12 "Looks good"');
|
|
288
|
+
const c = await api().post(`/tasks/${seg(k)}/comments`, { text }, { idempotencyKey: randomUUID() });
|
|
289
|
+
if (o.json) json(c);
|
|
290
|
+
else print(`Commented on ${bold(k.toUpperCase())}`);
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
case 'projects': {
|
|
294
|
+
const ps = await api().all('/projects');
|
|
295
|
+
if (o.json) {
|
|
296
|
+
json(ps);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
for (const p of ps)
|
|
300
|
+
print(`${bold(pad(p.key, 8))} ${pad(p.name, 28)} ${dim(`${p.openTasks} open · ${p.doneTasks} done${p.private ? ' · private' : ''}`)}`);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
case 'open': {
|
|
304
|
+
const k = need(args[0], 'What to open? sudo-cli open WEB-12');
|
|
305
|
+
need(url, 'Not logged in. Run: sudo-cli login');
|
|
306
|
+
// Keys look like WEB-12 (a task) or WEB (a project); resolve through the API so typos fail here.
|
|
307
|
+
const target = /-\d+$/.test(k) ? (await api().get(`/tasks/${seg(k)}`)).url : (await api().get(`/projects/${seg(k)}`)).url;
|
|
308
|
+
// The link comes from the server and goes to xdg-open / open / explorer, which would run or
|
|
309
|
+
// open anything: only a page of the workspace itself is opened (or printed).
|
|
310
|
+
if (!sameOrigin(target, url)) {
|
|
311
|
+
err.write(`sudo-cli: the workspace answered with a link outside ${new URL(url).origin}; not opening it.\n`);
|
|
312
|
+
return 1;
|
|
313
|
+
}
|
|
314
|
+
if (o.json) {
|
|
315
|
+
json({ url: target });
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
if (o.print || !out.isTTY) {
|
|
319
|
+
print(target);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
const bin = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
|
|
323
|
+
spawn(bin, [target], { stdio: 'ignore', detached: true })
|
|
324
|
+
.on('error', () => print(target))
|
|
325
|
+
.unref();
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
default:
|
|
329
|
+
throw new UsageError(`Unknown command “${cmd}”. Run sudo-cli --help.`);
|
|
330
|
+
}
|
|
331
|
+
} catch (e) {
|
|
332
|
+
if (e instanceof UsageError) {
|
|
333
|
+
err.write(`sudo-cli: ${e.message}\n`);
|
|
334
|
+
return 2;
|
|
335
|
+
}
|
|
336
|
+
if (e instanceof ApiError) {
|
|
337
|
+
// The message is the server's text: stripped of control characters like everything else it sends.
|
|
338
|
+
const msg = clean(e.message);
|
|
339
|
+
if (o.json) err.write(JSON.stringify(clean({ error: msg, code: e.code, ...(e.field ? { field: e.field } : {}) })) + '\n');
|
|
340
|
+
else err.write(`sudo-cli: ${msg}${e.status === 401 ? ' Run sudo-cli login.' : ''}\n`);
|
|
341
|
+
return 1;
|
|
342
|
+
}
|
|
343
|
+
throw e;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* A line from the terminal (hidden for keys), or the next line of stdin when it's piped (read once,
|
|
348
|
+
so `printf 'url\nkey' | sudo-cli login` answers both questions). */
|
|
349
|
+
const piped = new WeakMap();
|
|
350
|
+
async function ask(io, question, { hidden = false } = {}) {
|
|
351
|
+
const input = io.stdin || process.stdin;
|
|
352
|
+
const output = io.stderr || process.stderr;
|
|
353
|
+
if (!input.isTTY) {
|
|
354
|
+
if (!piped.has(input)) {
|
|
355
|
+
let s = '';
|
|
356
|
+
for await (const ch of input) s += ch;
|
|
357
|
+
piped.set(input, s.split(/\r?\n/));
|
|
358
|
+
}
|
|
359
|
+
return piped.get(input).shift() || '';
|
|
360
|
+
}
|
|
361
|
+
const rl = readline.createInterface({ input, output, terminal: true });
|
|
362
|
+
if (hidden) rl._writeToOutput = s => output.write(s.startsWith(question) ? question : s.includes('\n') ? '\n' : '');
|
|
363
|
+
try {
|
|
364
|
+
return await new Promise(resolve => rl.question(question, resolve));
|
|
365
|
+
} finally {
|
|
366
|
+
rl.close();
|
|
367
|
+
}
|
|
368
|
+
}
|