@fulldivevr/workcord-cli 3.0.0 → 3.0.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
@@ -91,11 +91,11 @@ Email login returns `accessToken`; phone/social login returns `token`. The CLI
91
91
  emits both fields on either path so callers can read one consistently — either
92
92
  value works as `WORKCORD_TOKEN`.
93
93
 
94
- Email auth is domain-restricted. WorkCord prod and VIA both allow
94
+ Email auth is allowlist-restricted. WorkCord prod and VIA both allow
95
95
  `fulldive.com`, `via.com`, `via.com.tw`, `viatech.com.cn`, `*.tvbs.com`,
96
96
  `tvbs.*.com`, `tvbs.com.*`, and `*.tvbs.com.tw` (`EMAIL_AUTH_ALLOWED_DOMAINS` in
97
- `.sc/stacks/wize-rooms-api/client.yaml`). Anything else gets
98
- `DOMAIN_NOT_ALLOWED`.
97
+ `.sc/stacks/wize-rooms-api/client.yaml`). WorkCord prod also allows individually
98
+ approved addresses from `email_auth_allowlist`.
99
99
 
100
100
  `auth login --phone` is for local and RoomCord only. Deployed WorkCord does not
101
101
  set `AUTH_PHONE_OTP_DEV_BYPASS`, so `POST /auth/send-otp` returns 503
@@ -14,7 +14,7 @@ import { pairComputer } from "../machine/pair.js";
14
14
  import { nextRotationDelay, rotateMachineKey, ROTATE_EVERY_MS } from "../machine/rotate.js";
15
15
  import { installService, serviceArgs, servicePaths, uninstallService } from "../machine/service.js";
16
16
  import { acquireMachineLock } from "../machine/lock.js";
17
- import { resolveRoots } from "../machine/policy.js";
17
+ import { resolveHome, resolveRoots } from "../machine/policy.js";
18
18
  import { ConversationThreads } from "../machine/threads.js";
19
19
  import { MachineRelayClient } from "../machine/relay.js";
20
20
  import { getOrCreateGroup } from "./_group.js";
