@fulldivevr/workcord-cli 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -19
- package/dist/auth.js +46 -3
- package/dist/cli.js +5 -1
- package/dist/client.js +23 -79
- package/dist/commands/auth.js +28 -14
- package/dist/commands/health.js +29 -0
- package/dist/commands/hermes.js +644 -0
- package/dist/config.js +58 -0
- package/dist/generated/commands/custom-agents.js +14 -0
- package/dist/generated/commands/live-kit-webhook.js +20 -0
- package/dist/generated/commands/room-call.js +33 -0
- package/dist/generated/commands/rooms.js +24 -0
- package/dist/generated/index.js +4 -2
- package/dist/hermes-client.js +87 -0
- package/dist/hermes-guards.js +70 -0
- package/dist/http.js +68 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,14 +20,15 @@ npx @fulldivevr/workcord-cli --help
|
|
|
20
20
|
|
|
21
21
|
| Setting | How |
|
|
22
22
|
| --- | --- |
|
|
23
|
-
| Base URL | `WORKCORD_API_URL
|
|
24
|
-
| User token |
|
|
23
|
+
| Base URL | WorkCord production by default; override with saved configuration, `WORKCORD_API_URL`, or `--base-url <url>` |
|
|
24
|
+
| User token | Saved by `auth login --save`, or supplied by `WORKCORD_TOKEN` |
|
|
25
25
|
| Signing secret | `WORKCORD_JWT_SECRET` env var **only** — never a flag; privileged, enables `--as` impersonation |
|
|
26
26
|
| Internal admin key | `WORKCORD_INTERNAL_API_KEY`, internal commands only |
|
|
27
27
|
| Acting user | `--as <userId>`, per invocation — only with `WORKCORD_JWT_SECRET` |
|
|
28
28
|
|
|
29
29
|
Base URL must be a bare origin (scheme + host, no path). The `/api/v1` prefix
|
|
30
|
-
is added by the CLI.
|
|
30
|
+
is added by the CLI. Resolution order is `--base-url`, `WORKCORD_API_URL`,
|
|
31
|
+
saved configuration, then the WorkCord production default.
|
|
31
32
|
|
|
32
33
|
| Deployment | `WORKCORD_API_URL` |
|
|
33
34
|
| --- | --- |
|
|
@@ -40,10 +41,27 @@ is added by the CLI.
|
|
|
40
41
|
| Local RoomCord / JesusCord | `http://localhost:3000` |
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
|
-
|
|
44
|
-
export WORKCORD_TOKEN=<token from `workcord auth login`>
|
|
44
|
+
workcord auth login --email you@company.com --save
|
|
45
45
|
|
|
46
|
-
workcord
|
|
46
|
+
workcord rooms list
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Use another server for one command:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
workcord --base-url https://via-rooms-api.wizeup.app rooms list
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Save another server and login for future commands:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
workcord --base-url https://via-rooms-api.wizeup.app auth login --email you@via.com --save
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Reset the saved server to WorkCord by logging in and saving again:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
workcord --base-url https://work-rooms-api.wizeup.app auth login --email you@company.com --save
|
|
47
65
|
```
|
|
48
66
|
|
|
49
67
|
Output is JSON on stdout, errors on stderr, non-zero exit on failure.
|
|
@@ -53,18 +71,16 @@ Output is JSON on stdout, errors on stderr, non-zero exit on failure.
|
|
|
53
71
|
Every request carries a bearer token. There are two ways to get one, and one
|
|
54
72
|
extra header for internal admin routes.
|
|
55
73
|
|
|
56
|
-
**1. A real user token (normal path).** Log in once
|
|
74
|
+
**1. A real user token (normal path).** Log in once with `--save`, and every
|
|
57
75
|
command runs as that user with exactly that user's permissions:
|
|
58
76
|
|
|
59
77
|
```bash
|
|
60
|
-
workcord auth login --email you@company.com
|
|
78
|
+
workcord auth login --email you@company.com --save
|
|
61
79
|
# → code arrives by email, CLI prompts for it, prints
|
|
62
80
|
# { token, accessToken, refreshToken, user, isNewUser } on stdout
|
|
63
|
-
# and
|
|
81
|
+
# and confirms the login was saved locally
|
|
64
82
|
|
|
65
|
-
|
|
66
|
-
workcord users get-me
|
|
67
|
-
workcord whoami # → {"mode":"token","userId":"...","source":"WORKCORD_TOKEN"}
|
|
83
|
+
workcord rooms list
|
|
68
84
|
```
|
|
69
85
|
|
|
70
86
|
`WORKCORD_TOKEN` is sent as-is in `Authorization: Bearer`; the CLI never signs
|
|
@@ -84,7 +100,7 @@ value works as `WORKCORD_TOKEN`.
|
|
|
84
100
|
|
|
85
101
|
Email auth is domain-restricted. WorkCord prod and VIA both allow
|
|
86
102
|
`fulldive.com`, `via.com`, `via.com.tw`, `viatech.com.cn`, `*.tvbs.com`,
|
|
87
|
-
`tvbs.*.com`, `tvbs.com
|
|
103
|
+
`tvbs.*.com`, `tvbs.com.*`, and `*.tvbs.com.tw` (`EMAIL_AUTH_ALLOWED_DOMAINS` in
|
|
88
104
|
`.sc/stacks/wize-rooms-api/client.yaml`). Anything else gets
|
|
89
105
|
`DOMAIN_NOT_ALLOWED`.
|
|
90
106
|
|
|
@@ -92,7 +108,7 @@ Email auth is domain-restricted. WorkCord prod and VIA both allow
|
|
|
92
108
|
set `AUTH_PHONE_OTP_DEV_BYPASS`, so `POST /auth/send-otp` returns 503
|
|
93
109
|
`OTP_NOT_AVAILABLE` — the CLI turns that into an explicit "use `--email`" error.
|
|
94
110
|
|
|
95
|
-
Tokens expire. On `401`, log in again
|
|
111
|
+
Tokens expire. On `401`, log in again with `--save`, or replace `WORKCORD_TOKEN`.
|
|
96
112
|
|
|
97
113
|
**2. Local minting / impersonation (privileged, trusted CI only).** With
|
|
98
114
|
`WORKCORD_JWT_SECRET` set, the global `--as <userId>` flag mints an HS256 JWT
|
|
@@ -234,9 +250,9 @@ from each room, merges them by `sentAt` descending, and includes
|
|
|
234
250
|
|
|
235
251
|
**Switch user identity per call:**
|
|
236
252
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
as 100 users by changing `--as <userId>` per invocation.
|
|
253
|
+
Saved login is for one interactive user. Agents can use `WORKCORD_TOKEN` to
|
|
254
|
+
choose the token owner per invocation; with `WORKCORD_JWT_SECRET`, one process
|
|
255
|
+
can act as 100 users by changing `--as <userId>` per invocation.
|
|
240
256
|
|
|
241
257
|
**Onboard a fresh agent identity:**
|
|
242
258
|
|
|
@@ -265,6 +281,115 @@ workcord --as $U dm send-text --to $OTHER --text "hey"
|
|
|
265
281
|
# → composite: opens (or finds) the DM room, sends the message, returns both
|
|
266
282
|
```
|
|
267
283
|
|
|
284
|
+
## Hermes Hub (`hermes` namespace)
|
|
285
|
+
|
|
286
|
+
WorkCord provisions a **Hermes Hub tenant** for each room-agent and each Hermes
|
|
287
|
+
custom agent. That tenant is where the agent actually runs: its chats, runs,
|
|
288
|
+
files, skills, flavor pin, A2A peers and eval runs all live on the Hub, not in
|
|
289
|
+
WorkCord. The `hermes` namespace drives that tenant directly.
|
|
290
|
+
|
|
291
|
+
This is the one namespace that talks to a **second origin**. Every other command
|
|
292
|
+
goes to the WorkCord API; `hermes` uses the WorkCord API only to reveal the
|
|
293
|
+
tenant key, then calls the Hub's `/v1/tenant-api/*` routes with it.
|
|
294
|
+
|
|
295
|
+
### Target selection
|
|
296
|
+
|
|
297
|
+
Every `hermes` command takes exactly one of:
|
|
298
|
+
|
|
299
|
+
| Flag | Meaning |
|
|
300
|
+
| --- | --- |
|
|
301
|
+
| `--agent <id>` | a custom agent id, used verbatim |
|
|
302
|
+
| `--room-agent <id>` | a room agent id, resolved as `workcord-room-agent:<id>` |
|
|
303
|
+
|
|
304
|
+
Passing both, or neither, is an error.
|
|
305
|
+
|
|
306
|
+
### Credentials
|
|
307
|
+
|
|
308
|
+
The tenant key is resolved in one place (`resolveHermesKey` in `src/auth.ts`)
|
|
309
|
+
and is **never** accepted as a flag:
|
|
310
|
+
|
|
311
|
+
| Env var | Meaning |
|
|
312
|
+
| --- | --- |
|
|
313
|
+
| `WORKCORD_HERMES_KEY` | tenant key, used verbatim; requires `WORKCORD_HERMES_HUB_URL` |
|
|
314
|
+
| `WORKCORD_HERMES_HUB_URL` | Hub origin (bare origin, no path); requires `WORKCORD_HERMES_KEY` |
|
|
315
|
+
| `WORKCORD_HERMES_TENANT_ID` | tenant id for the tenant-scoped routes, alongside the two above |
|
|
316
|
+
|
|
317
|
+
Setting only one of `WORKCORD_HERMES_KEY` / `WORKCORD_HERMES_HUB_URL` is an
|
|
318
|
+
error — the CLI will not half-guess a hub.
|
|
319
|
+
|
|
320
|
+
With none of them set, the CLI calls
|
|
321
|
+
`POST /api/v1/agents/{id}/hermes-cli-key/reveal` on WorkCord (as the acting
|
|
322
|
+
user, so ownership is enforced server-side) and gets back the key, tenant id and
|
|
323
|
+
hub origin.
|
|
324
|
+
|
|
325
|
+
Revealed credentials are cached per agent ref in `~/.workcord/config.json`
|
|
326
|
+
under `hermes`, alongside the saved login. The file is written `0600` inside a
|
|
327
|
+
`0700` directory. Two flags control it:
|
|
328
|
+
|
|
329
|
+
| Flag | Effect |
|
|
330
|
+
| --- | --- |
|
|
331
|
+
| `--refresh` | ignore the cached entry, re-reveal, rewrite the cache |
|
|
332
|
+
| `--no-cache` | neither read nor write the cache for this invocation |
|
|
333
|
+
|
|
334
|
+
Env credentials always win over the cache. `hermes key` prints the resolved
|
|
335
|
+
credentials on stdout — that is its whole job; no other command emits key
|
|
336
|
+
material.
|
|
337
|
+
|
|
338
|
+
### Commands
|
|
339
|
+
|
|
340
|
+
| Group | What |
|
|
341
|
+
| --- | --- |
|
|
342
|
+
| `hermes key` / `whoami` / `runtime` | reveal credentials, show the tenant, show its runtime |
|
|
343
|
+
| `hermes agent status\|start\|stop\|restart\|card` | tenant agent lifecycle |
|
|
344
|
+
| `hermes chat list\|create\|get\|messages\|send\|message\|stream\|stop\|approve` | chats and chat messages (`stream` is SSE) |
|
|
345
|
+
| `hermes run create\|get\|stream\|stop\|approve` | runs |
|
|
346
|
+
| `hermes files ls\|cat\|download` | tenant-visible files |
|
|
347
|
+
| `hermes share groups\|ls\|read\|stat\|grep\|find\|write\|mkdir\|move\|rm` | share-group filesystem |
|
|
348
|
+
| `hermes skills list\|enable\|disable\|files\|cat` | skills installed on this tenant |
|
|
349
|
+
| `hermes flavor current\|catalog\|get\|set\|subscribe\|checkout\|publish\|rollout` | flavor marketplace and this tenant's pin |
|
|
350
|
+
| `hermes skill catalog\|get\|checkout\|publish` | skill marketplace |
|
|
351
|
+
| `hermes eval suites\|run\|list\|get\|cancel` | flavor eval suites and runs |
|
|
352
|
+
| `hermes peers list\|get\|call\|find` | A2A peers |
|
|
353
|
+
| `hermes conversations list\|get\|messages` | A2A conversations |
|
|
354
|
+
| `hermes sessions list`, `hermes artifacts` | agent sessions and artifacts |
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
workcord hermes whoami --room-agent $RA
|
|
358
|
+
workcord hermes chat send --room-agent $RA --chat $CHAT --text "status?"
|
|
359
|
+
workcord hermes flavor current --agent $AGENT
|
|
360
|
+
workcord hermes peers find --agent $AGENT --query "translation" --limit 5
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Version flags are named `--flavor-version` and `--skill-version`, not
|
|
364
|
+
`--version`: commander's program-level `.version()` matches `--version`
|
|
365
|
+
anywhere in argv, prints the CLI version and exits before the subcommand runs.
|
|
366
|
+
|
|
367
|
+
### Authoring flavors and skills
|
|
368
|
+
|
|
369
|
+
`flavor publish`, `flavor rollout` and `skill publish` are **owner-gated on the
|
|
370
|
+
Hub**. A WorkCord agent tenant is provisioned as a *consumer* and owns no flavor
|
|
371
|
+
or skill namespace, so these will 403 out of the box. Ownership is
|
|
372
|
+
admin-assigned only (`PUT /v1/flavors/{id}/owner`, likewise for skills) and
|
|
373
|
+
there is **no self-service bootstrap** — a hub operator has to assign the
|
|
374
|
+
namespace to your tenant first. The CLI turns that 403 into an explicit error
|
|
375
|
+
naming the tenant and the namespace so you know exactly what to ask for.
|
|
376
|
+
|
|
377
|
+
The CLI does **not** pack or untar archives. The authoring loop is:
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
workcord hermes flavor checkout --agent $AGENT --flavor-version 1.4.0 --out flavor.tgz
|
|
381
|
+
# unpack, edit, and repack with hermes-hub's own tooling
|
|
382
|
+
workcord hermes flavor publish --agent $AGENT --archive flavor.tgz
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`--archive` must already be a gzipped package built beforehand; anything over
|
|
386
|
+
32 MiB is rejected locally before upload.
|
|
387
|
+
|
|
388
|
+
`flavor set` warns (and refuses without `--force`) on a downgrade or a
|
|
389
|
+
deprecated target. `flavor rollout` defaults to the policy-respecting automatic
|
|
390
|
+
rollout; `--all-matching` re-pins every tenant on the Hub pinned to that flavor
|
|
391
|
+
id regardless of their own update policy, and therefore requires `--force`.
|
|
392
|
+
|
|
268
393
|
## Development
|
|
269
394
|
|
|
270
395
|
```bash
|
|
@@ -294,8 +419,12 @@ without running `regen` if you don't need the latest server changes.
|
|
|
294
419
|
```
|
|
295
420
|
src/
|
|
296
421
|
├── cli.ts commander entry, global --as flag
|
|
297
|
-
├── auth.ts auth resolution: WORKCORD_TOKEN vs minted --as JWT
|
|
298
|
-
|
|
422
|
+
├── auth.ts auth resolution: WORKCORD_TOKEN vs minted --as JWT,
|
|
423
|
+
│ plus Hermes Hub key resolution
|
|
424
|
+
├── http.ts shared fetch primitives (retry, errors, query)
|
|
425
|
+
├── client.ts WorkCord fetch wrapper (JSON + multipart)
|
|
426
|
+
├── hermes-client.ts Hermes Hub fetch wrapper (JSON, upload, SSE, binary)
|
|
427
|
+
├── hermes-guards.ts rollout/ownership/archive guards, version compare
|
|
299
428
|
├── commands/ hand-written semantic commands
|
|
300
429
|
└── generated/ auto-generated from openapi.json
|
|
301
430
|
├── index.ts
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import jwt from "jsonwebtoken";
|
|
2
|
+
import { readConfig, saveHermesEntry } from "./config.js";
|
|
2
3
|
export function actingUserId(program) {
|
|
3
4
|
return program.opts().as;
|
|
4
5
|
}
|
|
@@ -9,9 +10,9 @@ export function mintToken(userId, secret) {
|
|
|
9
10
|
}
|
|
10
11
|
return jwt.sign({ userId, id: userId }, key, { algorithm: "HS256", expiresIn: "60d" });
|
|
11
12
|
}
|
|
12
|
-
export function resolveAuth(asUserId, env = process.env) {
|
|
13
|
+
export function resolveAuth(asUserId, env = process.env, config = {}) {
|
|
13
14
|
const secret = env.WORKCORD_JWT_SECRET?.trim() || undefined;
|
|
14
|
-
const token = env.WORKCORD_TOKEN?.trim() || undefined;
|
|
15
|
+
const token = env.WORKCORD_TOKEN?.trim() || config.token?.trim() || undefined;
|
|
15
16
|
if (asUserId) {
|
|
16
17
|
if (!secret) {
|
|
17
18
|
throw new Error("--as <userId> mints a token and needs WORKCORD_JWT_SECRET. Set it, or drop --as and set WORKCORD_TOKEN to a token from `workcord auth login`.");
|
|
@@ -25,7 +26,49 @@ export function resolveAuth(asUserId, env = process.env) {
|
|
|
25
26
|
if (secret) {
|
|
26
27
|
throw new Error("--as <userId> is required when authenticating with WORKCORD_JWT_SECRET");
|
|
27
28
|
}
|
|
28
|
-
throw new Error("No credentials.
|
|
29
|
+
throw new Error("No credentials. Run `workcord auth login --email <address> --save`, or set WORKCORD_TOKEN. For privileged impersonation, set WORKCORD_JWT_SECRET and pass --as <userId>.");
|
|
30
|
+
}
|
|
31
|
+
async function revealHermesKey(agentRef, asUserId) {
|
|
32
|
+
const { call } = await import("./client.js");
|
|
33
|
+
const res = (await call({
|
|
34
|
+
method: "POST",
|
|
35
|
+
path: `/api/v1/agents/${encodeURIComponent(agentRef)}/hermes-cli-key/reveal`,
|
|
36
|
+
asUserId,
|
|
37
|
+
}));
|
|
38
|
+
if (!res?.key) {
|
|
39
|
+
throw new Error(`Reveal for ${agentRef} returned no Hermes key`);
|
|
40
|
+
}
|
|
41
|
+
if (!res.hubBaseUrl) {
|
|
42
|
+
throw new Error(`Reveal for ${agentRef} returned no hubBaseUrl. Set WORKCORD_HERMES_HUB_URL (with WORKCORD_HERMES_KEY) to target the hub explicitly.`);
|
|
43
|
+
}
|
|
44
|
+
return { key: res.key, tenantId: res.tenantId ?? "", hubBaseUrl: res.hubBaseUrl };
|
|
45
|
+
}
|
|
46
|
+
export async function resolveHermesKey(agentRef, opts = {}) {
|
|
47
|
+
const env = opts.env ?? process.env;
|
|
48
|
+
const envKey = env.WORKCORD_HERMES_KEY?.trim() || undefined;
|
|
49
|
+
const envHub = env.WORKCORD_HERMES_HUB_URL?.trim() || undefined;
|
|
50
|
+
if (envKey) {
|
|
51
|
+
if (!envHub) {
|
|
52
|
+
throw new Error("WORKCORD_HERMES_KEY is set but WORKCORD_HERMES_HUB_URL is not. Set both, or unset the key to reveal it from WorkCord.");
|
|
53
|
+
}
|
|
54
|
+
return { key: envKey, tenantId: env.WORKCORD_HERMES_TENANT_ID?.trim() || "", hubBaseUrl: envHub, source: "env" };
|
|
55
|
+
}
|
|
56
|
+
if (envHub && !envKey) {
|
|
57
|
+
throw new Error("WORKCORD_HERMES_HUB_URL is set but WORKCORD_HERMES_KEY is not. Set both, or unset the hub URL to reveal both from WorkCord.");
|
|
58
|
+
}
|
|
59
|
+
const useCache = opts.cache !== false;
|
|
60
|
+
const config = opts.config ?? (await readConfig());
|
|
61
|
+
if (useCache && !opts.refresh) {
|
|
62
|
+
const cached = config.hermes?.[agentRef];
|
|
63
|
+
if (cached?.key && cached.hubBaseUrl) {
|
|
64
|
+
return { key: cached.key, tenantId: cached.tenantId, hubBaseUrl: cached.hubBaseUrl, source: "cache" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const revealed = await (opts.reveal ?? revealHermesKey)(agentRef, opts.asUserId);
|
|
68
|
+
if (useCache) {
|
|
69
|
+
await (opts.save ?? saveHermesEntry)(agentRef, { ...revealed, revealedAt: new Date().toISOString() });
|
|
70
|
+
}
|
|
71
|
+
return { ...revealed, source: "reveal" };
|
|
29
72
|
}
|
|
30
73
|
export function decodeJwtPayload(token) {
|
|
31
74
|
const parts = token.split(".");
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,8 @@ import { registerUsers } from "./commands/users.js";
|
|
|
11
11
|
import { registerRoomAgents } from "./commands/room-agents.js";
|
|
12
12
|
import { registerCustomAgents } from "./commands/custom-agents.js";
|
|
13
13
|
import { registerChallenges } from "./commands/challenges.js";
|
|
14
|
+
import { registerHealth } from "./commands/health.js";
|
|
15
|
+
import { registerHermes } from "./commands/hermes.js";
|
|
14
16
|
import { registerAll as registerGenerated } from "./generated/index.js";
|
|
15
17
|
const program = new Command();
|
|
16
18
|
const require = createRequire(import.meta.url);
|
|
@@ -20,7 +22,7 @@ program
|
|
|
20
22
|
.description("CLI for the WorkCord API. One invocation = one user action.")
|
|
21
23
|
.version(version)
|
|
22
24
|
.option("--as <userId>", "user to act as")
|
|
23
|
-
.option("--base-url <url>", "API base URL (overrides WORKCORD_API_URL)");
|
|
25
|
+
.option("--base-url <url>", "API base URL (overrides WORKCORD_API_URL and saved configuration)");
|
|
24
26
|
program.hook("preAction", () => {
|
|
25
27
|
setBaseUrlOverride(program.opts().baseUrl);
|
|
26
28
|
});
|
|
@@ -33,6 +35,8 @@ registerUsers(program);
|
|
|
33
35
|
registerRoomAgents(program);
|
|
34
36
|
registerCustomAgents(program);
|
|
35
37
|
registerChallenges(program); // adds regenerate-cards (not in the OpenAPI spec)
|
|
38
|
+
registerHealth(program);
|
|
39
|
+
registerHermes(program);
|
|
36
40
|
registerGenerated(program);
|
|
37
41
|
program.parseAsync().catch((err) => {
|
|
38
42
|
const out = { error: err.message ?? String(err) };
|
package/dist/client.js
CHANGED
|
@@ -1,73 +1,32 @@
|
|
|
1
1
|
import { resolveAuth } from "./auth.js";
|
|
2
|
+
import { DEFAULT_BASE_URL, readConfig } from "./config.js";
|
|
3
|
+
import { applyQuery, fetchWithRetry, httpError, normalizeBaseUrl, safeJson } from "./http.js";
|
|
2
4
|
let baseUrlOverride;
|
|
3
5
|
export function setBaseUrlOverride(url) {
|
|
4
6
|
baseUrlOverride = url;
|
|
5
7
|
}
|
|
6
|
-
export function getBaseUrl() {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
throw new Error("No API base URL configured. Set WORKCORD_API_URL or pass --base-url <url> (e.g. https://work-rooms-api.wizeup.app)");
|
|
10
|
-
}
|
|
11
|
-
const trimmed = raw.replace(/\/+$/, "");
|
|
12
|
-
let parsed;
|
|
13
|
-
try {
|
|
14
|
-
parsed = new URL(trimmed);
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
throw new Error(`Invalid API base URL: ${raw}`);
|
|
18
|
-
}
|
|
19
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
20
|
-
throw new Error(`API base URL must be http or https: ${raw}`);
|
|
21
|
-
}
|
|
22
|
-
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
23
|
-
throw new Error(`API base URL must be an origin with no path, query or fragment: ${raw}`);
|
|
24
|
-
}
|
|
25
|
-
return parsed.origin;
|
|
26
|
-
}
|
|
27
|
-
const RETRY_MAX = Number(process.env.WORKCORD_RETRY_MAX ?? 3);
|
|
28
|
-
const RETRY_BACKOFF_MS = Number(process.env.WORKCORD_RETRY_BACKOFF_MS ?? 200);
|
|
29
|
-
const RETRY_STATUSES = new Set([408, 429, 502, 503, 504]);
|
|
30
|
-
async function fetchWithRetry(build) {
|
|
31
|
-
let lastErr;
|
|
32
|
-
for (let attempt = 0; attempt <= RETRY_MAX; attempt++) {
|
|
33
|
-
try {
|
|
34
|
-
const { url, init } = build();
|
|
35
|
-
const res = await fetch(url, init);
|
|
36
|
-
if (!RETRY_STATUSES.has(res.status))
|
|
37
|
-
return res;
|
|
38
|
-
lastErr = res;
|
|
39
|
-
if (attempt >= RETRY_MAX)
|
|
40
|
-
return res;
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
lastErr = err;
|
|
44
|
-
if (attempt >= RETRY_MAX)
|
|
45
|
-
throw err;
|
|
46
|
-
}
|
|
47
|
-
const delay = RETRY_BACKOFF_MS * Math.pow(3, attempt);
|
|
48
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
49
|
-
}
|
|
50
|
-
if (lastErr instanceof Response)
|
|
51
|
-
return lastErr;
|
|
52
|
-
throw lastErr;
|
|
8
|
+
export async function getBaseUrl() {
|
|
9
|
+
const config = await readConfig();
|
|
10
|
+
return normalizeBaseUrl(baseUrlOverride || process.env.WORKCORD_API_URL || config.baseUrl || DEFAULT_BASE_URL);
|
|
53
11
|
}
|
|
54
12
|
export async function call({ method = "GET", path, query, body, headers, asUserId }) {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
13
|
+
const config = await readConfig();
|
|
14
|
+
const { bearer } = resolveAuth(asUserId, process.env, config);
|
|
15
|
+
return request({ method, path, query, body, headers, bearer });
|
|
16
|
+
}
|
|
17
|
+
export async function callPublic({ method = "GET", path, query, body, headers }) {
|
|
18
|
+
return request({ method, path, query, body, headers });
|
|
19
|
+
}
|
|
20
|
+
async function request({ method = "GET", path, query, body, headers, bearer }) {
|
|
21
|
+
const base = await getBaseUrl();
|
|
22
|
+
const url = applyQuery(new URL(base + path), query);
|
|
64
23
|
const res = await fetchWithRetry(() => ({
|
|
65
24
|
url,
|
|
66
25
|
init: {
|
|
67
26
|
method,
|
|
68
27
|
headers: {
|
|
69
28
|
"content-type": "application/json",
|
|
70
|
-
authorization: bearer,
|
|
29
|
+
...(bearer ? { authorization: bearer } : {}),
|
|
71
30
|
...headers,
|
|
72
31
|
},
|
|
73
32
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
@@ -75,25 +34,14 @@ export async function call({ method = "GET", path, query, body, headers, asUserI
|
|
|
75
34
|
}));
|
|
76
35
|
const text = await res.text();
|
|
77
36
|
const data = text ? safeJson(text) : null;
|
|
78
|
-
if (!res.ok)
|
|
79
|
-
|
|
80
|
-
err.status = res.status;
|
|
81
|
-
err.body = data;
|
|
82
|
-
throw err;
|
|
83
|
-
}
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw httpError(`${method} ${path}`, res.status, data);
|
|
84
39
|
return data;
|
|
85
40
|
}
|
|
86
|
-
function safeJson(s) {
|
|
87
|
-
try {
|
|
88
|
-
return JSON.parse(s);
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
return s;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
41
|
export async function callMultipart({ path, asUserId, fields, file }) {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
42
|
+
const config = await readConfig();
|
|
43
|
+
const { bearer } = resolveAuth(asUserId, process.env, config);
|
|
44
|
+
const base = await getBaseUrl();
|
|
97
45
|
const url = base + path;
|
|
98
46
|
const res = await fetchWithRetry(() => {
|
|
99
47
|
const fd = new FormData();
|
|
@@ -114,12 +62,8 @@ export async function callMultipart({ path, asUserId, fields, file }) {
|
|
|
114
62
|
});
|
|
115
63
|
const text = await res.text();
|
|
116
64
|
const data = text ? safeJson(text) : null;
|
|
117
|
-
if (!res.ok)
|
|
118
|
-
|
|
119
|
-
err.status = res.status;
|
|
120
|
-
err.body = data;
|
|
121
|
-
throw err;
|
|
122
|
-
}
|
|
65
|
+
if (!res.ok)
|
|
66
|
+
throw httpError(`POST ${path}`, res.status, data);
|
|
123
67
|
return data;
|
|
124
68
|
}
|
|
125
69
|
//# sourceMappingURL=client.js.map
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { getBaseUrl } from "../client.js";
|
|
4
|
+
import { getConfigPath, saveConfig } from "../config.js";
|
|
4
5
|
import { getOrCreateGroup } from "./_group.js";
|
|
5
6
|
async function postJson(path, body) {
|
|
6
|
-
const res = await fetch(getBaseUrl() + path, {
|
|
7
|
+
const res = await fetch((await getBaseUrl()) + path, {
|
|
7
8
|
method: "POST",
|
|
8
9
|
headers: { "content-type": "application/json" },
|
|
9
10
|
body: JSON.stringify(body),
|
|
@@ -37,11 +38,21 @@ function normalize(data) {
|
|
|
37
38
|
r.accessToken = r.token;
|
|
38
39
|
return r;
|
|
39
40
|
}
|
|
40
|
-
function emit(data) {
|
|
41
|
+
async function emit(data, save = false) {
|
|
41
42
|
const result = normalize(data);
|
|
42
43
|
process.stdout.write(JSON.stringify(result) + "\n");
|
|
44
|
+
if (save && result.token) {
|
|
45
|
+
await saveConfig({
|
|
46
|
+
baseUrl: await getBaseUrl(),
|
|
47
|
+
token: result.token,
|
|
48
|
+
});
|
|
49
|
+
if (process.stdout.isTTY) {
|
|
50
|
+
process.stderr.write(`Logged in and saved to ${getConfigPath()}. Next: workcord rooms list\n`);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
43
54
|
if (result.token) {
|
|
44
|
-
process.stderr.write(
|
|
55
|
+
process.stderr.write("Logged in. Re-run with --save to remember this login on this computer.\n");
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
async function promptCode() {
|
|
@@ -59,7 +70,7 @@ async function verifyEmailCode(sessionId, code) {
|
|
|
59
70
|
}
|
|
60
71
|
return postJson("/api/v1/auth/email/verify-code", { sessionId, code });
|
|
61
72
|
}
|
|
62
|
-
async function loginByEmail(email, code) {
|
|
73
|
+
async function loginByEmail(email, code, save) {
|
|
63
74
|
const sessionId = randomUUID();
|
|
64
75
|
try {
|
|
65
76
|
await postJson(`/api/v1/auth/email/login/by-code/${encodeURIComponent(sessionId)}`, { email });
|
|
@@ -67,8 +78,9 @@ async function loginByEmail(email, code) {
|
|
|
67
78
|
catch (err) {
|
|
68
79
|
const c = errorCode(err);
|
|
69
80
|
if (c === "DOMAIN_NOT_ALLOWED" || c === "EMAIL_NOT_ALLOWED") {
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
const allowed = err.body?.error?.allowedDomains;
|
|
82
|
+
const domains = Array.isArray(allowed) ? allowed.filter((value) => typeof value === "string") : [];
|
|
83
|
+
throw new Error(`Email ${email} cannot sign in on this deployment.${domains.length ? ` Allowed domains: ${domains.join(", ")}.` : ""} Try an allowed work address or ask your WorkCord administrator to enable this domain.`);
|
|
72
84
|
}
|
|
73
85
|
throw err;
|
|
74
86
|
}
|
|
@@ -79,9 +91,9 @@ async function loginByEmail(email, code) {
|
|
|
79
91
|
process.stdout.write(JSON.stringify({ sessionId, codeSent: true }) + "\n");
|
|
80
92
|
return;
|
|
81
93
|
}
|
|
82
|
-
emit(await verifyEmailCode(sessionId, entered));
|
|
94
|
+
await emit(await verifyEmailCode(sessionId, entered), save);
|
|
83
95
|
}
|
|
84
|
-
async function loginByPhone(phone, code) {
|
|
96
|
+
async function loginByPhone(phone, code, save) {
|
|
85
97
|
try {
|
|
86
98
|
await postJson("/api/v1/auth/send-otp", { phone });
|
|
87
99
|
}
|
|
@@ -92,19 +104,20 @@ async function loginByPhone(phone, code) {
|
|
|
92
104
|
}
|
|
93
105
|
throw err;
|
|
94
106
|
}
|
|
95
|
-
emit(await postJson("/api/v1/auth/verify-otp", { phone, code }));
|
|
107
|
+
await emit(await postJson("/api/v1/auth/verify-otp", { phone, code }), save);
|
|
96
108
|
}
|
|
97
109
|
export function registerAuth(program) {
|
|
98
110
|
const group = getOrCreateGroup(program, "auth", "Auth operations");
|
|
99
111
|
group
|
|
100
112
|
.command("login")
|
|
101
|
-
.description("End-to-end login. Returns { token, accessToken, refreshToken, user, isNewUser }. " +
|
|
113
|
+
.description("End-to-end login. Use --save to remember this login on this computer. Returns { token, accessToken, refreshToken, user, isNewUser }. " +
|
|
102
114
|
"--email uses the emailed 6-digit code (deployed WorkCord/VIA); --phone uses OTP " +
|
|
103
115
|
"(local/RoomCord only). Does NOT use --as / WORKCORD_JWT_SECRET — the server mints the token. " +
|
|
104
|
-
"
|
|
116
|
+
"Without --save, set the returned token as WORKCORD_TOKEN to authenticate later commands.")
|
|
105
117
|
.option("--email <address>", "email address (mutually exclusive with --phone)")
|
|
106
118
|
.option("--phone <e164>", 'phone number in E.164 format (e.g. "+14155550199")')
|
|
107
119
|
.option("--code <code>", "verification code; prompted interactively for --email when omitted")
|
|
120
|
+
.option("--save", "remember this login on this computer")
|
|
108
121
|
.action(async (opts) => {
|
|
109
122
|
if (opts.email && opts.phone) {
|
|
110
123
|
throw new Error("--email and --phone are mutually exclusive");
|
|
@@ -113,18 +126,19 @@ export function registerAuth(program) {
|
|
|
113
126
|
throw new Error("one of --email <address> or --phone <e164> is required");
|
|
114
127
|
}
|
|
115
128
|
if (opts.email) {
|
|
116
|
-
await loginByEmail(opts.email, opts.code);
|
|
129
|
+
await loginByEmail(opts.email, opts.code, !!opts.save);
|
|
117
130
|
return;
|
|
118
131
|
}
|
|
119
|
-
await loginByPhone(opts.phone, opts.code ?? "111111");
|
|
132
|
+
await loginByPhone(opts.phone, opts.code ?? "111111", !!opts.save);
|
|
120
133
|
});
|
|
121
134
|
group
|
|
122
135
|
.command("verify-email")
|
|
123
136
|
.description("Verify an emailed code for a session started by `auth login --email`")
|
|
124
137
|
.requiredOption("--session-id <id>", "sessionId printed by `auth login --email`")
|
|
125
138
|
.requiredOption("--code <code>", "6-digit code from the email")
|
|
139
|
+
.option("--save", "remember this login on this computer")
|
|
126
140
|
.action(async (opts) => {
|
|
127
|
-
emit(await verifyEmailCode(opts.sessionId, opts.code));
|
|
141
|
+
await emit(await verifyEmailCode(opts.sessionId, opts.code), !!opts.save);
|
|
128
142
|
});
|
|
129
143
|
}
|
|
130
144
|
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { call, callPublic, getBaseUrl } from "../client.js";
|
|
2
|
+
export function registerHealth(program) {
|
|
3
|
+
program
|
|
4
|
+
.command("health")
|
|
5
|
+
.allowExcessArguments(false)
|
|
6
|
+
.description("Check the selected server and saved login")
|
|
7
|
+
.action(async () => {
|
|
8
|
+
const baseUrl = await getBaseUrl();
|
|
9
|
+
const health = await callPublic({ path: "/api/health" });
|
|
10
|
+
let loggedIn = false;
|
|
11
|
+
let userId;
|
|
12
|
+
try {
|
|
13
|
+
const user = await call({ path: "/api/v1/users/me", asUserId: program.opts().as });
|
|
14
|
+
loggedIn = true;
|
|
15
|
+
userId = user.id;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
loggedIn = false;
|
|
19
|
+
}
|
|
20
|
+
process.stdout.write(JSON.stringify({ baseUrl, loggedIn, userId, health }) + "\n");
|
|
21
|
+
});
|
|
22
|
+
program
|
|
23
|
+
.command("hello")
|
|
24
|
+
.description("Check the selected server")
|
|
25
|
+
.action(async () => {
|
|
26
|
+
process.stdout.write(JSON.stringify(await callPublic({ path: "/api/hello" })) + "\n");
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=health.js.map
|