@staqon/cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +311 -279
  2. package/dist/cli.js +112 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,279 +1,311 @@
1
- # @staqon/cli
2
-
3
- Command-line client for the StaqOn partner API. Send messages, post comments, run push-approval
4
- verifications and broadcast ads as your workspace, from a terminal or a script.
5
-
6
- - Requires **Node.js 20 or newer**.
7
- - Talks to `https://staqon.one` by default (override with `--base-url` or `STAQON_BASE_URL`).
8
-
9
- ## Install
10
-
11
- ```sh
12
- npm install -g @staqon/cli
13
- staqon --version
14
- ```
15
-
16
- ## Getting started
17
-
18
- 1. **The workspace owner creates an API key first.** Open your workspace at
19
- `<your-workspace-url>/workspace/settings.php?w=<your-workspace-slug>&tab=api` and, under the
20
- relevant card (Messaging API, Ad API, etc.), generate a key for the team you want the CLI to
21
- act as. Messaging and Ad keys both require a **Link** key to already exist on that same tab �
22
- set that up first if you see a "set up Link API first" notice.
23
- 2. On that same `api` tab, turn on **"Allow CLI access"** for the workspace. This is a separate
24
- switch from the API key above � it must be ON before `staqon login` can be approved. Turning it
25
- OFF immediately signs out every CLI session for that workspace.
26
- 3. Run `staqon login` from the machine or server you want to use. It prints a URL and a short code.
27
- 4. The **workspace owner** opens that URL (it's also reachable directly at `/cli`) and approves the
28
- code � they may be asked for their MPIN if their web session is old.
29
- 5. Run `staqon whoami`. It lists every key the CLI can use and which teams hold it. If a command you
30
- need isn't listed, go back to step 1 for that key type � the CLI itself can never create or see
31
- API keys, only the owner can.
32
- 6. Send your first message:
33
- ```sh
34
- staqon message send --email member@example.com -m "Hello from the CLI"
35
- ```
36
- 7. **Member approval (exit code 6).** Most calls that reach a specific member � direct messages,
37
- comments, internal messages, verification requests � require that member to have approved
38
- linking with your workspace in their own StaqOn app first. If they haven't, the CLI exits with
39
- code 6 and a message like "This member hasn't approved linking with this workspace." This isn't
40
- something the CLI or the owner can bypass � the member has to approve it on their end.
41
-
42
- ## Sign in
43
-
44
- There is no separate `init` step: `staqon login` is the whole setup.
45
-
46
- ```sh
47
- staqon login # prints a URL and a one-time code
48
- staqon login --open # also opens the URL in your browser
49
- ```
50
-
51
- 1. The CLI shows a verification URL and a code.
52
- 2. **The workspace owner** opens the URL and approves the code (they may be asked for their MPIN).
53
- 3. The CLI polls, receives a session token, and stores it.
54
-
55
- Where the token lives: the OS keychain when available, otherwise a user-only file
56
- (`credentials.json` in the `staqon` config directory: `%APPDATA%\staqon` on Windows,
57
- `$XDG_CONFIG_HOME/staqon` or `~/.config/staqon` elsewhere). The CLI says so when it has to use the file.
58
-
59
- | Command | What it does |
60
- |---|---|
61
- | `staqon whoami` | Workspace, session expiry, rate limits, and which teams can do what |
62
- | `staqon refresh` | Rotate the session token (the old one works for 5 more minutes) |
63
- | `staqon logout` | Forget the local token. Revoking it is done by the owner in the web UI |
64
-
65
- ### Running unattended (a server sending automatic alerts)
66
-
67
- Session tokens are sliding-expiry: **90 days of inactivity invalidates them, and every call extends
68
- that window**, up to a hard cap of **1 year from the original login**. For a server that calls the
69
- CLI regularly (e.g. daily alerts), the token effectively never goes stale on its own � but you should
70
- still rotate it well before the 90-day mark, in case the server is ever idle for a stretch:
71
-
72
- - Run `staqon refresh` on a schedule (cron / Task Scheduler) every 30-60 days. It rotates the token;
73
- the old one keeps working for 5 more minutes, so overlap is safe. Save the new token exactly as
74
- `staqon` does (OS keychain, or the user-only `credentials.json` file) � don't hardcode it elsewhere.
75
- - Once a year, at the hard cap, `refresh` will stop working and the command exits with an auth error.
76
- At that point a human (the workspace owner, or whoever can reach the approval URL) has to run
77
- `staqon login` again � there is no way around this step by design, since it's what lets the owner
78
- revoke access.
79
- - Do **not** try to use the partner's raw API key directly as a substitute for a long-lived token:
80
- the CLI protocol deliberately never hands the API key to the CLI session at all (see the server
81
- contract) � the CLI only ever gets a revocable, rate-limited session token, scoped to the CLI's own
82
- separate limits, so the partner's production API key headroom is never affected by CLI traffic.
83
- `STAQON_TOKEN` (below) is the only supported "skip login" path, and it still expires the same way.
84
-
85
- For CI, set `STAQON_TOKEN` instead of logging in (and `STAQON_BASE_URL` if not production). Tokens are
86
- only ever sent to the host that issued them, and the CLI refuses to send one to a different `--base-url`.
87
- `refresh` is not available for `STAQON_TOKEN` sessions -- if you use this path, you are responsible for
88
- replacing the value yourself before it expires (same 90-day sliding / 1-year hard cap rules apply).
89
-
90
- ## Teams
91
-
92
- Most calls are made on behalf of a **team** that holds the right API key. Selection order:
93
-
94
- 1. `--team <id|slug>` on the command line
95
- 2. the saved default (`staqon use <team>`; `staqon use` shows it; `staqon use --clear` removes it)
96
- 3. if the server answers `team_required`:
97
- - in a terminal, you are asked to pick one (and can save it as the default);
98
- - otherwise the CLI exits with code 5 and lists the eligible teams. It never picks one for you.
99
-
100
- `team_not_eligible` (exit 5) means the chosen team has no key for that call; the error lists the teams that do.
101
- `staqon whoami` shows which teams can send messages and ads.
102
-
103
- ## Commands
104
-
105
- Every command accepts the global options below. `--to` takes an email **or** a username and converts it if the
106
- endpoint wants the other kind (see [Identity](#identity-email-vs-username)).
107
-
108
- | Global option | |
109
- |---|---|
110
- | `--json` | Machine-readable: raw response body on stdout, errors as JSON on stderr |
111
- | `--team <team>` | Team to act as |
112
- | `--base-url <url>` | API host |
113
- | `--verbose` | Print request lines and trace ids to stderr |
114
- | `--no-color` | Disable colors |
115
-
116
- ### Messaging
117
-
118
- ```sh
119
- staqon message send --email ada@example.com -m "Hello"
120
- staqon message send --to ada_username -m "Hello" # username resolved to an email
121
- staqon message send --email ada@example.com --message-file note.txt # or -m - to read stdin
122
-
123
- staqon messages --username ada_username --limit 20 # read a thread (needs the member's approved link)
124
- staqon messages --username ada_username --before 152698 # page backwards (--after pages forwards)
125
-
126
- staqon comment --username ada_username -c "Looks good" --external-ref order-1042
127
- staqon comment --username ada_username -c "Status" --card @card.json
128
-
129
- staqon internal message --email ada@example.com -m "FYI" --title "Heads up" --link-url https://example.com
130
- ```
131
-
132
- - Message and comment text: up to 60,000 characters. `--external-ref` (max 190) is an idempotency key: a repeat is
133
- rejected and nothing is posted.
134
- - `messages --limit` is 1-200 (default 50). Attachment URLs in results are signed and **expire after 1 day**;
135
- re-fetch the thread for fresh ones.
136
- - `internal message` sends to a member's one-way API Messages feed and needs their approval.
137
-
138
- ### Members
139
-
140
- ```sh
141
- staqon members list --search "Ada" --limit 50 --page 2
142
- staqon member ada@example.com # or: staqon member --member-id 311
143
- staqon internal members --search ada # internal directory (search also matches username)
144
- ```
145
-
146
- `members list` shows email and username side by side. `--limit` is **1-100**: the directory is slow server-side
147
- (about 0.35 s per row) and larger pages hit the gateway timeout. Member searches match **name or email**, not username.
148
-
149
- ### Verification (push approval)
150
-
151
- ```sh
152
- staqon verify start --username ada_username --action "Approve payout #1042" \
153
- --context "Payout of 5,000 to account ending 1234" --ttl 60
154
- staqon verify check <verification_id> # prints Verified. / Not verified: <reason>
155
- staqon verify check <verification_id> --strict # exit 1 unless verified
156
- staqon verify cancel <verification_id>
157
- ```
158
-
159
- - `--ttl` is clamped to 30-180 seconds (default 30). `--action` max 100 chars, `--context` max 500.
160
- - `check` reasons include `pending`, `declined`, `timeout` and `cancelled`. Without `--strict`, `check` exits 0 for any
161
- answer; with it, only a verified result exits 0.
162
- - `cancel` after the member answered returns exit 1 with "Too late to cancel"; after expiry, "already expired".
163
- - Needs the member's approved link.
164
-
165
- ### Ads
166
-
167
- Ads are delivered asynchronously by a background worker.
168
-
169
- ```sh
170
- staqon ad image banner.png # upload only; nothing is delivered
171
- staqon ad create --body-file ad.txt --button-label "Open" --button-url https://example.com --image banner.png --wait
172
- staqon ad status <ad_request_id> # or --external-ref <ref>
173
- ```
174
-
175
- - Images: JPEG, PNG or WebP (checked on file contents), **max 300 KB**; the server crops to 16:9 and downsizes to
176
- at most 1280x720, so the stored size differs from your file.
177
- - `ad create`: body 50-900 characters; success is HTTP 202 plus an `ad_request_id`. `--wait` polls every 5 s for up
178
- to 10 minutes. `--external-ref` (max 150) is an idempotency key.
179
-
180
- ### Raw access
181
-
182
- ```sh
183
- staqon api external/members -q search=ada -q limit=10
184
- staqon api external/message -d '{"email":"ada@example.com","message":"Hi"}'
185
- ```
186
-
187
- Calls any CLI-proxy endpoint directly: `external/{message,messages,comment,members,member,ad,ad-image,ad-status,
188
- staqon-verify-start,staqon-verify-check,staqon-verify-cancel}` and `internal/{message,members}`.
189
- `-d` takes inline JSON, `@file.json` or `-` for stdin; `-F k=v` / `-F k=@path` sends multipart.
190
-
191
- ### Cards
192
-
193
- `--card` (comment, internal message, verify start) takes a JSON object, inline, `@file.json` or `-`. The CLI
194
- validates it before sending: required `header_color` (one of the palette colors), `title` (200) and `body` (2000);
195
- optional `meta_title`, `sub_body`, `icon`, `button` (`label`, `url`; not allowed on verification cards) and `table`
196
- (2-6 columns, 1-500 rows). Unknown keys are rejected.
197
-
198
- ## Identity: email vs. username
199
-
200
- The endpoints disagree: `message send` wants an email, while `messages`, `comment`, `verify` want a username. Give
201
- `--email`, `--username` or `--to <either>`; the CLI converts when needed.
202
-
203
- - email -> username is one fast lookup.
204
- - username -> email tries the directory search, then looks up the member's name via their thread and searches by
205
- name (fast, but only for members linked to your workspace), then **pages through the whole directory**. That last
206
- step can take minutes on a large workspace. **If you know the email, pass `--email`.**
207
-
208
- ## The consent model (why some calls return 403)
209
-
210
- Members control what a workspace may do. For `messages`, `comment`, `verify *` and `internal message`, the member must
211
- have approved the link with your workspace in their app. If they have not, the server answers 403 and the CLI prints
212
- a friendly message and exits with **code 6**:
213
-
214
- ```
215
- Error: This member hasn't approved linking with this workspace.
216
- trace id: ...
217
- Server said: No approved link for this member
218
- The member has to approve the link in their app; the CLI can't do it for them.
219
- ```
220
-
221
- The CLI can't work around it. Ask the member to approve, then retry. A 403 that is *not* about consent (CLI access
222
- switched off, workspace inactive) exits with code 3 instead.
223
-
224
- ## Exit codes
225
-
226
- | Code | Meaning |
227
- |---|---|
228
- | 0 | Success |
229
- | 1 | General failure: server rejected the request (4xx/5xx), network error or timeout, `verify check --strict` not verified, cancel too late or expired |
230
- | 2 | Usage error: bad flags or input, caught before anything is sent; also a token used against a different host |
231
- | 3 | Authentication: not logged in, session expired/revoked (401), or access denied (non-consent 403) |
232
- | 4 | Rate limited (429): session 30/min, 3,000/day, or the API key's own ceiling. `Retry after` is shown when sent |
233
- | 5 | Team or key problem: `team_required` (non-interactive), `team_not_eligible`, `no_key` |
234
- | 6 | Member has not approved the link (consent 403) |
235
- | 130 | Interrupted (Ctrl-C) |
236
-
237
- The CLI never retries on its own, except re-asking once after you pick a team interactively.
238
- Requests time out after 30 s (120 s for member directory listings); override with `STAQON_TIMEOUT_MS`.
239
-
240
- ## `--json`
241
-
242
- With `--json`, success prints the server's response body, unchanged, on stdout. Errors print **one JSON object on
243
- stderr** and nothing on stdout:
244
-
245
- ```json
246
- {
247
- "error": "This member hasn't approved linking with this workspace.",
248
- "exit_code": 6,
249
- "status": 403,
250
- "trace_id": "30becdb4-daac-46b0-9fb6-395249329632",
251
- "body": { "ok": false, "error": "No approved link for this member" },
252
- "hint": "Server said: No approved link for this member\n..."
253
- }
254
- ```
255
-
256
- `status`, `trace_id` and `body` appear for errors that came from the server; client-side errors have only
257
- `error`, `exit_code` and optionally `hint`. In `--json` mode the CLI never prompts, so team selection must be
258
- explicit (`--team` or a saved default). Tokens are redacted (`stq_cli_***`) from all output. Quote the `trace_id`
259
- when contacting support.
260
-
261
- ## Environment variables
262
-
263
- | Variable | Purpose |
264
- |---|---|
265
- | `STAQON_TOKEN` | Use this session token instead of the stored login |
266
- | `STAQON_BASE_URL` | API host (default `https://staqon.one`) |
267
- | `STAQON_TIMEOUT_MS` | Request timeout in milliseconds |
268
-
269
- ## Development
270
-
271
- ```sh
272
- npm install
273
- npm run build # bundle to dist/
274
- npm test # 52 mocked tests; no network, no login needed
275
- npm run test:live # optional; needs a login: STAQON_LIVE_USERNAME=... STAQON_LIVE_EMAIL=... (slow case: SLOW=1)
276
- ```
277
-
278
- The mocked tests run the real CLI against a local fake server with an isolated config directory, so they never touch
279
- your real login or keychain.
1
+ # @staqon/cli
2
+
3
+ Command-line client for the StaqOn partner API. Send messages, post comments, run push-approval
4
+ verifications and broadcast ads as your workspace, from a terminal or a script.
5
+
6
+ - Requires **Node.js 20 or newer**.
7
+ - Talks to `https://staqon.one` by default (override with `--base-url` or `STAQON_BASE_URL`).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install -g @staqon/cli
13
+ staqon --version
14
+ ```
15
+
16
+ ## Getting started
17
+
18
+ 1. **The workspace owner creates an API key first.** Open your workspace at
19
+ `<your-workspace-url>/workspace/settings.php?w=<your-workspace-slug>&tab=api` and, under the
20
+ relevant card (Messaging API, Ad API, etc.), generate a key for the team you want the CLI to
21
+ act as. Messaging and Ad keys both require a **Link** key to already exist on that same tab —
22
+ set that up first if you see a "set up Link API first" notice.
23
+ 2. On that same `api` tab, turn on **"Allow CLI access"** for the workspace. This is a separate
24
+ switch from the API key above — it must be ON before `staqon login` can be approved. Turning it
25
+ OFF immediately signs out every CLI session for that workspace.
26
+ 3. Run `staqon login` from the machine or server you want to use. It prints a URL and a short code.
27
+ 4. The **workspace owner** opens that URL (it's also reachable directly at `/cli`) and approves the
28
+ code — they may be asked for their MPIN if their web session is old.
29
+ 5. Run `staqon whoami`. It lists every key the CLI can use and which teams hold it. If a command you
30
+ need isn't listed, go back to step 1 for that key type — the CLI itself can never create or see
31
+ API keys, only the owner can.
32
+ 6. Send your first message:
33
+ ```sh
34
+ staqon message send --email member@example.com -m "Hello from the CLI"
35
+ ```
36
+ 7. **Member approval (exit code 6).** Most calls that reach a specific member — direct messages,
37
+ comments, internal messages, verification requests — require that member to have approved
38
+ linking with your workspace in their own StaqOn app first. If they haven't, the CLI exits with
39
+ code 6 and a message like "This member hasn't approved linking with this workspace." This isn't
40
+ something the CLI or the owner can bypass — the member has to approve it on their end.
41
+
42
+ ## Sign in
43
+
44
+ There is no separate `init` step: `staqon login` is the whole setup.
45
+
46
+ ```sh
47
+ staqon login # prints a URL and a one-time code
48
+ staqon login --open # also opens the URL in your browser
49
+ ```
50
+
51
+ 1. The CLI shows a verification URL and a code.
52
+ 2. **The workspace owner** opens the URL and approves the code (they may be asked for their MPIN).
53
+ 3. The CLI polls, receives a session token, and stores it.
54
+
55
+ Where the token lives: the OS keychain when available, otherwise a user-only file
56
+ (`credentials.json` in the `staqon` config directory: `%APPDATA%\staqon` on Windows,
57
+ `$XDG_CONFIG_HOME/staqon` or `~/.config/staqon` elsewhere). The CLI says so when it has to use the file.
58
+
59
+ | Command | What it does |
60
+ |---|---|
61
+ | `staqon whoami` | Workspace, session expiry, rate limits, and which teams can do what |
62
+ | `staqon refresh` | Rotate the session token (the old one works for 5 more minutes) |
63
+ | `staqon logout` | Forget the local token. Revoking it is done by the owner in the web UI |
64
+
65
+ ### Running unattended (a server sending automatic alerts)
66
+
67
+ Session tokens are sliding-expiry: **90 days of inactivity invalidates them, and every call extends
68
+ that window**, up to a hard cap of **1 year from the original login**. For a server that calls the
69
+ CLI regularly (e.g. daily alerts), the token effectively never goes stale on its own — but you should
70
+ still rotate it well before the 90-day mark, in case the server is ever idle for a stretch:
71
+
72
+ - Run `staqon refresh` on a schedule (cron / Task Scheduler) every 30-60 days. It rotates the token;
73
+ the old one keeps working for 5 more minutes, so overlap is safe. Save the new token exactly as
74
+ `staqon` does (OS keychain, or the user-only `credentials.json` file) — don't hardcode it elsewhere.
75
+ - Once a year, at the hard cap, `refresh` will stop working and the command exits with an auth error.
76
+ At that point a human (the workspace owner, or whoever can reach the approval URL) has to run
77
+ `staqon login` again — there is no way around this step by design, since it's what lets the owner
78
+ revoke access.
79
+ - Do **not** try to use the partner's raw API key directly as a substitute for a long-lived token:
80
+ the CLI protocol deliberately never hands the API key to the CLI session at all (see the server
81
+ contract) — the CLI only ever gets a revocable, rate-limited session token, scoped to the CLI's own
82
+ separate limits, so the partner's production API key headroom is never affected by CLI traffic.
83
+ `STAQON_TOKEN` (below) is the only supported "skip login" path, and it still expires the same way.
84
+
85
+ For CI, set `STAQON_TOKEN` instead of logging in (and `STAQON_BASE_URL` if not production). Tokens are
86
+ only ever sent to the host that issued them, and the CLI refuses to send one to a different `--base-url`.
87
+ `refresh` is not available for `STAQON_TOKEN` sessions -- if you use this path, you are responsible for
88
+ replacing the value yourself before it expires (same 90-day sliding / 1-year hard cap rules apply).
89
+
90
+ ## Teams
91
+
92
+ Most calls are made on behalf of a **team** that holds the right API key. Selection order:
93
+
94
+ 1. `--team <id|slug>` on the command line
95
+ 2. the saved default (`staqon use <team>`; `staqon use` shows it; `staqon use --clear` removes it)
96
+ 3. if the server answers `team_required`:
97
+ - in a terminal, you are asked to pick one (and can save it as the default);
98
+ - otherwise the CLI exits with code 5 and lists the eligible teams. It never picks one for you.
99
+
100
+ `team_not_eligible` (exit 5) means the chosen team has no key for that call; the error lists the teams that do.
101
+ `staqon whoami` shows which teams can send messages and ads.
102
+
103
+ ## Commands
104
+
105
+ Every command accepts the global options below. `--to` takes an email **or** a username and converts it if the
106
+ endpoint wants the other kind (see [Identity](#identity-email-vs-username)).
107
+
108
+ | Global option | |
109
+ |---|---|
110
+ | `--json` | Machine-readable: raw response body on stdout, errors as JSON on stderr |
111
+ | `--team <team>` | Team to act as |
112
+ | `--base-url <url>` | API host |
113
+ | `--verbose` | Print request lines and trace ids to stderr |
114
+ | `--no-color` | Disable colors |
115
+
116
+ ### Messaging
117
+
118
+ ```sh
119
+ staqon message send --email ada@example.com -m "Hello"
120
+ staqon message send --to ada_username -m "Hello" # username resolved to an email
121
+ staqon message send --email ada@example.com --message-file note.txt # or -m - to read stdin
122
+
123
+ staqon messages --username ada_username --limit 20 # read a thread (needs the member's approved link)
124
+ staqon messages --username ada_username --before 152698 # page backwards (--after pages forwards)
125
+
126
+ staqon comment --username ada_username -c "Looks good" --external-ref order-1042
127
+ staqon comment --username ada_username -c "Status" --card @card.json
128
+
129
+ staqon internal message --email ada@example.com -m "FYI" --title "Heads up" --link-url https://example.com
130
+ ```
131
+
132
+ - Message and comment text: up to 60,000 characters. `--external-ref` (max 190) is an idempotency key: a repeat is
133
+ rejected and nothing is posted.
134
+ - `messages --limit` is 1-200 (default 50). Attachment URLs in results are signed and **expire after 1 day**;
135
+ re-fetch the thread for fresh ones.
136
+ - `internal message` sends to a member's one-way API Messages feed and needs their approval.
137
+
138
+ ### Members
139
+
140
+ ```sh
141
+ staqon members list --search "Ada" --limit 50 --page 2
142
+ staqon member ada@example.com # or: staqon member --member-id 311
143
+ staqon internal members --search ada # internal directory (search also matches username)
144
+
145
+ staqon members add --name "Ada Lovelace" --email ada@example.com --phone +919812345678
146
+ staqon members remove --email ada@example.com
147
+ staqon members teams ext-ref-1042 # external teams a linked member has joined
148
+ ```
149
+
150
+ `members list` shows email and username side by side. `--limit` is **1-100**: the directory is slow server-side
151
+ (about 0.35 s per row) and larger pages hit the gateway timeout. Member searches match **name or email**, not username.
152
+
153
+ `members add` creates the member if the email is new, otherwise links them; the response says which (`status`:
154
+ `invited` or `existing`). `members remove` archives their thread rather than deleting history. `members teams`
155
+ looks up by the `external_ref` you sent to `staqon-verify-start`/link-invite, not by email.
156
+
157
+ ### Escalations
158
+
159
+ ```sh
160
+ staqon escalation create --title "Payout stuck" --description "Payout #1042 failed twice" \
161
+ --due-date 2026-12-01 --priority high
162
+ staqon escalation list --page 1 --limit 20 --status open
163
+ staqon escalation get --uuid 3f9b2c10-... # or: staqon escalation get --matter-id 882
164
+ ```
165
+
166
+ - `create`: `--title` and `--due-date` (`YYYY-MM-DD`) are required; `--description` or `--description-file` (not
167
+ both); `--priority` is optional. Success returns a `matter_id` and `uuid`.
168
+ - `list` results expose `uuid`, not `matter_id` — use `--uuid` with `get` to look up a single matter from a list.
169
+ - `get` takes exactly one of `--uuid` / `--matter-id`.
170
+
171
+ ### Status & webhooks
172
+
173
+ ```sh
174
+ staqon status # key context for this external team: workspace, members, rate limit
175
+ staqon internal status # same, for the internal team
176
+ staqon webhook status # webhook health for this external team
177
+ staqon internal webhook-status
178
+ ```
179
+
180
+ ### Verification (push approval)
181
+
182
+ ```sh
183
+ staqon verify start --username ada_username --action "Approve payout #1042" \
184
+ --context "Payout of 5,000 to account ending 1234" --ttl 60
185
+ staqon verify check <verification_id> # prints Verified. / Not verified: <reason>
186
+ staqon verify check <verification_id> --strict # exit 1 unless verified
187
+ staqon verify cancel <verification_id>
188
+ ```
189
+
190
+ - `--ttl` is clamped to 30-180 seconds (default 30). `--action` max 100 chars, `--context` max 500.
191
+ - `check` reasons include `pending`, `declined`, `timeout` and `cancelled`. Without `--strict`, `check` exits 0 for any
192
+ answer; with it, only a verified result exits 0.
193
+ - `cancel` after the member answered returns exit 1 with "Too late to cancel"; after expiry, "already expired".
194
+ - Needs the member's approved link.
195
+
196
+ ### Ads
197
+
198
+ Ads are delivered asynchronously by a background worker.
199
+
200
+ ```sh
201
+ staqon ad image banner.png # upload only; nothing is delivered
202
+ staqon ad create --body-file ad.txt --button-label "Open" --button-url https://example.com --image banner.png --wait
203
+ staqon ad status <ad_request_id> # or --external-ref <ref>
204
+ ```
205
+
206
+ - Images: JPEG, PNG or WebP (checked on file contents), **max 300 KB**; the server crops to 16:9 and downsizes to
207
+ at most 1280x720, so the stored size differs from your file.
208
+ - `ad create`: body 50-900 characters; success is HTTP 202 plus an `ad_request_id`. `--wait` polls every 5 s for up
209
+ to 10 minutes. `--external-ref` (max 150) is an idempotency key.
210
+
211
+ ### Raw access
212
+
213
+ ```sh
214
+ staqon api external/members -q search=ada -q limit=10
215
+ staqon api external/message -d '{"email":"ada@example.com","message":"Hi"}'
216
+ ```
217
+
218
+ Calls any CLI-proxy endpoint directly: `external/{message,messages,comment,members,member,ad,ad-image,ad-status,
219
+ staqon-verify-start,staqon-verify-check,staqon-verify-cancel,add-member,remove-member,member-teams,escalation,escalations,
220
+ webhook-status,status}` and `internal/{message,members,webhook-status,status}`.
221
+ `-d` takes inline JSON, `@file.json` or `-` for stdin; `-F k=v` / `-F k=@path` sends multipart.
222
+
223
+ ### Cards
224
+
225
+ `--card` (comment, internal message, verify start) takes a JSON object, inline, `@file.json` or `-`. The CLI
226
+ validates it before sending: required `header_color` (one of the palette colors), `title` (200) and `body` (2000);
227
+ optional `meta_title`, `sub_body`, `icon`, `button` (`label`, `url`; not allowed on verification cards) and `table`
228
+ (2-6 columns, 1-500 rows). Unknown keys are rejected.
229
+
230
+ ## Identity: email vs. username
231
+
232
+ The endpoints disagree: `message send` wants an email, while `messages`, `comment`, `verify` want a username. Give
233
+ `--email`, `--username` or `--to <either>`; the CLI converts when needed.
234
+
235
+ - email -> username is one fast lookup.
236
+ - username -> email tries the directory search, then looks up the member's name via their thread and searches by
237
+ name (fast, but only for members linked to your workspace), then **pages through the whole directory**. That last
238
+ step can take minutes on a large workspace. **If you know the email, pass `--email`.**
239
+
240
+ ## The consent model (why some calls return 403)
241
+
242
+ Members control what a workspace may do. For `messages`, `comment`, `verify *` and `internal message`, the member must
243
+ have approved the link with your workspace in their app. If they have not, the server answers 403 and the CLI prints
244
+ a friendly message and exits with **code 6**:
245
+
246
+ ```
247
+ Error: This member hasn't approved linking with this workspace.
248
+ trace id: ...
249
+ Server said: No approved link for this member
250
+ The member has to approve the link in their app; the CLI can't do it for them.
251
+ ```
252
+
253
+ The CLI can't work around it. Ask the member to approve, then retry. A 403 that is *not* about consent (CLI access
254
+ switched off, workspace inactive) exits with code 3 instead.
255
+
256
+ ## Exit codes
257
+
258
+ | Code | Meaning |
259
+ |---|---|
260
+ | 0 | Success |
261
+ | 1 | General failure: server rejected the request (4xx/5xx), network error or timeout, `verify check --strict` not verified, cancel too late or expired |
262
+ | 2 | Usage error: bad flags or input, caught before anything is sent; also a token used against a different host |
263
+ | 3 | Authentication: not logged in, session expired/revoked (401), or access denied (non-consent 403) |
264
+ | 4 | Rate limited (429): session 30/min, 3,000/day, or the API key's own ceiling. `Retry after` is shown when sent |
265
+ | 5 | Team or key problem: `team_required` (non-interactive), `team_not_eligible`, `no_key` |
266
+ | 6 | Member has not approved the link (consent 403) |
267
+ | 130 | Interrupted (Ctrl-C) |
268
+
269
+ The CLI never retries on its own, except re-asking once after you pick a team interactively.
270
+ Requests time out after 30 s (120 s for member directory listings); override with `STAQON_TIMEOUT_MS`.
271
+
272
+ ## `--json`
273
+
274
+ With `--json`, success prints the server's response body, unchanged, on stdout. Errors print **one JSON object on
275
+ stderr** and nothing on stdout:
276
+
277
+ ```json
278
+ {
279
+ "error": "This member hasn't approved linking with this workspace.",
280
+ "exit_code": 6,
281
+ "status": 403,
282
+ "trace_id": "30becdb4-daac-46b0-9fb6-395249329632",
283
+ "body": { "ok": false, "error": "No approved link for this member" },
284
+ "hint": "Server said: No approved link for this member\n..."
285
+ }
286
+ ```
287
+
288
+ `status`, `trace_id` and `body` appear for errors that came from the server; client-side errors have only
289
+ `error`, `exit_code` and optionally `hint`. In `--json` mode the CLI never prompts, so team selection must be
290
+ explicit (`--team` or a saved default). Tokens are redacted (`stq_cli_***`) from all output. Quote the `trace_id`
291
+ when contacting support.
292
+
293
+ ## Environment variables
294
+
295
+ | Variable | Purpose |
296
+ |---|---|
297
+ | `STAQON_TOKEN` | Use this session token instead of the stored login |
298
+ | `STAQON_BASE_URL` | API host (default `https://staqon.one`) |
299
+ | `STAQON_TIMEOUT_MS` | Request timeout in milliseconds |
300
+
301
+ ## Development
302
+
303
+ ```sh
304
+ npm install
305
+ npm run build # bundle to dist/
306
+ npm test # 52 mocked tests; no network, no login needed
307
+ npm run test:live # optional; needs a login: STAQON_LIVE_USERNAME=... STAQON_LIVE_EMAIL=... (slow case: SLOW=1)
308
+ ```
309
+
310
+ The mocked tests run the real CLI against a local fake server with an isolated config directory, so they never touch
311
+ your real login or keychain.
package/dist/cli.js CHANGED
@@ -642,8 +642,17 @@ var ENDPOINTS = [
642
642
  "external/staqon-verify-start",
643
643
  "external/staqon-verify-check",
644
644
  "external/staqon-verify-cancel",
645
+ "external/add-member",
646
+ "external/remove-member",
647
+ "external/member-teams",
648
+ "external/status",
649
+ "external/escalation",
650
+ "external/escalations",
651
+ "external/webhook-status",
645
652
  "internal/message",
646
- "internal/members"
653
+ "internal/members",
654
+ "internal/webhook-status",
655
+ "internal/status"
647
656
  ];
648
657
  function kv(items, flag) {
649
658
  return (items ?? []).map((s) => {
@@ -977,6 +986,31 @@ function registerTyped(program2) {
977
986
  const r = await proxy({ ep: "external/members", method: "GET", query: memberQuery(o), timeoutMs: LIST_TIMEOUT_MS });
978
987
  show(r, (b) => memberTable(b, "last_message_at"));
979
988
  });
989
+ members.command("add").description("Add a member to this team (creates the user if the email is new)").option("--name <name>", "full name (required)").option("--email <email>", "email address (required)").option("--phone <phone>", "phone number, optional").action(async (o) => {
990
+ const name = required("--name", o["name"]);
991
+ const email = required("--email", o["email"]);
992
+ const json = { name, email };
993
+ if (o["phone"]) json["phone"] = o["phone"];
994
+ const r = await proxy({ ep: "external/add-member", json });
995
+ show(
996
+ r,
997
+ (b) => ok(`${b["status"] === "existing" ? "Already a member" : "Added"}. member_id=${b["member_id"]} username=${b["username"]}${b["conversation_id"] ? ` conversation_id=${b["conversation_id"]}` : ""} status=${b["status"]}`)
998
+ );
999
+ });
1000
+ members.command("remove").description("Remove a member from this team (archives their thread, keeps history)").option("--email <email>", "email address (required)").action(async (o) => {
1001
+ const email = required("--email", o["email"]);
1002
+ const r = await proxy({ ep: "external/remove-member", json: { email } });
1003
+ show(r, (b) => ok(`Removed. member_id=${b["member_id"]}`));
1004
+ });
1005
+ members.command("teams <external_ref>").description("Which external teams (in this workspace) has a linked member joined? Looks up by the external_ref sent to link-invite").action(async (externalRef) => {
1006
+ maxLen("external_ref", externalRef, 255);
1007
+ const r = await proxy({ ep: "external/member-teams", method: "GET", query: { external_ref: externalRef } });
1008
+ show(r, (b) => {
1009
+ const rows = (b["teams"] ?? []).map((t) => [String(t["id"]), t["name"] ?? "", t["role"] ?? "", t["joined_at"] ?? ""]);
1010
+ if (!rows.length) return void out(pc4.dim("No external teams."));
1011
+ table(rows, ["ID", "NAME", "ROLE", "JOINED_AT"]);
1012
+ });
1013
+ });
980
1014
  program2.command("member [email-or-id]").description("Look up one member by email or member id").option("--email <email>", "look up by email").option("--member-id <id>", "look up by member id").action(async (arg, o) => {
981
1015
  const given = [arg, o["email"], o["memberId"]].filter((x) => x);
982
1016
  if (given.length !== 1) throw usage("Give exactly one of: <email-or-id>, --email, --member-id.");
@@ -994,6 +1028,73 @@ function registerTyped(program2) {
994
1028
  out(`${pc4.bold(k.padEnd(14))} ${m[k] ?? ""}`);
995
1029
  });
996
1030
  });
1031
+ program2.command("status").description("Key context and usage for this (external) team").action(async () => {
1032
+ const r = await proxy({ ep: "external/status", method: "GET" });
1033
+ show(r, (b) => {
1034
+ for (const k of ["workspace", "team", "team_type", "total_members", "active_members", "api_key_usage", "rate_limit", "rate_remaining"])
1035
+ out(`${pc4.bold(k.padEnd(16))} ${b[k] ?? ""}`);
1036
+ });
1037
+ });
1038
+ const escalation = program2.command("escalation").description("Raise and read escalation matters against your team's members");
1039
+ withWho(escalation.command("create").description("Raise an escalation against a member in this team")).option("--title <title>", "at least 2 chars (required)").option("--description <text>", 'required; "-" reads stdin').option("--description-file <path>", "read the description from a file").option("--priority <level>", "low|medium|high|critical (default medium)").option("--due-date <YYYY-MM-DD>", "must be a future date (required)").action(async (o) => {
1040
+ const title = required("--title", o["title"]);
1041
+ if (title.length < 2) throw usage("--title must be at least 2 characters.");
1042
+ const description = await textFrom("description", o["description"], o["descriptionFile"]);
1043
+ if (!description) throw usage("--description (or --description-file) is required.");
1044
+ const dueDate = required("--due-date", o["dueDate"]);
1045
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) throw usage("--due-date must be YYYY-MM-DD.");
1046
+ if (o["priority"] && !["low", "medium", "high", "critical"].includes(o["priority"])) throw usage("--priority must be one of: low, medium, high, critical.");
1047
+ const w = who(o);
1048
+ if (!!w.email === !!w.username && !w.to) throw usage("Give exactly one of --email or --username (or --to).");
1049
+ const json = { title, description, due_date: dueDate };
1050
+ if (o["priority"]) json["priority"] = o["priority"];
1051
+ if (w.email) json["email"] = w.email;
1052
+ if (w.username) json["username"] = w.username;
1053
+ if (w.to) {
1054
+ const email = await resolveIdentity(w, "email").catch(() => void 0);
1055
+ if (email) json["email"] = email;
1056
+ else json["username"] = w.to;
1057
+ }
1058
+ const r = await proxy({ ep: "external/escalation", json });
1059
+ show(r, (b) => {
1060
+ ok(`Raised. matter_id=${b["matter_id"]} uuid=${b["uuid"]}`);
1061
+ out(`Check it with: staqon escalation get --uuid ${b["uuid"]}`);
1062
+ });
1063
+ });
1064
+ escalation.command("list").description("List escalation matters raised against this team's members").option("--page <n>", "page number", intOpt("--page", 1, 1e6)).option("--limit <n>", "1-200 (default 50)", intOpt("--limit", 1, 200)).option("--status <status>", "open|in_progress|resolved").action(async (o) => {
1065
+ const query = {};
1066
+ if (o["page"]) query["page"] = String(o["page"]);
1067
+ if (o["limit"]) query["limit"] = String(o["limit"]);
1068
+ if (o["status"]) query["status"] = o["status"];
1069
+ const r = await proxy({ ep: "external/escalations", method: "GET", query });
1070
+ show(r, (b) => {
1071
+ const rows = (b["matters"] ?? []).map((m) => [m["uuid"], m["title"], m["status"], m["priority"], m["due_date"], m["target_username"] ?? ""]);
1072
+ table(rows, ["UUID", "TITLE", "STATUS", "PRIORITY", "DUE_DATE", "MEMBER"]);
1073
+ out(pc4.dim(`page ${b["page"]}/${b["pages"]}, ${b["total"]} total`));
1074
+ });
1075
+ });
1076
+ escalation.command("get").description("Get one escalation matter by uuid or matter id").option("--uuid <uuid>", 'look up by uuid (as shown in "escalation list")').option("--matter-id <id>", "look up by numeric matter id").action(async (o) => {
1077
+ if (!!o["uuid"] === !!o["matterId"]) throw usage("Give exactly one of --uuid, --matter-id.");
1078
+ const query = o["uuid"] ? { uuid: o["uuid"] } : { matter_id: o["matterId"] };
1079
+ const r = await proxy({ ep: "external/escalations", method: "GET", query });
1080
+ show(r, (b) => {
1081
+ const m = obj3(b["matter"]);
1082
+ for (const k of ["uuid", "title", "description", "status", "priority", "due_date", "resolved_at", "comment_count", "last_activity_at", "created_at", "target_username"])
1083
+ out(`${pc4.bold(k.padEnd(16))} ${m[k] ?? ""}`);
1084
+ });
1085
+ });
1086
+ const webhookRow = (h) => [h["url"], (h["events"] ?? []).join(","), h["is_active"] ? "yes" : "NO", String(h["failure_count"]), h["last_triggered_at"] ?? "-", h["created_at"] ?? ""];
1087
+ const webhookHuman = (b) => {
1088
+ const rows = (b["webhooks"] ?? []).map(webhookRow);
1089
+ if (!rows.length) return void out(pc4.dim("No webhooks configured for this team."));
1090
+ table(rows, ["URL", "EVENTS", "ACTIVE", "FAILURES", "LAST_TRIGGERED", "CREATED"]);
1091
+ const dead = (b["webhooks"] ?? []).filter((h) => !h["is_active"]);
1092
+ if (dead.length) out(pc4.yellow(`${dead.length} webhook(s) auto-disabled after 10 consecutive failures.`));
1093
+ };
1094
+ const webhook = program2.command("webhook").description("Webhook health for this team");
1095
+ webhook.command("status").description("Webhook health for this (external) team").action(async () => {
1096
+ show(await proxy({ ep: "external/webhook-status", method: "GET" }), webhookHuman);
1097
+ });
997
1098
  const internal = program2.command("internal").description("Internal (one-way API Messages feed)");
998
1099
  withWho(internal.command("message").description("Send a one-way message to a member's API Messages feed (needs their approval)")).option("-m, --message <text>", 'message text, max 60000 chars; "-" reads stdin').option("--message-file <path>", "read the message from a file").option("--title <title>", "max 200 chars").option("--external-ref <ref>", "idempotency key, max 190 chars").option("--link-url <url>", "http(s) URL").option("--card <json>", "card object: inline JSON, @file.json, or - for stdin").action(async (o) => {
999
1100
  const text = maxLen("message", await textFrom("message", o["message"], o["messageFile"]), 6e4);
@@ -1024,6 +1125,16 @@ function registerTyped(program2) {
1024
1125
  const r = await proxy({ ep: "internal/members", method: "GET", query: memberQuery(o), timeoutMs: LIST_TIMEOUT_MS });
1025
1126
  show(r, (b) => memberTable(b, "role"));
1026
1127
  });
1128
+ internal.command("webhook-status").description("Webhook health for this (internal) team").action(async () => {
1129
+ show(await proxy({ ep: "internal/webhook-status", method: "GET" }), webhookHuman);
1130
+ });
1131
+ internal.command("status").description("Key context and usage for this (internal) team").action(async () => {
1132
+ const r = await proxy({ ep: "internal/status", method: "GET" });
1133
+ show(r, (b) => {
1134
+ for (const k of ["workspace", "team", "team_type", "total_members", "active_members", "api_key_usage", "rate_limit", "rate_remaining"])
1135
+ out(`${pc4.bold(k.padEnd(16))} ${b[k] ?? ""}`);
1136
+ });
1137
+ });
1027
1138
  const ad = program2.command("ad").description("Ads (asynchronous: delivered by a background worker)");
1028
1139
  const uploadImage = async (file2) => {
1029
1140
  const { bytes, type } = checkImageFile(file2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staqon/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "StaqOn command-line client for partner developers",
5
5
  "keywords": [
6
6
  "staqon",