@oxygen-agent/cli 1.46.0 → 1.50.37

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
@@ -7,6 +7,12 @@ OXYGEN is the CLI/API-first GTM engineering workspace for early-stage B2B SaaS t
7
7
  - Node.js >=22.22.0
8
8
  - npm >=10.9.0
9
9
 
10
+ Supported OSes:
11
+
12
+ - macOS: credentials are stored in Keychain.
13
+ - Windows: credentials are stored in Windows Credential Manager.
14
+ - Linux: credentials are stored in ~/.config/oxygen/credentials.json with user-only file permissions.
15
+
10
16
  ## Install
11
17
 
12
18
  ```sh
@@ -14,12 +20,18 @@ npm install -g @oxygen-agent/cli
14
20
  oxygen login
15
21
  ```
16
22
 
23
+ Copy both lines: the login command runs after npm has installed the `oxygen` binary.
24
+
25
+ On Windows, run the same commands from PowerShell, Windows Terminal, or Command Prompt after installing Node.js with npm added to PATH.
26
+
17
27
  ## Update
18
28
 
19
29
  ```sh
20
30
  oxygen update
21
31
  ```
22
32
 
33
+ `oxygen update` runs `npm install -g @oxygen-agent/cli@latest`, preserves npm prefixes on macOS, Linux, and Windows, and runs npm through `cmd.exe` on Windows.
34
+
23
35
  For product documentation and support, visit https://oxygen-agent.com.
24
36
 
25
- Version: 1.46.0
37
+ Version: 1.50.37
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, chmodSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
@@ -9,6 +9,7 @@ const SERVICE_NAME = "oxygen-cli";
9
9
  const ACCOUNT_NAME = "default";
10
10
  const DEFAULT_API_URL = "https://oxygen-agent.com";
11
11
  const DEFAULT_PROFILE_NAME = "default";
12
+ const WINDOWS_CREDENTIAL_PAYLOAD_ENV = "OXYGEN_CLI_WINDOWS_CREDENTIAL_PAYLOAD";
12
13
  export function defaultApiUrl(env = process.env) {
13
14
  return normalizeApiUrl(env.OXYGEN_API_URL ?? DEFAULT_API_URL);
14
15
  }
@@ -68,6 +69,7 @@ export async function clearCredentials(env = process.env, options = {}) {
68
69
  exitCode: 1,
69
70
  });
70
71
  }
72
+ // skipcq: JS-0320
71
73
  delete profiles[profile];
72
74
  const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b));