@@ -44,7 +44,12 @@ export function registerMachine(program) {
44
44
  const enrolled = (await call({
45
45
  method: "POST",
46
46
  path: "/api/v1/machine-agents",
47
- body: { name: opts.name, platform: `${process.platform}-${process.arch}`, cliVersion: version },
47
+ body: {
48
+ name: opts.name,
49
+ platform: `${process.platform}-${process.arch}`,
50
+ cliVersion: version,
51
+ installationId: await readOrCreateInstallationId(),
52
+ },
48
53
  asUserId: actingUserId(program),
49
54
  }));
50
55
  await saveMachineFile({
@@ -75,11 +80,13 @@ export function registerMachine(program) {
75
80
  .description("Connect this computer and run your tasks on it until stopped (foreground). Tasks run with full access, without approvals.")
76
81
  .option("--root <dir>", "directory a task may work in (repeatable); without one, only session search runs", collect, [])
77
82
  .option("--adapter <name>", "coding agent that runs tasks: auto, claude or codex", "auto")
83
+ .option("--home <dir>", "home directory the coding agent runs with (HOME, CODEX_HOME=<dir>/.codex, CLAUDE_CONFIG_DIR=<dir>/.claude)")
78
84
  .action(async (opts) => {
79
85
  const credential = resolveMachineCredential(process.env, await readMachineFile());
80
86
  const roots = await resolveRoots(opts.root);
87
+ const home = await resolveHome(opts.home);
81
88
  const installed = await detectAdapters();
82
- const adapters = pickAdapters(installed, opts.adapter);
89
+ const adapters = pickAdapters(installed, opts.adapter, home);
83
90
  const baseUrl = credential.baseUrl || (await getBaseUrl());
84
91
  const installationId = await readOrCreateInstallationId();
85
92
  const release = await acquireMachineLock(getMachineLockPath());
@@ -94,7 +101,8 @@ export function registerMachine(program) {
94
101
  handle: (rpc, signal, onEvent) => handleTask(rpc, {
95
102
  roots, adapters, adapter: Object.keys(adapters)[0], contexts, threads, signal, onEvent,
96
103
  }),
97
- probe: () => probeMachine(baseUrl, credential.key, installationId),
104
+ probe: (key) => probeMachine(baseUrl, key, installationId),
105
+ ...(credential.source === "file" ? { reloadKey: () => savedKeyFor(baseUrl) } : {}),
98
106
  });
99
107
  const rotation = credential.source === "file" ? scheduleRotation(client) : undefined;
100
108
  process.stderr.write(`machine: full access, agents: ${Object.keys(adapters).join(", ") || "none"}, roots: ${roots.length ? roots.join(", ") : "none (session search only)"}\n`);
@@ -113,17 +121,19 @@ export function registerMachine(program) {
113
121
  .description("Keep `machine start` running in the background (launchd on macOS, systemd on Linux).")
114
122
  .option("--root <dir>", "directory a task may work in (repeatable)", collect, [])
115
123
  .option("--adapter <name>", "coding agent that runs tasks: auto, claude or codex", "auto")
124
+ .option("--home <dir>", "home directory the coding agent runs with (HOME, CODEX_HOME=<dir>/.codex, CLAUDE_CONFIG_DIR=<dir>/.claude)")
116
125
  .action(async (opts) => {
117
126
  await readMachineFile().then((file) => resolveMachineCredential({}, file));
118
127
  const roots = await resolveRoots(opts.root);
128
+ const home = await resolveHome(opts.home);
119
129
  const installed = await detectAdapters();
120
- pickAdapters(installed, opts.adapter);
130
+ pickAdapters(installed, opts.adapter, home);
121
131
  const paths = servicePaths();
122
132
  const pathDirs = [...new Set([dirname(process.execPath), ...Object.values(installed).map((path) => dirname(path)), "/usr/bin", "/bin"])];
123
133
  const installedService = await installService({
124
134
  node: process.execPath,
125
135
  cli: await realpath(process.argv[1]),
126
- args: serviceArgs({ roots, adapter: opts.adapter }),
136
+ args: serviceArgs({ roots, adapter: opts.adapter, home }),
127
137
  pathDirs,
128
138
  logPath: paths.log,
129
139
  home: homedir(),
@@ -170,7 +180,7 @@ function collect(value, previous) {
170
180
  return [...previous, value];
171
181
  }
172
182
  const RUNNERS = { claude: runClaude, codex: runCodex };
173
- export function pickAdapters(installed, choice) {
183
+ export function pickAdapters(installed, choice, home) {
174
184
  const names = choice === "auto" ? ADAPTER_NAMES.filter((name) => installed[name]) : [choice];
175
185
  const picked = {};
176
186
  for (const name of names) {
@@ -179,10 +189,14 @@ export function pickAdapters(installed, choice) {
179
189
  const binary = installed[name];
180
190
  if (!binary)
181
191
  throw new Error(`${name} is not installed on this computer (not on PATH).`);
182
- picked[name] = (input) => RUNNERS[name]({ ...input, binary });
192
+ picked[name] = (input) => RUNNERS[name]({ ...input, binary, ...(home ? { home } : {}) });
183
193
  }
184
194
  return picked;
185
195
  }
196
+ async function savedKeyFor(baseUrl) {
197
+ const file = await readMachineFile();
198
+ return file && normalizeBaseUrl(file.baseUrl || baseUrl) === normalizeBaseUrl(baseUrl) ? file.key : undefined;
199
+ }
186
200
  function scheduleRotation(client) {
187
201
  const handle = { timer: undefined };
188
202
  const run = async () => {
@@ -62,7 +62,7 @@ function runOnce(input, signal, attempt) {
62
62
  cwd: input.cwd,
63
63
  // One process per turn: a background command would outlive the turn and its answer.
64
64
  // This also lifts Claude Code's block on a long foreground `sleep`, which offers only the background.
65
- env: { ...childEnv(input.env), CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" },
65
+ env: { ...childEnv(input.env, input.home), CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" },
66
66
  stdio: ["ignore", "pipe", "pipe"],
67
67
  });
68
68
  const onAbort = () => stopTree(child);
@@ -47,7 +47,7 @@ export function codexItemEvent(method, item) {
47
47
  export async function runCodex(input) {
48
48
  const child = spawn(input.binary ?? "codex", ["app-server"], {
49
49
  cwd: input.cwd,
50
- env: childEnv(input.env),
50
+ env: childEnv(input.env, input.home),
51
51
  stdio: ["pipe", "pipe", "pipe"],
52
52
  });
53
53
  child.stdout.setEncoding("utf8");
@@ -122,11 +122,13 @@ export async function runCodex(input) {
122
122
  child.on("error", (error) => {
123
123
  settle.reject(new Error(error.code === "ENOENT" ? "Codex is not installed on this machine (no `codex` on PATH)." : error.message));
124
124
  });
125
+ // "close" fires after stdout and stderr have ended, so the stderr tail is complete here.
125
126
  child.on("close", (code) => {
127
+ const exited = new Error(`Codex exited with ${code}: ${stderr.trim().slice(0, 500)}`);
126
128
  for (const waiter of pending.values())
127
- waiter.reject(new Error(`Codex exited with ${code}`));
129
+ waiter.reject(exited);
128
130
  pending.clear();
129
- settle.reject(new Error(`Codex exited with ${code}: ${stderr.trim().slice(0, 500)}`));
131
+ settle.reject(exited);
130
132
  });
131
133
  const signal = AbortSignal.any([input.signal, AbortSignal.timeout(TASK_TIMEOUT_MS)]);
132
134
  const onAbort = () => {
@@ -1,8 +1,16 @@
1
1
  import { access, constants } from "node:fs/promises";
2
2
  import { delimiter, join } from "node:path";
3
3
  export const ADAPTER_NAMES = ["claude", "codex"];
4
- export function childEnv(env = process.env) {
5
- return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith("WORKCORD_")));
4
+ /**
5
+ * The env every adapter spawns its CLI with. With a home, each tool's own config
6
+ * variable is pinned under it too, so one inherited from the parent shell cannot
7
+ * point the tool at another home.
8
+ */
9
+ export function childEnv(env = process.env, home) {
10
+ const inherited = Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith("WORKCORD_")));
11
+ if (!home)
12
+ return inherited;
13
+ return { ...inherited, HOME: home, CODEX_HOME: join(home, ".codex"), CLAUDE_CONFIG_DIR: join(home, ".claude") };
6
14
  }
7
15
  /** Absolute path of each installed agent CLI, found the way a shell would find it. */
8
16
  export async function detectAdapters(env = process.env) {
@@ -1,5 +1,17 @@
1
- import { realpath } from "node:fs/promises";
2
- import { sep } from "node:path";
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve, sep } from "node:path";
4
+ export async function resolveHome(dir) {
5
+ if (!dir)
6
+ return undefined;
7
+ const absolute = resolve(dir === "~" ? homedir() : dir.startsWith("~/") ? join(homedir(), dir.slice(2)) : dir);
8
+ const found = await stat(absolute).catch(() => undefined);
9
+ if (!found)
10
+ throw new Error(`--home ${absolute} does not exist.`);
11
+ if (!found.isDirectory())
12
+ throw new Error(`--home ${absolute} is not a directory.`);
13
+ return absolute;
14
+ }
3
15
  export async function resolveRoots(roots) {
4
16
  return Promise.all(roots.map((root) => realpath(root)));
5
17
  }
@@ -155,10 +155,19 @@ export class MachineRelayClient {
155
155
  return;
156
156
  }
157
157
  this.setStatus("errored");
158
- const verdict = await this.options.probe().catch(() => "retry");
158
+ const verdict = await this.options.probe(this.key).catch(() => "retry");
159
159
  if (this.stopped)
160
160
  return;
161
161
  if (verdict === "unauthorized") {
162
+ const saved = await this.options.reloadKey?.().catch(() => undefined);
163
+ if (this.stopped)
164
+ return;
165
+ if (saved && saved !== this.key) {
166
+ this.key = saved;
167
+ this.attempt = 0;
168
+ this.scheduleReconnect("this computer was paired again");
169
+ return;
170
+ }
162
171
  const reason = "the server says this machine key is revoked or invalid. Pair it again with `workcord machine pair` (or `workcord machine enroll`).";
163
172
  this.log(`machine: ${reason}`);
164
173
  this.end({ ok: false, reason });
@@ -7,7 +7,10 @@ const exec = promisify(execFile);
7
7
  export const LAUNCHD_LABEL = "com.fulldive.workcord.machine";
8
8
  const SYSTEMD_UNIT = "workcord-machine.service";
9
9
  export function serviceArgs(input) {
10
- return ["machine", "start", ...input.roots.flatMap((root) => ["--root", root]), "--adapter", input.adapter];
10
+ return [
11
+ "machine", "start", ...input.roots.flatMap((root) => ["--root", root]), "--adapter", input.adapter,
12
+ ...(input.home ? ["--home", input.home] : []),
13
+ ];
11
14
  }
12
15
  export function launchdPlist(spec) {
13
16
  const args = [spec.node, spec.cli, ...spec.args].map((arg) => ` <string>${xml(arg)}</string>`).join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulldivevr/workcord-cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Agent-first CLI using persistent Hermes tenant chat, with advanced API diagnostics.",
5
5
  "private": false,
6
6
  "license": "UNLICENSED",