@staqon.one/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 +155 -2
- package/dist/api/client.js +71 -0
- package/dist/auth.js +102 -0
- package/dist/commands/ad.js +82 -0
- package/dist/commands/auth.js +37 -0
- package/dist/commands/members.js +111 -0
- package/dist/commands/messaging.js +92 -0
- package/dist/commands/misc.js +94 -0
- package/dist/commands/verify.js +64 -0
- package/dist/config.js +37 -0
- package/dist/index.js +24 -0
- package/dist/output.js +24 -0
- package/package.json +30 -4
- package/src/api/client.ts +84 -0
- package/src/auth.ts +132 -0
- package/src/commands/ad.ts +100 -0
- package/src/commands/auth.ts +39 -0
- package/src/commands/members.ts +130 -0
- package/src/commands/messaging.ts +116 -0
- package/src/commands/misc.ts +109 -0
- package/src/commands/verify.ts +75 -0
- package/src/config.ts +48 -0
- package/src/index.ts +30 -0
- package/src/output.ts +24 -0
- package/tsconfig.json +15 -0
package/README.md
CHANGED
|
@@ -1,3 +1,156 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @staqon/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A command-line tool for a StaqOn workspace owner — send messages, post ads, run e-StaqOn Verify approvals, manage External and Internal team members, and raise escalation matters, all from your terminal.
|
|
4
|
+
|
|
5
|
+
The CLI never holds, sees, or asks you to paste a raw API key. Instead it uses a device-pairing flow: you approve each computer once from your StaqOn dashboard, and after that every call goes through StaqOn's own proxy on your workspace's behalf. Full technical details for engineers who want them are at [`/developer`](https://staqon.one/developer) (the live, interactive Swagger spec) — this README covers the CLI's own commands.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @staqon/cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node 18+.
|
|
14
|
+
|
|
15
|
+
## Sign in
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
staqon login
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This prints an 8-character code and a link (`https://staqon.one/cli?code=XXXX-XXXX`). Open the link (or go to `/cli` and type the code) while signed in to StaqOn as the workspace owner, pick which workspace to pair, and confirm. If your web session is more than 15 minutes old, you'll be asked for your MPIN first.
|
|
22
|
+
|
|
23
|
+
The code expires in 10 minutes. Once approved, the CLI stores a session token locally (`~/.staqon/credentials.json`, permissions `0600`) and you won't need to log in again until that session is revoked or expires.
|
|
24
|
+
|
|
25
|
+
**A prerequisite:** the workspace owner must turn on "Allow CLI access" in **Workspace Settings → API → CLI access** before `staqon login` can succeed. This is a separate, per-workspace switch — turning it off immediately signs out every paired computer.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
staqon whoami # confirm you're paired, see your rate-limit usage and which commands are available
|
|
29
|
+
staqon logout # forget the session on THIS computer (does not revoke it server-side — use the dashboard for that)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## How calls are authorized
|
|
33
|
+
|
|
34
|
+
Every command goes through StaqOn's own CLI proxy, never a tenant's raw API key:
|
|
35
|
+
|
|
36
|
+
1. Your paired session's bearer token (`stq_cli_...`) authenticates the call.
|
|
37
|
+
2. StaqOn checks, on every single call, that your workspace's "Allow CLI access" switch is still on and that you're still that workspace's active owner.
|
|
38
|
+
3. StaqOn picks which of the workspace's own API keys to act as — automatically if there's exactly one eligible key for that command, or you'll be asked to disambiguate with `--team <id-or-slug>` if there are several active teams of the right type.
|
|
39
|
+
4. The underlying endpoint runs exactly as it would for a direct API call — same validation, same responses.
|
|
40
|
+
|
|
41
|
+
If a command returns `"error": "team_required"` with a list of teams, re-run it with `--team <id-or-slug>`.
|
|
42
|
+
|
|
43
|
+
## Rate limits
|
|
44
|
+
|
|
45
|
+
Two independent limits apply to every call:
|
|
46
|
+
|
|
47
|
+
- **This CLI session:** 30 requests/minute, 3,000/day — separate from, and in addition to, the workspace's own API key limits.
|
|
48
|
+
- **The workspace's underlying API key:** if that key is already at its own limit from *direct* API traffic, CLI calls pause too (CLI traffic itself never counts against or depletes the key's direct-API headroom — they're tracked separately).
|
|
49
|
+
|
|
50
|
+
A `429` response includes `Retry-After` in seconds either way.
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
### Members (External team)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
staqon members list [--page 1] [--limit 50] [--search text] [--team id-or-slug]
|
|
58
|
+
staqon members get --email you@example.com
|
|
59
|
+
staqon members get --member-id 123
|
|
60
|
+
staqon members add --name "Jane Doe" --email jane@example.com
|
|
61
|
+
staqon members remove --email jane@example.com
|
|
62
|
+
staqon members teams --external-ref your-ref-from-link-invite
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--limit` accepts 1–200 on `list`. `add` only takes `--name` and `--email` — there is no phone field on this endpoint.
|
|
66
|
+
|
|
67
|
+
Removing a member archives their thread (history is kept) and drops their team/conversation membership.
|
|
68
|
+
|
|
69
|
+
### Internal team members
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
staqon internal-members [--page 1] [--limit 50] [--search text] [--team id-or-slug]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Same shape as `members list`, but for Internal teams: no guest-role filter, and it reports each member's `role` instead of message stats (Internal is a one-way feed, not a conversation).
|
|
76
|
+
|
|
77
|
+
### Messaging (External team)
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
staqon messages send --email jane@example.com --message "Hello!"
|
|
81
|
+
staqon messages comment --username jane.doe --comment "Thanks for your order" [--external-ref my-idempotency-key]
|
|
82
|
+
staqon messages read --username jane.doe [--limit 50] [--before 1000 | --after 2000]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `send` and `comment` both write into a member's thread as the workspace. `send` uses the member's email; `comment` uses their StaqOn username and supports an `--external-ref` so a retried call with the same ref is idempotent — but **the uniqueness check is workspace-wide, not per recipient**: reusing the same ref for a different recipient will silently fail to reach them (you'll get a 409 back, not a second delivery).
|
|
86
|
+
- `read` requires an **approved consent link** for that member — you'll get a 403 if the member hasn't linked. `send`/`comment` don't need that link (writing in is the workspace's own authority).
|
|
87
|
+
- `--before`/`--after` page through history; use one or the other, not both.
|
|
88
|
+
|
|
89
|
+
### Internal messaging
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
staqon internal-message --username jane.doe --message "Your ticket was updated" [--title "Ticket #123"] [--link-url https://...] [--external-ref ref]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A one-way push into the member's API Messages feed — not a conversation (no replies, reactions, or attachments). Requires the member's explicit, separate consent for *internal* messages: a 403 means they haven't opted in; a 404 with "Internal Link is not set up" means the workspace hasn't configured that gate at all yet.
|
|
96
|
+
|
|
97
|
+
### e-StaqOn Verify — push approval for sensitive actions
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
staqon verify start --username jane.doe --action "Withdraw $500" --context "Approve withdrawal of $500 to your linked bank account?" [--ttl 60]
|
|
101
|
+
staqon verify check --id <verification_id>
|
|
102
|
+
staqon verify cancel --id <verification_id>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`start` pushes an approval prompt to the member's StaqOn app and returns a `verification_id`. Poll `check` until you get `verified: true` (approved), or `verified: false` with a `reason` of `pending`, `declined`, `timeout`, or `cancelled`. `ttl` is 30–180 seconds (default 30; out-of-range values are clamped, not rejected). `cancel` withdraws a still-pending request — if the member already answered, you'll get a 409 and their answer stands, not your cancellation.
|
|
106
|
+
|
|
107
|
+
This requires an approved, **unexpired** consent link for the member (stricter than `messages send`'s check).
|
|
108
|
+
|
|
109
|
+
### Advertisement API
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
staqon ad upload-image --file ./banner.jpg
|
|
113
|
+
staqon ad send --body "Big autumn sale, 20% off everything" --button-label "Shop now" --button-url https://example.com/sale [--image-url <from upload-image>] [--external-ref campaign-001]
|
|
114
|
+
staqon ad status --external-ref campaign-001
|
|
115
|
+
staqon ad status --request-id 812
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Requires an **ad-purpose** API key on the target team (separate from your messaging key — created in Workspace Settings → API → Ad API). `ad send` is asynchronous: it queues the send and returns right away; poll `ad status` for progress and final counts. `upload-image` enforces JPEG/PNG/WebP under 300KB; images are centre-cropped to 16:9 server-side.
|
|
119
|
+
|
|
120
|
+
### Escalation matters
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
staqon escalation raise --email jane@example.com --title "Repeated no-shows" --description "..." --due-date 2026-12-01 [--priority high]
|
|
124
|
+
staqon escalation list [--page 1] [--limit 50] [--status open]
|
|
125
|
+
staqon escalation get --matter-id 42
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`--due-date` must be strictly after today. `--priority` is one of `low`, `medium`, `high`, `critical` (default `medium`). `list`/`get` only ever show matters your key can see — scoped to "the target member is in this key's team," not by any other workspace linkage.
|
|
129
|
+
|
|
130
|
+
### Status and webhook health
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
staqon status [--internal]
|
|
134
|
+
staqon webhooks [--internal]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`status` reports member counts and your current rate-limit usage for the External (default) or Internal surface. `webhooks` reports each registered webhook's health — a webhook that fails 10 consecutive deliveries auto-disables, and this is the only way to notice that happened short of your own monitoring.
|
|
138
|
+
|
|
139
|
+
## A note on API keys and this CLI
|
|
140
|
+
|
|
141
|
+
Creating a **new** Messaging or Ad API key from the dashboard requires the workspace's Link integration to be set up first — the "+ New key" button is disabled with a "Set up Link first" tooltip until then. Keys created before Link was required keep working. This restriction is enforced in the dashboard UI, not by the API itself, and it has nothing to do with CLI pairing — CLI access is a separate switch and doesn't require Link at all.
|
|
142
|
+
|
|
143
|
+
## Troubleshooting
|
|
144
|
+
|
|
145
|
+
| Symptom | Likely cause |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `staqon login` never completes | The workspace owner hasn't approved the code at `/cli` yet, or 10 minutes passed — run `staqon login` again. |
|
|
148
|
+
| Any command returns 401 "...missing, expired or revoked" | Your paired session is gone. Run `staqon login` again. |
|
|
149
|
+
| 403 "CLI access is turned off for this workspace" | The owner turned the workspace's CLI switch off. They'll need to turn it back on and you'll need to pair again. |
|
|
150
|
+
| 409 "no_key" | The workspace has no active API key of the right surface+purpose for that command. The owner creates one in Workspace Settings → API. |
|
|
151
|
+
| 409 "team_required" | The workspace has more than one eligible team for that command. Re-run with `--team <id-or-slug>` — the error body lists the choices. |
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
|
156
|
+
</content>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { BASE_URL, loadSession, saveSession, clearSession } from '../config.js';
|
|
2
|
+
export class StaqOnApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
constructor(status, body) {
|
|
6
|
+
super(typeof body.error === 'string' ? body.error : `HTTP ${status}`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.body = body;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The real, server-enforced ceiling for every paginated CLI list endpoint
|
|
13
|
+
* (members.php, internal/members.php, messages.php): Swagger says
|
|
14
|
+
* `maximum: 200` on all three. The OLD cli had a client-side MAX_LIST_LIMIT
|
|
15
|
+
* of 100 — that was a self-imposed, unnecessarily tight cap, not a server
|
|
16
|
+
* fact. Verified against openapi.yaml this session (Nov 11 2026).
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_LIST_LIMIT = 200;
|
|
19
|
+
function buildQuery(params = {}) {
|
|
20
|
+
const qs = new URLSearchParams();
|
|
21
|
+
for (const [k, v] of Object.entries(params)) {
|
|
22
|
+
if (v !== undefined)
|
|
23
|
+
qs.set(k, String(v));
|
|
24
|
+
}
|
|
25
|
+
const s = qs.toString();
|
|
26
|
+
return s ? `?${s}` : '';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The CLI's ONE front door, matching /api/v1/cli-proxy.php exactly:
|
|
30
|
+
* GET|POST /api/v1/cli-proxy.php?_ep=<name>&...their own query
|
|
31
|
+
* Authorization: Bearer stq_cli_<64 hex>
|
|
32
|
+
* X-StaqOn-Team: <team id or slug> (only when the workspace has >1 eligible team)
|
|
33
|
+
* Never sends X-API-Key — the proxy drops it server-side anyway; the CLI
|
|
34
|
+
* never holds, or needs, a raw tenant key.
|
|
35
|
+
*/
|
|
36
|
+
export async function proxy(ep, opts = {}) {
|
|
37
|
+
const session = await loadSession();
|
|
38
|
+
if (!session) {
|
|
39
|
+
throw new StaqOnApiError(401, { error: 'Not logged in. Run: staqon login' });
|
|
40
|
+
}
|
|
41
|
+
const method = opts.method ?? (opts.json ? 'POST' : 'GET');
|
|
42
|
+
const url = `${BASE_URL}/api/v1/cli-proxy.php${buildQuery({ _ep: ep, ...opts.query })}`;
|
|
43
|
+
const headers = {
|
|
44
|
+
Authorization: `Bearer ${session.token}`,
|
|
45
|
+
};
|
|
46
|
+
if (opts.team)
|
|
47
|
+
headers['X-StaqOn-Team'] = opts.team;
|
|
48
|
+
if (opts.json)
|
|
49
|
+
headers['Content-Type'] = 'application/json';
|
|
50
|
+
const res = await fetch(url, {
|
|
51
|
+
method,
|
|
52
|
+
headers,
|
|
53
|
+
body: opts.json ? JSON.stringify(opts.json) : undefined,
|
|
54
|
+
});
|
|
55
|
+
const body = (await res.json().catch(() => ({})));
|
|
56
|
+
if (res.status === 401) {
|
|
57
|
+
// Session is gone/expired/revoked server-side — don't leave a dead token lying around locally.
|
|
58
|
+
await clearSession();
|
|
59
|
+
throw new StaqOnApiError(401, {
|
|
60
|
+
error: body.error ?? 'This CLI session is missing, expired or revoked. Run: staqon login',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
throw new StaqOnApiError(res.status, body);
|
|
65
|
+
return body;
|
|
66
|
+
}
|
|
67
|
+
/** `_ep=whoami` — no backing file; the proxy answers directly with session/workspace/limits/access. */
|
|
68
|
+
export async function whoami() {
|
|
69
|
+
return proxy('whoami', { method: 'GET' });
|
|
70
|
+
}
|
|
71
|
+
export { BASE_URL, loadSession, saveSession, clearSession };
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BASE_URL, saveSession, clearSession, loadSession } from './config.js';
|
|
2
|
+
const CLI_VERSION = '0.1.0'; // keep in sync with package.json
|
|
3
|
+
async function postJson(path, body) {
|
|
4
|
+
const res = await fetch(`${BASE_URL}/api/cli-pair.php?action=${path}`, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: { 'Content-Type': 'application/json' },
|
|
7
|
+
body: JSON.stringify(body),
|
|
8
|
+
});
|
|
9
|
+
const data = (await res.json());
|
|
10
|
+
if (!res.ok || data.ok === false) {
|
|
11
|
+
throw new Error(data.error ?? `Login failed (HTTP ${res.status})`);
|
|
12
|
+
}
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
function osLabel() {
|
|
16
|
+
const p = process.platform;
|
|
17
|
+
if (p === 'darwin')
|
|
18
|
+
return 'macOS';
|
|
19
|
+
if (p === 'win32')
|
|
20
|
+
return 'Windows';
|
|
21
|
+
if (p === 'linux')
|
|
22
|
+
return 'Linux';
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Device-pairing login, matching api/cli-pair.php exactly:
|
|
30
|
+
* 1. code_request -> {pair_id, code, verification_url, poll_secret, expires_in, interval}
|
|
31
|
+
* 2. show the code + verification_url to the user
|
|
32
|
+
* 3. poll {pair_id, poll_secret} every `interval` seconds until approved/denied/expired
|
|
33
|
+
* 4. on 'approved', the token is minted and returned EXACTLY ONCE on that poll response —
|
|
34
|
+
* save it immediately; a repeat poll after that returns 'consumed', not the token again.
|
|
35
|
+
*
|
|
36
|
+
* No API key is ever involved. The workspace owner approves the device at
|
|
37
|
+
* <verification_url> (StaqOn's own /cli page), picking which workspace and,
|
|
38
|
+
* if their web session is stale, re-entering their MPIN — all server-side.
|
|
39
|
+
*/
|
|
40
|
+
export async function login(deviceLabel) {
|
|
41
|
+
const label = deviceLabel ?? `${osLabel()} computer`;
|
|
42
|
+
const start = await postJson('code_request', {
|
|
43
|
+
device_label: label,
|
|
44
|
+
os: osLabel(),
|
|
45
|
+
cli_version: CLI_VERSION,
|
|
46
|
+
});
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log(` To finish signing in, visit: ${start.verification_url}`);
|
|
49
|
+
console.log(` And enter this code: ${start.code}`);
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(' Waiting for approval (this code expires in 10 minutes)...');
|
|
52
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
53
|
+
const intervalMs = Math.max(start.interval, 1) * 1000;
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
await sleep(intervalMs);
|
|
56
|
+
const poll = await postJson('poll', {
|
|
57
|
+
pair_id: start.pair_id,
|
|
58
|
+
poll_secret: start.poll_secret,
|
|
59
|
+
});
|
|
60
|
+
if (poll.status === 'pending')
|
|
61
|
+
continue;
|
|
62
|
+
if (poll.status === 'approved' && poll.token && poll.workspace) {
|
|
63
|
+
await saveSession({
|
|
64
|
+
token: poll.token,
|
|
65
|
+
workspace: poll.workspace,
|
|
66
|
+
expires_at: poll.expires_at ?? '',
|
|
67
|
+
paired_at: new Date().toISOString(),
|
|
68
|
+
});
|
|
69
|
+
return { workspace: poll.workspace };
|
|
70
|
+
}
|
|
71
|
+
if (poll.status === 'denied')
|
|
72
|
+
throw new Error('Login request was denied.');
|
|
73
|
+
if (poll.status === 'expired')
|
|
74
|
+
throw new Error('Login code expired. Run: staqon login');
|
|
75
|
+
if (poll.status === 'consumed')
|
|
76
|
+
throw new Error('This login code was already used. Run: staqon login');
|
|
77
|
+
throw new Error(poll.error ?? `Unexpected login status: ${poll.status}`);
|
|
78
|
+
}
|
|
79
|
+
throw new Error('Login timed out. Run: staqon login');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Rotates the current session token via ?action=refresh. The OLD token
|
|
83
|
+
* stays valid for 5 more minutes server-side (CLI_ROTATE_GRACE=300), so a
|
|
84
|
+
* failed save here doesn't strand the user mid-rotation.
|
|
85
|
+
*/
|
|
86
|
+
export async function refresh() {
|
|
87
|
+
const session = await loadSession();
|
|
88
|
+
if (!session)
|
|
89
|
+
throw new Error('Not logged in. Run: staqon login');
|
|
90
|
+
const res = await fetch(`${BASE_URL}/api/cli-pair.php?action=refresh`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
93
|
+
});
|
|
94
|
+
const data = (await res.json());
|
|
95
|
+
if (!res.ok || !data.ok || !data.token) {
|
|
96
|
+
throw new Error(data.error ?? 'Could not refresh. Run: staqon login');
|
|
97
|
+
}
|
|
98
|
+
await saveSession({ ...session, token: data.token });
|
|
99
|
+
}
|
|
100
|
+
export async function logout() {
|
|
101
|
+
await clearSession();
|
|
102
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { proxy, BASE_URL, loadSession } from '../api/client.js';
|
|
3
|
+
import { fail, printJson } from '../output.js';
|
|
4
|
+
function teamOpt(cmd) {
|
|
5
|
+
return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
|
|
6
|
+
}
|
|
7
|
+
export function registerAdCommands(program) {
|
|
8
|
+
const ad = program.command('ad').description('Advertisement API — send a card to every member thread of a team');
|
|
9
|
+
teamOpt(ad
|
|
10
|
+
.command('upload-image')
|
|
11
|
+
.description('Upload one banner image (JPEG/PNG/WebP, max 300KB) and get back an image_url for `ad send --image-url`')
|
|
12
|
+
.requiredOption('--file <path>', 'local path to the image')).action(async (opts) => {
|
|
13
|
+
try {
|
|
14
|
+
const session = await loadSession();
|
|
15
|
+
if (!session)
|
|
16
|
+
throw new Error('Not logged in. Run: staqon login');
|
|
17
|
+
const data = await readFile(opts.file);
|
|
18
|
+
const form = new FormData();
|
|
19
|
+
form.append('image', new Blob([data]), opts.file.split('/').pop() ?? 'image');
|
|
20
|
+
const headers = { Authorization: `Bearer ${session.token}` };
|
|
21
|
+
if (opts.team)
|
|
22
|
+
headers['X-StaqOn-Team'] = opts.team;
|
|
23
|
+
const res = await fetch(`${BASE_URL}/api/v1/cli-proxy.php?_ep=external/ad-image`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers,
|
|
26
|
+
body: form,
|
|
27
|
+
});
|
|
28
|
+
const body = (await res.json());
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new Error(typeof body.error === 'string' ? body.error : `HTTP ${res.status}`);
|
|
31
|
+
printJson(body);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
fail(e);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
teamOpt(ad
|
|
38
|
+
.command('send')
|
|
39
|
+
.description('Queue an ad card to every active thread of the team — asynchronous: returns 202 immediately, poll `ad status` for progress')
|
|
40
|
+
.requiredOption('--body <text>', '50-900 characters')
|
|
41
|
+
.requiredOption('--button-label <text>', '1-30 characters')
|
|
42
|
+
.requiredOption('--button-url <url>', 'must start with https://')
|
|
43
|
+
.option('--image-url <url>', 'from `ad upload-image`, same workspace+team')
|
|
44
|
+
.option('--external-ref <ref>', 'idempotency key — repeating it returns the original send\'s counts, sends nothing twice')).action(async (opts) => {
|
|
45
|
+
try {
|
|
46
|
+
const body = await proxy('external/ad', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
team: opts.team,
|
|
49
|
+
json: {
|
|
50
|
+
body: opts.body,
|
|
51
|
+
button_label: opts.buttonLabel,
|
|
52
|
+
button_url: opts.buttonUrl,
|
|
53
|
+
image_url: opts.imageUrl,
|
|
54
|
+
external_ref: opts.externalRef,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
printJson(body);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
fail(e);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
teamOpt(ad
|
|
64
|
+
.command('status')
|
|
65
|
+
.description('Progress or final result of one ad send — look up by external_ref or ad_request_id')
|
|
66
|
+
.option('--external-ref <ref>')
|
|
67
|
+
.option('--request-id <id>')).action(async (opts) => {
|
|
68
|
+
if (!opts.externalRef && !opts.requestId)
|
|
69
|
+
fail(new Error('Pass --external-ref or --request-id.'));
|
|
70
|
+
try {
|
|
71
|
+
const body = await proxy('external/ad-status', {
|
|
72
|
+
method: 'GET',
|
|
73
|
+
team: opts.team,
|
|
74
|
+
query: { external_ref: opts.externalRef, ad_request_id: opts.requestId },
|
|
75
|
+
});
|
|
76
|
+
printJson(body);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
fail(e);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { login, logout } from '../auth.js';
|
|
2
|
+
import { whoami as fetchWhoami } from '../api/client.js';
|
|
3
|
+
import { ok, fail } from '../output.js';
|
|
4
|
+
export function registerAuthCommands(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('login')
|
|
7
|
+
.description('Pair this computer with a StaqOn workspace (device-code flow — no API key needed)')
|
|
8
|
+
.option('--label <name>', 'a name for this computer, shown to the workspace owner when they approve')
|
|
9
|
+
.action(async (opts) => {
|
|
10
|
+
try {
|
|
11
|
+
const { workspace } = await login(opts.label);
|
|
12
|
+
ok(`Paired with workspace "${workspace.name}" (${workspace.slug}).`);
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
fail(e);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
program
|
|
19
|
+
.command('logout')
|
|
20
|
+
.description('Forget this computer\'s paired session (does not revoke it on the server — use the dashboard for that)')
|
|
21
|
+
.action(async () => {
|
|
22
|
+
await logout();
|
|
23
|
+
ok('Logged out locally. The server-side session, if still live, can be revoked from the workspace\'s CLI access page.');
|
|
24
|
+
});
|
|
25
|
+
program
|
|
26
|
+
.command('whoami')
|
|
27
|
+
.description('Show the paired workspace, session info, rate-limit usage, and which commands are available')
|
|
28
|
+
.action(async () => {
|
|
29
|
+
try {
|
|
30
|
+
const body = await fetchWhoami();
|
|
31
|
+
console.log(JSON.stringify(body, null, 2));
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
fail(e);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
|
|
2
|
+
import { fail, printJson } from '../output.js';
|
|
3
|
+
function teamOpt(cmd) {
|
|
4
|
+
return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
|
|
5
|
+
}
|
|
6
|
+
export function registerMemberCommands(program) {
|
|
7
|
+
const members = program.command('members').description('Manage external team members');
|
|
8
|
+
teamOpt(members
|
|
9
|
+
.command('list')
|
|
10
|
+
.description('List this team\'s external (guest) members')
|
|
11
|
+
.option('--page <n>', 'page number', '1')
|
|
12
|
+
.option('--limit <n>', `page size, 1-${MAX_LIST_LIMIT} (default 50)`, '50')
|
|
13
|
+
.option('--search <text>', 'match against name or email')).action(async (opts) => {
|
|
14
|
+
try {
|
|
15
|
+
const body = await proxy('external/members', {
|
|
16
|
+
method: 'GET',
|
|
17
|
+
team: opts.team,
|
|
18
|
+
query: { page: opts.page, limit: opts.limit, search: opts.search },
|
|
19
|
+
});
|
|
20
|
+
printJson(body);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
fail(e);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
teamOpt(members
|
|
27
|
+
.command('get')
|
|
28
|
+
.description('Get one external member, by email or member_id')
|
|
29
|
+
.option('--email <email>')
|
|
30
|
+
.option('--member-id <id>')).action(async (opts) => {
|
|
31
|
+
try {
|
|
32
|
+
const body = await proxy('external/member', {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
team: opts.team,
|
|
35
|
+
query: { email: opts.email, member_id: opts.memberId },
|
|
36
|
+
});
|
|
37
|
+
printJson(body);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
fail(e);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
teamOpt(members
|
|
44
|
+
.command('add')
|
|
45
|
+
.description('Add (or re-add) a member to this external team')
|
|
46
|
+
.requiredOption('--name <name>')
|
|
47
|
+
.requiredOption('--email <email>')).action(async (opts) => {
|
|
48
|
+
try {
|
|
49
|
+
const body = await proxy('external/add-member', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
team: opts.team,
|
|
52
|
+
json: { name: opts.name, email: opts.email },
|
|
53
|
+
});
|
|
54
|
+
printJson(body);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
fail(e);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
teamOpt(members
|
|
61
|
+
.command('remove')
|
|
62
|
+
.description('Remove a member from this external team (archives their thread; history kept)')
|
|
63
|
+
.requiredOption('--email <email>')).action(async (opts) => {
|
|
64
|
+
try {
|
|
65
|
+
const body = await proxy('external/remove-member', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
team: opts.team,
|
|
68
|
+
json: { email: opts.email },
|
|
69
|
+
});
|
|
70
|
+
printJson(body);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
fail(e);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
teamOpt(members
|
|
77
|
+
.command('teams')
|
|
78
|
+
.description('Which external teams has a linked member joined? (looked up by external_ref)')
|
|
79
|
+
.requiredOption('--external-ref <ref>', 'the external_ref originally sent to link-invite')).action(async (opts) => {
|
|
80
|
+
try {
|
|
81
|
+
const body = await proxy('external/member-teams', {
|
|
82
|
+
method: 'GET',
|
|
83
|
+
team: opts.team,
|
|
84
|
+
query: { external_ref: opts.externalRef },
|
|
85
|
+
});
|
|
86
|
+
printJson(body);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
fail(e);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// Internal-surface member listing — same family, different endpoint/scope (no guest filter, reports role not message stats).
|
|
93
|
+
teamOpt(program
|
|
94
|
+
.command('internal-members')
|
|
95
|
+
.description('List this key\'s internal team members (role-based, no external thread stats)')
|
|
96
|
+
.option('--page <n>', 'page number', '1')
|
|
97
|
+
.option('--limit <n>', `page size, 1-${MAX_LIST_LIMIT} (default 50)`, '50')
|
|
98
|
+
.option('--search <text>', 'match against name, email or username')).action(async (opts) => {
|
|
99
|
+
try {
|
|
100
|
+
const body = await proxy('internal/members', {
|
|
101
|
+
method: 'GET',
|
|
102
|
+
team: opts.team,
|
|
103
|
+
query: { page: opts.page, limit: opts.limit, search: opts.search },
|
|
104
|
+
});
|
|
105
|
+
printJson(body);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
fail(e);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { proxy, MAX_LIST_LIMIT } from '../api/client.js';
|
|
2
|
+
import { fail, printJson } from '../output.js';
|
|
3
|
+
function teamOpt(cmd) {
|
|
4
|
+
return cmd.option('--team <idOrSlug>', 'team id or slug — only needed if the workspace has more than one eligible team');
|
|
5
|
+
}
|
|
6
|
+
export function registerMessagingCommands(program) {
|
|
7
|
+
const messages = program.command('messages').description('Send and read External team member conversations');
|
|
8
|
+
teamOpt(messages
|
|
9
|
+
.command('send')
|
|
10
|
+
.description('Send a message into a member\'s thread, as the workspace owner')
|
|
11
|
+
.requiredOption('--email <email>')
|
|
12
|
+
.requiredOption('--message <text>', 'max 60000 characters')).action(async (opts) => {
|
|
13
|
+
try {
|
|
14
|
+
const body = await proxy('external/message', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
team: opts.team,
|
|
17
|
+
json: { email: opts.email, message: opts.message },
|
|
18
|
+
});
|
|
19
|
+
printJson(body);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
fail(e);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
teamOpt(messages
|
|
26
|
+
.command('comment')
|
|
27
|
+
.description('Post a comment into a member\'s thread, attributed to the workspace (no consent link needed — writing in is the workspace\'s own authority)')
|
|
28
|
+
.requiredOption('--username <username>')
|
|
29
|
+
.requiredOption('--comment <text>', 'max 60000 characters')
|
|
30
|
+
.option('--external-ref <ref>', 'idempotency key, max 190 chars — scoped to the WHOLE WORKSPACE, not per recipient; reusing it for a different recipient silently does not reach them')).action(async (opts) => {
|
|
31
|
+
try {
|
|
32
|
+
const body = await proxy('external/comment', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
team: opts.team,
|
|
35
|
+
json: { username: opts.username, comment: opts.comment, external_ref: opts.externalRef },
|
|
36
|
+
});
|
|
37
|
+
printJson(body);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
fail(e);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
teamOpt(messages
|
|
44
|
+
.command('read')
|
|
45
|
+
.description('Read a member\'s thread history — requires an APPROVED consent link for that member (403 if not present)')
|
|
46
|
+
.requiredOption('--username <username>')
|
|
47
|
+
.option('--limit <n>', `1-${MAX_LIST_LIMIT} (default 50)`, '50')
|
|
48
|
+
.option('--before <id>', 'message id cursor — older messages, newest-first')
|
|
49
|
+
.option('--after <id>', 'message id cursor — newer messages, oldest-first (mutually exclusive with --before)')).action(async (opts) => {
|
|
50
|
+
if (opts.before && opts.after)
|
|
51
|
+
fail(new Error('Use either --before or --after, not both.'));
|
|
52
|
+
try {
|
|
53
|
+
const body = await proxy('external/messages', {
|
|
54
|
+
method: 'GET',
|
|
55
|
+
team: opts.team,
|
|
56
|
+
query: { username: opts.username, limit: opts.limit, before: opts.before, after: opts.after },
|
|
57
|
+
});
|
|
58
|
+
printJson(body);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
fail(e);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
// Internal surface: one-way feed push, NOT a conversation (no replies/reactions/attachments).
|
|
65
|
+
// Requires an APPROVED link_type='internal' consent row (stricter than External's write path).
|
|
66
|
+
teamOpt(program
|
|
67
|
+
.command('internal-message')
|
|
68
|
+
.description('Push a one-way message into a member\'s API Messages feed (internal surface — requires member consent)')
|
|
69
|
+
.requiredOption('--username <username>')
|
|
70
|
+
.requiredOption('--message <text>', 'max 60000 characters')
|
|
71
|
+
.option('--title <text>', 'max 200 characters')
|
|
72
|
+
.option('--link-url <url>', 'must be http(s)')
|
|
73
|
+
.option('--external-ref <ref>', 'idempotency key, max 190 chars — scoped per API key, not per recipient')).action(async (opts) => {
|
|
74
|
+
try {
|
|
75
|
+
const body = await proxy('internal/message', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
team: opts.team,
|
|
78
|
+
json: {
|
|
79
|
+
username: opts.username,
|
|
80
|
+
message: opts.message,
|
|
81
|
+
title: opts.title,
|
|
82
|
+
link_url: opts.linkUrl,
|
|
83
|
+
external_ref: opts.externalRef,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
printJson(body);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
fail(e);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|