@oxygen-agent/cli 1.46.0 → 1.64.5

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.64.5
@@ -1,6 +1,19 @@
1
+ export type StoredProfileIdentity = {
2
+ organization: {
3
+ id: string;
4
+ slug: string | null;
5
+ name: string;
6
+ };
7
+ user: {
8
+ id: string;
9
+ email: string | null;
10
+ };
11
+ capturedAt: string;
12
+ };
1
13
  export type StoredCredentials = {
2
14
  token: string;
3
15
  apiUrl: string;
16
+ identity?: StoredProfileIdentity;
4
17
  };
5
18
  export type CredentialProfile = StoredCredentials & {
6
19
  name: string;
@@ -23,5 +36,17 @@ export declare function clearCredentials(env?: NodeJS.ProcessEnv, options?: {
23
36
  remainingProfiles: number;
24
37
  }>;
25
38
  export declare function listCredentialProfiles(env?: NodeJS.ProcessEnv): Promise<CredentialProfilesState>;
39
+ export type ProfileResolution = {
40
+ name: string;
41
+ source: "env" | "file" | "default";
42
+ credentials: StoredCredentials | null;
43
+ exists: boolean;
44
+ };
45
+ export declare function resolveActiveProfile(env?: NodeJS.ProcessEnv): Promise<ProfileResolution>;
46
+ export declare function findProfileNameByOrganizationId(organizationId: string, env?: NodeJS.ProcessEnv): Promise<string | null>;
47
+ export declare function pickProfileNameForIdentity(organizationId: string, candidateSeed: string, env?: NodeJS.ProcessEnv): Promise<{
48
+ name: string;
49
+ renamed: boolean;
50
+ }>;
26
51
  export declare function switchCredentialProfile(profile: string, env?: NodeJS.ProcessEnv): Promise<CredentialProfile>;
27
52
  export declare function normalizeApiUrl(value: string): string;
@@ -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
  }
@@ -42,9 +43,17 @@ export async function saveCredentials(credentials, env = process.env, options =
42
43
  token: credentials.token,
43
44
  apiUrl: normalizeApiUrl(credentials.apiUrl),
44
45
  };
46
+ if (credentials.identity)
47
+ normalized.identity = credentials.identity;
45
48
  const existing = await readSystemCredentialDocument(env);
46
49
  const profile = normalizeProfileName(options.profile ?? readEnvProfile(env) ?? existing?.activeProfile ?? DEFAULT_PROFILE_NAME);
47
50
  const profiles = existing ? readProfiles(existing) : {};
51
+ if (!normalized.identity) {
52
+ const prior = profiles[profile];
53
+ if (prior?.identity && prior.token === normalized.token) {
54
+ normalized.identity = prior.identity;
55
+ }
56
+ }
48
57
  profiles[profile] = normalized;
49
58
  const activeProfile = options.activate === false
50
59
  ? readProfileName(existing?.activeProfile) ?? profile
@@ -68,6 +77,7 @@ export async function clearCredentials(env = process.env, options = {}) {
68
77
  exitCode: 1,
69
78
  });
70
79
  }
80
+ // skipcq: JS-0320
71
81
  delete profiles[profile];
72
82
  const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b));
73
83
  if (names.length === 0) {
@@ -114,6 +124,50 @@ export async function listCredentialProfiles(env = process.env) {
114
124
  }),
115
125
  };
116
126
  }