73
75
  if (names.length === 0) {
@@ -186,19 +188,19 @@ async function readSystemCredentialDocument(env) {
186
188
  async function readSystemCredentialPayload(env) {
187
189
  const store = credentialStore(env);
188
190
  if (store === "keychain")
189
- return readMacOSKeychainPayload();
191
+ return readMacOSKeychainPayload(env);
190
192
  if (store === "credential-manager")
191
- return readWindowsCredentialManagerPayload();
193
+ return readWindowsCredentialManagerPayload(env);
192
194
  return readFileCredentialPayload(env);
193
195
  }
194
196
  async function writeSystemCredentialPayload(payload, env) {
195
197
  const store = credentialStore(env);
196
198
  if (store === "keychain") {
197
- await writeMacOSKeychainPayload(payload);
199
+ await writeMacOSKeychainPayload(payload, env);
198
200
  return;
199
201
  }
200
202
  if (store === "credential-manager") {
201
- await writeWindowsCredentialManagerPayload(payload);
203
+ await writeWindowsCredentialManagerPayload(payload, env);
202
204
  return;
203
205
  }
204
206
  writeFileCredentialPayload(payload, env);
@@ -206,11 +208,11 @@ async function writeSystemCredentialPayload(payload, env) {
206
208
  async function deleteSystemCredentialPayload(env) {
207
209
  const store = credentialStore(env);
208
210
  if (store === "keychain") {
209
- await deleteMacOSKeychain();
211
+ await deleteMacOSKeychain(env);
210
212
  return;
211
213
  }
212
214
  if (store === "credential-manager") {
213
- await deleteWindowsCredentialManager();
215
+ await deleteWindowsCredentialManager(env);
214
216
  return;
215
217
  }
216
218
  deleteFileCredentials(env);
@@ -271,63 +273,97 @@ function deleteFileCredentials(env) {
271
273
  const path = credentialsPath(env);
272
274
  rmSync(path, { force: true });
273
275
  }
274
- async function readMacOSKeychainPayload() {
276
+ async function readMacOSKeychainPayload(env) {
275
277
  try {
276
- const { stdout } = await execFileAsync("security", [
278
+ const { stdout } = await execFileAsync(macOSSecurityCommand(env), [
277
279
  "find-generic-password",
278
280
  "-a",
279
281
  ACCOUNT_NAME,
280
282
  "-s",
281
283
  SERVICE_NAME,
282
284
  "-w",
283
- ]);
285
+ ], { env });
284
286
  return stdout;
285
287
  }
286
288
  catch {
287
289
  return null;
288
290
  }
289
291
  }
290
- async function writeMacOSKeychainPayload(payload) {
291
- await execFileAsync("security", [
292
- "add-generic-password",
293
- "-a",
294
- ACCOUNT_NAME,
295
- "-s",
296
- SERVICE_NAME,
297
- "-w",
298
- payload,
299
- "-U",
300
- ]);
292
+ // `security -i` reads commands with a line buffer that traditionally caps at
293
+ // 4096 bytes. Beyond that, the input is silently truncated, which would
294
+ // corrupt the stored credential and break re-auth. Reject early with a clear
295
+ // error so a user with too many profiles gets actionable feedback instead of
296
+ // a partial-write that only surfaces on the next CLI invocation.
297
+ const MACOS_SECURITY_LINE_LIMIT_BYTES = 4096;
298
+ async function writeMacOSKeychainPayload(payload, env) {
299
+ // `security add-generic-password -w PAYLOAD` would put the token in argv
300
+ // (visible to any same-UID `ps`). Use `security -i` instead: the subcommand
301
+ // is written on stdin, so the only argv `ps` ever sees is `security -i`.
302
+ const command = macOSSecurityCommand(env);
303
+ const escaped = payload.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
304
+ const line = `add-generic-password -a "${ACCOUNT_NAME}" -s "${SERVICE_NAME}" -w "${escaped}" -U\n`;
305
+ const lineBytes = Buffer.byteLength(line, "utf8");
306
+ if (lineBytes >= MACOS_SECURITY_LINE_LIMIT_BYTES) {
307
+ throw credentialStoreError("write", "macOS Keychain", {
308
+ code: "payload_too_large",
309
+ stderr: `Credential document is ${lineBytes} bytes; \`security -i\` truncates lines at ${MACOS_SECURITY_LINE_LIMIT_BYTES} bytes. Reduce stored profiles (oxygen logout <profile>) and retry.`,
310
+ });
311
+ }
312
+ await new Promise((resolve, reject) => {
313
+ const child = spawn(command, ["-i"], { env, stdio: ["pipe", "pipe", "pipe"] });
314
+ let stderr = "";
315
+ child.stderr.on("data", (chunk) => {
316
+ stderr += chunk.toString("utf8");
317
+ });
318
+ child.once("error", (error) => {
319
+ reject(credentialStoreError("write", "macOS Keychain", error));
320
+ });
321
+ child.once("close", (code, signal) => {
322
+ const trimmedStderr = stderr.trim();
323
+ if (code === 0 && !trimmedStderr) {
324
+ resolve();
325
+ return;
326
+ }
327
+ reject(credentialStoreError("write", "macOS Keychain", { code, signal, stderr: trimmedStderr }));
328
+ });
329
+ child.stdin.end(line);
330
+ });
301
331
  }
302
- async function deleteMacOSKeychain() {
303
- await execFileAsync("security", [
332
+ async function deleteMacOSKeychain(env) {
333
+ await execFileAsync(macOSSecurityCommand(env), [
304
334
  "delete-generic-password",
305
335
  "-a",
306
336
  ACCOUNT_NAME,
307
337
  "-s",
308
338
  SERVICE_NAME,
309
- ]).catch(() => undefined);
339
+ ], { env }).catch(() => undefined);
310
340
  }
311
- async function readWindowsCredentialManagerPayload() {
341
+ async function readWindowsCredentialManagerPayload(env) {
312
342
  try {
313
343
  const { stdout } = await runPowerShell(`${windowsCredentialManagerScript()}
314
344
  $ptr = [IntPtr]::Zero
315
- if (-not [OxygenCredMan]::CredRead("${SERVICE_NAME}", 1, 0, [ref]$ptr)) { exit 2 }
316
- $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][OxygenCredMan+CREDENTIAL])
317
- $secret = [Runtime.InteropServices.Marshal]::PtrToStringUni($cred.CredentialBlob, [int]($cred.CredentialBlobSize / 2))
318
- [OxygenCredMan]::CredFree($ptr)
319
- [Console]::Out.Write($secret)
320
- `);
345
+ try {
346
+ if (-not [OxygenCredMan]::CredRead("${SERVICE_NAME}", 1, 0, [ref]$ptr)) { exit 2 }
347
+ $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][OxygenCredMan+CREDENTIAL])
348
+ $secret = [Runtime.InteropServices.Marshal]::PtrToStringUni($cred.CredentialBlob, [int]($cred.CredentialBlobSize / 2))
349
+ [Console]::Out.Write($secret)
350
+ } finally {
351
+ if ($ptr -ne [IntPtr]::Zero) { [OxygenCredMan]::CredFree($ptr) }
352
+ }
353
+ `, env);
321
354
  return stdout;
322
355
  }
323
356
  catch {
324
357
  return null;
325
358
  }
326
359
  }
327
- async function writeWindowsCredentialManagerPayload(value) {
360
+ async function writeWindowsCredentialManagerPayload(value, env) {
328
361
  const payload = Buffer.from(value, "utf16le").toString("base64");
329
- await runPowerShell(`${windowsCredentialManagerScript()}
330
- $secret = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]))
362
+ try {
363
+ await runPowerShell(`${windowsCredentialManagerScript()}
364
+ $payload = $env:${WINDOWS_CREDENTIAL_PAYLOAD_ENV}
365
+ if ([string]::IsNullOrWhiteSpace($payload)) { exit 1 }
366
+ $secret = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($payload))
331
367
  $bytes = [Text.Encoding]::Unicode.GetBytes($secret)
332
368
  $blob = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni($secret)
333
369
  try {
@@ -342,23 +378,58 @@ try {
342
378
  } finally {
343
379
  [Runtime.InteropServices.Marshal]::FreeCoTaskMem($blob)
344
380
  }
345
- `, [payload]);
381
+ `, {
382
+ ...env,
383
+ [WINDOWS_CREDENTIAL_PAYLOAD_ENV]: payload,
384
+ });
385
+ }
386
+ catch (error) {
387
+ throw credentialStoreError("write", "Windows Credential Manager", error);
388
+ }
346
389
  }
347
- async function deleteWindowsCredentialManager() {
390
+ async function deleteWindowsCredentialManager(env) {
348
391
  await runPowerShell(`${windowsCredentialManagerScript()}
349
392
  [void][OxygenCredMan]::CredDelete("${SERVICE_NAME}", 1, 0)
350
- `).catch(() => undefined);
393
+ `, env).catch(() => undefined);
351
394
  }
352
- async function runPowerShell(script, args = []) {
353
- return execFileAsync("powershell.exe", [
395
+ async function runPowerShell(script, env) {
396
+ const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
397
+ return execFileAsync(windowsPowerShellCommand(env), [
354
398
  "-NoProfile",
355
399
  "-NonInteractive",
356
400
  "-ExecutionPolicy",
357
401
  "Bypass",
358
- "-Command",
359
- script,
360
- ...args,
361
- ]);
402
+ "-EncodedCommand",
403
+ encodedCommand,
404
+ ], { env });
405
+ }
406
+ function credentialStoreError(operation, store, error) {
407
+ const child = error;
408
+ const details = {
409
+ operation,
410
+ store,
411
+ };
412
+ if (typeof child.code === "number") {
413
+ details.exit_code = child.code;
414
+ }
415
+ else if (typeof child.code === "string") {
416
+ details.reason = child.code;
417
+ }
418
+ if (typeof child.signal === "string")
419
+ details.signal = child.signal;
420
+ if (typeof child.stderr === "string" && child.stderr.trim()) {
421
+ details.stderr = sanitizeCredentialErrorText(child.stderr.trim()).slice(0, 4000);
422
+ }
423
+ return new OxygenError("credential_store_failed", `Unable to ${operation} Oxygen CLI credentials in ${store}.`, { details, exitCode: 1 });
424
+ }
425
+ function sanitizeCredentialErrorText(value) {
426
+ return value.replace(/\boxy_live_[A-Za-z0-9_-]+\b/g, "[redacted_token]");
427
+ }
428
+ function macOSSecurityCommand(env) {
429
+ return env.OXYGEN_SECURITY_COMMAND?.trim() || "security";
430
+ }
431
+ function windowsPowerShellCommand(env) {
432
+ return env.OXYGEN_POWERSHELL_COMMAND?.trim() || "powershell.exe";
362
433
  }
363
434
  function windowsCredentialManagerScript() {
364
435
  return `