@fulldivevr/workcord-cli 1.1.0 → 1.1.1

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
 
package/dist/auth.js CHANGED
@@ -9,9 +9,9 @@ export function mintToken(userId, secret) {
9
9
  }
10
10
  return jwt.sign({ userId, id: userId }, key, { algorithm: "HS256", expiresIn: "60d" });
11
11
  }
12
- export function resolveAuth(asUserId, env = process.env) {
12
+ export function resolveAuth(asUserId, env = process.env, config = {}) {
13
13
  const secret = env.WORKCORD_JWT_SECRET?.trim() || undefined;
14
- const token = env.WORKCORD_TOKEN?.trim() || undefined;
14
+ const token = env.WORKCORD_TOKEN?.trim() || config.token?.trim() || undefined;
15
15
  if (asUserId) {
16
16
  if (!secret) {
17
17
  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 +25,7 @@ export function resolveAuth(asUserId, env = process.env) {
25
25
  if (secret) {
26
26
  throw new Error("--as <userId> is required when authenticating with WORKCORD_JWT_SECRET");
27
27
  }
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>.");
28
+ 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>.");
29
29
  }
30
30
  export function decodeJwtPayload(token) {
31
31
  const parts = token.split(".");
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ 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";
14
15
  import { registerAll as registerGenerated } from "./generated/index.js";
15
16
  const program = new Command();
16
17
  const require = createRequire(import.meta.url);
@@ -20,7 +21,7 @@ program
20
21
  .description("CLI for the WorkCord API. One invocation = one user action.")
21
22
  .version(version)
22
23
  .option("--as <userId>", "user to act as")
23
- .option("--base-url <url>", "API base URL (overrides WORKCORD_API_URL)");
24
+ .option("--base-url <url>", "API base URL (overrides WORKCORD_API_URL and saved configuration)");
24
25
  program.hook("preAction", () => {
25
26
  setBaseUrlOverride(program.opts().baseUrl);
26
27
  });
@@ -33,6 +34,7 @@ registerUsers(program);
33
34
  registerRoomAgents(program);
34
35
  registerCustomAgents(program);
35
36
  registerChallenges(program); // adds regenerate-cards (not in the OpenAPI spec)
37
+ registerHealth(program);
36
38
  registerGenerated(program);
37
39
  program.parseAsync().catch((err) => {
38
40
  const out = { error: err.message ?? String(err) };
package/dist/client.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { resolveAuth } from "./auth.js";
2
+ import { DEFAULT_BASE_URL, readConfig } from "./config.js";
2
3
  let baseUrlOverride;
3
4
  export function setBaseUrlOverride(url) {
4
5
  baseUrlOverride = url;
5
6
  }
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
- }
7
+ export async function getBaseUrl() {
8
+ const config = await readConfig();
9
+ return normalizeBaseUrl(baseUrlOverride || process.env.WORKCORD_API_URL || config.baseUrl || DEFAULT_BASE_URL);
10
+ }
11
+ function normalizeBaseUrl(raw) {
11
12
  const trimmed = raw.replace(/\/+$/, "");
12
13
  let parsed;
13
14
  try {
@@ -52,8 +53,15 @@ async function fetchWithRetry(build) {
52
53
  throw lastErr;
53
54
  }
54
55
  export async function call({ method = "GET", path, query, body, headers, asUserId }) {
55
- const { bearer } = resolveAuth(asUserId);
56
- const base = getBaseUrl();
56
+ const config = await readConfig();
57
+ const { bearer } = resolveAuth(asUserId, process.env, config);
58
+ return request({ method, path, query, body, headers, bearer });
59
+ }
60
+ export async function callPublic({ method = "GET", path, query, body, headers }) {
61
+ return request({ method, path, query, body, headers });
62
+ }
63
+ async function request({ method = "GET", path, query, body, headers, bearer }) {
64
+ const base = await getBaseUrl();
57
65
  const url = new URL(base + path);
58
66
  if (query) {
59
67
  for (const [k, v] of Object.entries(query)) {
@@ -67,7 +75,7 @@ export async function call({ method = "GET", path, query, body, headers, asUserI
67
75
  method,
68
76
  headers: {
69
77
  "content-type": "application/json",
70
- authorization: bearer,
78
+ ...(bearer ? { authorization: bearer } : {}),
71
79
  ...headers,
72
80
  },
73
81
  body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -92,8 +100,9 @@ function safeJson(s) {
92
100
  }
93
101
  }
94
102
  export async function callMultipart({ path, asUserId, fields, file }) {
95
- const { bearer } = resolveAuth(asUserId);
96
- const base = getBaseUrl();
103
+ const config = await readConfig();
104
+ const { bearer } = resolveAuth(asUserId, process.env, config);
105
+ const base = await getBaseUrl();
97
106
  const url = base + path;
98
107
  const res = await fetchWithRetry(() => {
99
108
  const fd = new FormData();
@@ -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
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const DEFAULT_BASE_URL = "https://work-rooms-api.wizeup.app";
5
+ const configPath = join(homedir(), ".workcord", "config.json");
6
+ export async function readConfig() {
7
+ try {
8
+ const value = JSON.parse(await readFile(configPath, "utf8"));
9
+ if (!value || typeof value !== "object")
10
+ return {};
11
+ const config = value;
12
+ return {
13
+ baseUrl: typeof config.baseUrl === "string" ? config.baseUrl : undefined,
14
+ token: typeof config.token === "string" ? config.token : undefined,
15
+ };
16
+ }
17
+ catch (err) {
18
+ if (err.code === "ENOENT")
19
+ return {};
20
+ throw new Error(`Could not read ${configPath}: ${err.message}`);
21
+ }
22
+ }
23
+ export async function saveConfig(update) {
24
+ const current = await readConfig();
25
+ const next = { ...current, ...update };
26
+ await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
27
+ await chmod(dirname(configPath), 0o700);
28
+ await writeFile(configPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
29
+ await chmod(configPath, 0o600);
30
+ }
31
+ export function getConfigPath() {
32
+ return configPath;
33
+ }
34
+ //# sourceMappingURL=config.js.map
@@ -19,7 +19,6 @@ import { register as register_feed } from "./commands/feed.js";
19
19
  import { register as register_feedback } from "./commands/feedback.js";
20
20
  import { register as register_gamification } from "./commands/gamification.js";
21
21
  import { register as register_google_play_iap } from "./commands/google-play-iap.js";
22
- import { register as register_health } from "./commands/health.js";
23
22
  import { register as register_internal } from "./commands/internal.js";
24
23
  import { register as register_interview } from "./commands/interview.js";
25
24
  import { register as register_invite_redirect } from "./commands/invite-redirect.js";
@@ -84,7 +83,6 @@ export function registerAll(program) {
84
83
  register_feedback(program);
85
84
  register_gamification(program);
86
85
  register_google_play_iap(program);
87
- register_health(program);
88
86
  register_internal(program);
89
87
  register_interview(program);
90
88
  register_invite_redirect(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulldivevr/workcord-cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "CLI for the WorkCord API. One invocation = one user action.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",
@@ -1,29 +0,0 @@
1
- import { call } from "../../client.js";
2
- import { actingUserId } from "../../auth.js";
3
- import { getOrCreateGroup, hasSubcommand } from "../_helpers.js";
4
- export function register(program) {
5
- const group = getOrCreateGroup(program, "health", "Health operations");
6
- if (!hasSubcommand(group, "health")) {
7
- group
8
- .command("health")
9
- .description("GET /api/health")
10
- .action(async (opts) => {
11
- const as = actingUserId(program);
12
- const path = `/api/health`;
13
- const data = await call({ method: "GET", path, asUserId: as });
14
- process.stdout.write(JSON.stringify(data) + "\n");
15
- });
16
- }
17
- if (!hasSubcommand(group, "hello")) {
18
- group
19
- .command("hello")
20
- .description("GET /api/hello")
21
- .action(async (opts) => {
22
- const as = actingUserId(program);
23
- const path = `/api/hello`;
24
- const data = await call({ method: "GET", path, asUserId: as });
25
- process.stdout.write(JSON.stringify(data) + "\n");
26
- });
27
- }
28
- }
29
- //# sourceMappingURL=health.js.map