127
+ export async function resolveActiveProfile(env = process.env) {
128
+ const envProfile = readEnvProfile(env);
129
+ const document = await readSystemCredentialDocument(env);
130
+ const profiles = document ? readProfiles(document) : {};
131
+ const fileActive = readProfileName(document?.activeProfile);
132
+ const candidate = envProfile ?? fileActive ?? DEFAULT_PROFILE_NAME;
133
+ const credentials = profiles[candidate] ?? null;
134
+ const source = envProfile
135
+ ? "env"
136
+ : fileActive
137
+ ? "file"
138
+ : "default";
139
+ return { name: candidate, source, credentials, exists: Boolean(credentials) };
140
+ }
141
+ export async function findProfileNameByOrganizationId(organizationId, env = process.env) {
142
+ const document = await readSystemCredentialDocument(env);
143
+ if (!document)
144
+ return null;
145
+ const profiles = readProfiles(document);
146
+ for (const [name, credentials] of Object.entries(profiles)) {
147
+ if (credentials.identity?.organization.id === organizationId)
148
+ return name;
149
+ }
150
+ return null;
151
+ }
152
+ export async function pickProfileNameForIdentity(organizationId, candidateSeed, env = process.env) {
153
+ const document = await readSystemCredentialDocument(env);
154
+ const profiles = document ? readProfiles(document) : {};
155
+ const existingForOrg = Object.entries(profiles).find(([, credentials]) => credentials.identity?.organization.id === organizationId);
156
+ if (existingForOrg)
157
+ return { name: existingForOrg[0], renamed: false };
158
+ const base = normalizeProfileName(candidateSeed);
159
+ if (!profiles[base])
160
+ return { name: base, renamed: false };
161
+ for (let suffix = 2; suffix < 1000; suffix++) {
162
+ const next = `${base}-${suffix}`;
163
+ if (!profiles[next])
164
+ return { name: next, renamed: true };
165
+ }
166
+ throw new OxygenError("profile_collision", "Unable to derive a unique CLI profile name.", {
167
+ details: { base },
168
+ exitCode: 1,
169
+ });
170
+ }
117
171
  export async function switchCredentialProfile(profile, env = process.env) {
118
172
  const document = await readSystemCredentialDocument(env);
119
173
  if (!document) {
@@ -186,19 +240,19 @@ async function readSystemCredentialDocument(env) {
186
240
  async function readSystemCredentialPayload(env) {
187
241
  const store = credentialStore(env);
188
242
  if (store === "keychain")
189
- return readMacOSKeychainPayload();
243
+ return readMacOSKeychainPayload(env);
190
244
  if (store === "credential-manager")
191
- return readWindowsCredentialManagerPayload();
245
+ return readWindowsCredentialManagerPayload(env);
192
246
  return readFileCredentialPayload(env);
193
247
  }
194
248
  async function writeSystemCredentialPayload(payload, env) {
195
249
  const store = credentialStore(env);
196
250
  if (store === "keychain") {
197
- await writeMacOSKeychainPayload(payload);
251
+ await writeMacOSKeychainPayload(payload, env);
198
252
  return;
199
253
  }
200
254
  if (store === "credential-manager") {
201
- await writeWindowsCredentialManagerPayload(payload);
255
+ await writeWindowsCredentialManagerPayload(payload, env);
202
256
  return;
203
257
  }
204
258
  writeFileCredentialPayload(payload, env);
@@ -206,11 +260,11 @@ async function writeSystemCredentialPayload(payload, env) {
206
260
  async function deleteSystemCredentialPayload(env) {
207
261
  const store = credentialStore(env);
208
262
  if (store === "keychain") {
209
- await deleteMacOSKeychain();
263
+ await deleteMacOSKeychain(env);
210
264
  return;
211
265
  }
212
266
  if (store === "credential-manager") {
213
- await deleteWindowsCredentialManager();
267
+ await deleteWindowsCredentialManager(env);
214
268
  return;
215
269
  }
216
270
  deleteFileCredentials(env);
@@ -229,6 +283,8 @@ async function writeCredentialDocument(input, env) {
229
283
  activeProfile: input.activeProfile,
230
284
  profiles: input.profiles,
231
285
  };
286
+ if (activeCredentials.identity)
287
+ document.identity = activeCredentials.identity;
232
288
  await writeSystemCredentialPayload(serializeCredentialDocument(document), env);
233
289
  }
234
290
  function credentialStore(env) {
@@ -271,63 +327,97 @@ function deleteFileCredentials(env) {
271
327
  const path = credentialsPath(env);
272
328
  rmSync(path, { force: true });
273
329
  }
274
- async function readMacOSKeychainPayload() {
330
+ async function readMacOSKeychainPayload(env) {
275
331
  try {
276
- const { stdout } = await execFileAsync("security", [
332
+ const { stdout } = await execFileAsync(macOSSecurityCommand(env), [
277
333
  "find-generic-password",
278
334
  "-a",
279
335
  ACCOUNT_NAME,
280
336
  "-s",
281
337
  SERVICE_NAME,
282
338
  "-w",
283
- ]);
339
+ ], { env });
284
340
  return stdout;
285
341
  }
286
342
  catch {
287
343
  return null;
288
344
  }
289
345
  }
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
- ]);
346
+ // `security -i` reads commands with a line buffer that traditionally caps at
347
+ // 4096 bytes. Beyond that, the input is silently truncated, which would
348
+ // corrupt the stored credential and break re-auth. Reject early with a clear
349
+ // error so a user with too many profiles gets actionable feedback instead of
350
+ // a partial-write that only surfaces on the next CLI invocation.
351
+ const MACOS_SECURITY_LINE_LIMIT_BYTES = 4096;
352
+ async function writeMacOSKeychainPayload(payload, env) {
353
+ // `security add-generic-password -w PAYLOAD` would put the token in argv
354
+ // (visible to any same-UID `ps`). Use `security -i` instead: the subcommand
355
+ // is written on stdin, so the only argv `ps` ever sees is `security -i`.
356
+ const command = macOSSecurityCommand(env);
357
+ const escaped = payload.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
358
+ const line = `add-generic-password -a "${ACCOUNT_NAME}" -s "${SERVICE_NAME}" -w "${escaped}" -U\n`;
359
+ const lineBytes = Buffer.byteLength(line, "utf8");
360
+ if (lineBytes >= MACOS_SECURITY_LINE_LIMIT_BYTES) {
361
+ throw credentialStoreError("write", "macOS Keychain", {
362
+ code: "payload_too_large",
363
+ 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.`,
364
+ });
365
+ }
366
+ await new Promise((resolve, reject) => {
367
+ const child = spawn(command, ["-i"], { env, stdio: ["pipe", "pipe", "pipe"] });
368
+ let stderr = "";
369
+ child.stderr.on("data", (chunk) => {
370
+ stderr += chunk.toString("utf8");
371
+ });
372
+ child.once("error", (error) => {
373
+ reject(credentialStoreError("write", "macOS Keychain", error));
374
+ });
375
+ child.once("close", (code, signal) => {
376
+ const trimmedStderr = stderr.trim();
377
+ if (code === 0 && !trimmedStderr) {
378
+ resolve();
379
+ return;
380
+ }
381
+ reject(credentialStoreError("write", "macOS Keychain", { code, signal, stderr: trimmedStderr }));
382
+ });
383
+ child.stdin.end(line);
384
+ });
301
385
  }
302
- async function deleteMacOSKeychain() {
303
- await execFileAsync("security", [
386
+ async function deleteMacOSKeychain(env) {
387
+ await execFileAsync(macOSSecurityCommand(env), [
304
388
  "delete-generic-password",
305
389
  "-a",
306
390
  ACCOUNT_NAME,
307
391
  "-s",
308
392
  SERVICE_NAME,
309
- ]).catch(() => undefined);
393
+ ], { env }).catch(() => undefined);
310
394
  }
311
- async function readWindowsCredentialManagerPayload() {
395
+ async function readWindowsCredentialManagerPayload(env) {
312
396
  try {
313
397
  const { stdout } = await runPowerShell(`${windowsCredentialManagerScript()}
314
398
  $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
- `);
399
+ try {
400
+ if (-not [OxygenCredMan]::CredRead("${SERVICE_NAME}", 1, 0, [ref]$ptr)) { exit 2 }
401
+ $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][OxygenCredMan+CREDENTIAL])
402
+ $secret = [Runtime.InteropServices.Marshal]::PtrToStringUni($cred.CredentialBlob, [int]($cred.CredentialBlobSize / 2))
403
+ [Console]::Out.Write($secret)
404
+ } finally {
405
+ if ($ptr -ne [IntPtr]::Zero) { [OxygenCredMan]::CredFree($ptr) }
406
+ }
407
+ `, env);
321
408
  return stdout;
322
409
  }
323
410
  catch {
324
411
  return null;
325
412
  }
326
413
  }
327
- async function writeWindowsCredentialManagerPayload(value) {
414
+ async function writeWindowsCredentialManagerPayload(value, env) {
328
415
  const payload = Buffer.from(value, "utf16le").toString("base64");
329
- await runPowerShell(`${windowsCredentialManagerScript()}
330
- $secret = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($args[0]))
416
+ try {
417
+ await runPowerShell(`${windowsCredentialManagerScript()}
418
+ $payload = $env:${WINDOWS_CREDENTIAL_PAYLOAD_ENV}
419
+ if ([string]::IsNullOrWhiteSpace($payload)) { exit 1 }
420
+ $secret = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($payload))
331
421
  $bytes = [Text.Encoding]::Unicode.GetBytes($secret)
332
422
  $blob = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni($secret)
333
423
  try {
@@ -342,23 +432,58 @@ try {
342
432
  } finally {
343
433
  [Runtime.InteropServices.Marshal]::FreeCoTaskMem($blob)
344
434
  }
345
- `, [payload]);
435
+ `, {
436
+ ...env,
437
+ [WINDOWS_CREDENTIAL_PAYLOAD_ENV]: payload,
438
+ });
439
+ }
440
+ catch (error) {
441
+ throw credentialStoreError("write", "Windows Credential Manager", error);
442
+ }
346
443
  }
347
- async function deleteWindowsCredentialManager() {
444
+ async function deleteWindowsCredentialManager(env) {
348
445
  await runPowerShell(`${windowsCredentialManagerScript()}
349
446
  [void][OxygenCredMan]::CredDelete("${SERVICE_NAME}", 1, 0)
350
- `).catch(() => undefined);
447
+ `, env).catch(() => undefined);
351
448
  }
352
- async function runPowerShell(script, args = []) {
353
- return execFileAsync("powershell.exe", [
449
+ function runPowerShell(script, env) {
450
+ const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
451
+ return execFileAsync(windowsPowerShellCommand(env), [
354
452
  "-NoProfile",
355
453
  "-NonInteractive",
356
454
  "-ExecutionPolicy",
357
455
  "Bypass",
358
- "-Command",
359
- script,
360
- ...args,
361
- ]);
456
+ "-EncodedCommand",
457
+ encodedCommand,
458
+ ], { env });
459
+ }
460
+ function credentialStoreError(operation, store, error) {
461
+ const child = error;
462
+ const details = {
463
+ operation,
464
+ store,
465
+ };
466
+ if (typeof child.code === "number") {
467
+ details.exit_code = child.code;
468
+ }
469
+ else if (typeof child.code === "string") {
470
+ details.reason = child.code;
471
+ }
472
+ if (typeof child.signal === "string")
473
+ details.signal = child.signal;
474
+ if (typeof child.stderr === "string" && child.stderr.trim()) {
475
+ details.stderr = sanitizeCredentialErrorText(child.stderr.trim()).slice(0, 4000);
476
+ }
477
+ return new OxygenError("credential_store_failed", `Unable to ${operation} Oxygen CLI credentials in ${store}.`, { details, exitCode: 1 });
478
+ }
479
+ function sanitizeCredentialErrorText(value) {
480
+ return value.replace(/\boxy_live_[A-Za-z0-9_-]+\b/g, "[redacted_token]");
481
+ }
482
+ function macOSSecurityCommand(env) {
483
+ return env.OXYGEN_SECURITY_COMMAND?.trim() || "security";
484
+ }
485
+ function windowsPowerShellCommand(env) {
486
+ return env.OXYGEN_POWERSHELL_COMMAND?.trim() || "powershell.exe";
362
487
  }
363
488
  function windowsCredentialManagerScript() {
364
489
  return `
@@ -445,16 +570,60 @@ function parseStoredCredentialsObject(value) {
445
570
  return null;
446
571
  if (typeof apiUrl !== "string" || !apiUrl.trim())
447
572
  return null;
448
- return { token: token.trim(), apiUrl: normalizeApiUrl(apiUrl) };
573
+ const credentials = {
574
+ token: token.trim(),
575
+ apiUrl: normalizeApiUrl(apiUrl),
576
+ };
577
+ const identity = parseStoredProfileIdentity(value.identity);
578
+ if (identity)
579
+ credentials.identity = identity;
580
+ return credentials;
581
+ }
582
+ function parseStoredProfileIdentity(value) {
583
+ if (!value || typeof value !== "object" || Array.isArray(value))
584
+ return null;
585
+ const record = value;
586
+ const org = record.organization;
587
+ const user = record.user;
588
+ if (!org || typeof org !== "object" || Array.isArray(org))
589
+ return null;
590
+ if (!user || typeof user !== "object" || Array.isArray(user))
591
+ return null;
592
+ const orgRecord = org;
593
+ const userRecord = user;
594
+ const orgId = typeof orgRecord.id === "string" ? orgRecord.id.trim() : "";
595
+ const orgName = typeof orgRecord.name === "string" ? orgRecord.name : "";
596
+ if (!orgId || !orgName)
597
+ return null;
598
+ const orgSlug = typeof orgRecord.slug === "string" && orgRecord.slug.trim()
599
+ ? orgRecord.slug.trim()
600
+ : null;
601
+ const userId = typeof userRecord.id === "string" ? userRecord.id.trim() : "";
602
+ if (!userId)
603
+ return null;
604
+ const userEmail = typeof userRecord.email === "string" && userRecord.email.trim()
605
+ ? userRecord.email.trim()
606
+ : null;
607
+ const capturedAt = typeof record.capturedAt === "string" && record.capturedAt.trim()
608
+ ? record.capturedAt.trim()
609
+ : new Date(0).toISOString();
610
+ return {
611
+ organization: { id: orgId, slug: orgSlug, name: orgName },
612
+ user: { id: userId, email: userEmail },
613
+ capturedAt,
614
+ };
449
615
  }
450
616
  function readProfiles(document) {
451
617
  const profiles = {};
452
618
  const rawProfiles = document.profiles ?? {};
453
619
  for (const [name, credentials] of Object.entries(rawProfiles)) {
454
- profiles[normalizeProfileName(name)] = {
620
+ const normalized = {
455
621
  token: credentials.token,
456
622
  apiUrl: normalizeApiUrl(credentials.apiUrl),
457
623
  };
624
+ if (credentials.identity)
625
+ normalized.identity = credentials.identity;
626
+ profiles[normalizeProfileName(name)] = normalized;
458
627
  }
459
628
  if (Object.keys(profiles).length === 0) {
460
629
  const defaultCredentials = parseStoredCredentialsObject(document);