@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 CHANGED
@@ -20,14 +20,15 @@ npx @fulldivevr/workcord-cli --help
20
20
 
21
21
  | Setting | How |
22
22
  | --- | --- |
23
- | Base URL | `WORKCORD_API_URL` env var or `--base-url <url>` — **required, no default** |
24
- | User token | `WORKCORD_TOKEN` env var **only** — a per-user access token from `workcord auth login`; the normal way to authenticate |
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
- export WORKCORD_API_URL=https://work-rooms-api.wizeup.app
44
- export WORKCORD_TOKEN=<token from `workcord auth login`>
44
+ workcord auth login --email you@company.com --save
45
45
 
46
- workcord users get-me
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, export the token, and every
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 an `export WORKCORD_TOKEN=...` hint on stderr
81
+ # and confirms the login was saved locally
64
82
 
65
- export WORKCORD_TOKEN=<token>
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.*` (`EMAIL_AUTH_ALLOWED_DOMAINS` in
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 and re-export `WORKCORD_TOKEN`.
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
- The CLI is stateless. With `WORKCORD_TOKEN` the identity is the token's owner;
238
- swap the env var to swap users. With `WORKCORD_JWT_SECRET` one process can act
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
- ├── client.ts fetch wrapper (JSON + multipart)
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. Set WORKCORD_TOKEN to a token from `workcord auth login`, or set WORKCORD_JWT_SECRET and pass --as <userId>.");
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 raw = baseUrlOverride || process.env.WORKCORD_API_URL;
8
- if (!raw) {
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 { bearer } = resolveAuth(asUserId);
56
- const base = getBaseUrl();
57
- const url = new URL(base + path);
58
- if (query) {
59
- for (const [k, v] of Object.entries(query)) {
60
- if (v !== undefined && v !== null)
61
- url.searchParams.set(k, String(v));
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
- const err = new Error(`${method} ${path} -> ${res.status}`);
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 { bearer } = resolveAuth(asUserId);
96
- const base = getBaseUrl();
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
- const err = new Error(`POST ${path} -> ${res.status}`);
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
@@ -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(`export WORKCORD_TOKEN=${result.token}\n`);
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
- throw new Error(`Email ${email} is not allowed to sign in on this deployment. ` +
71
- "Check the stack's EMAIL_AUTH_ALLOWED_DOMAINS / email_auth_allowlist.");
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
- "Export the returned token as WORKCORD_TOKEN to authenticate every other command.")
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