@bctrl/cli 0.1.5 → 0.1.7

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.
Files changed (62) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +109 -95
  3. package/dist/api/auth.d.ts +10 -1
  4. package/dist/api/auth.js +10 -1
  5. package/dist/api/client.d.ts +2 -1
  6. package/dist/api/client.js +37 -37
  7. package/dist/api/device-auth.d.ts +39 -0
  8. package/dist/api/device-auth.js +93 -0
  9. package/dist/api/errors.d.ts +1 -0
  10. package/dist/api/errors.js +8 -0
  11. package/dist/bin/bctrl.js +0 -0
  12. package/dist/commands/ai/index.js +10 -11
  13. package/dist/commands/api-key/index.js +3 -8
  14. package/dist/commands/auth/device-login.d.ts +6 -0
  15. package/dist/commands/auth/device-login.js +19 -0
  16. package/dist/commands/auth/login.d.ts +11 -0
  17. package/dist/commands/auth/login.js +140 -20
  18. package/dist/commands/auth/logout.d.ts +2 -0
  19. package/dist/commands/auth/logout.js +19 -2
  20. package/dist/commands/auth/status.d.ts +1 -0
  21. package/dist/commands/auth/status.js +22 -5
  22. package/dist/commands/auth/token.js +6 -0
  23. package/dist/commands/browser-extension/index.js +7 -15
  24. package/dist/commands/file/index.js +8 -7
  25. package/dist/commands/help/index.js +5 -4
  26. package/dist/commands/notification-recipient/index.d.ts +3 -0
  27. package/dist/commands/notification-recipient/index.js +72 -0
  28. package/dist/commands/proxy/index.js +6 -4
  29. package/dist/commands/run/index.js +31 -32
  30. package/dist/commands/runtime/index.js +186 -107
  31. package/dist/commands/shared/help.d.ts +26 -3
  32. package/dist/commands/shared/help.js +87 -23
  33. package/dist/commands/shared/io.d.ts +1 -0
  34. package/dist/commands/shared/io.js +4 -2
  35. package/dist/commands/shared/operation.d.ts +50 -3
  36. package/dist/commands/shared/operation.js +171 -25
  37. package/dist/commands/shared/output.js +3 -7
  38. package/dist/commands/space/index.js +35 -32
  39. package/dist/commands/space/list.js +6 -5
  40. package/dist/commands/subaccount/index.js +13 -15
  41. package/dist/commands/usage/index.js +9 -5
  42. package/dist/commands/vault/index.js +15 -21
  43. package/dist/config/auth-store.d.ts +75 -9
  44. package/dist/config/auth-store.js +133 -27
  45. package/dist/config/config.d.ts +2 -2
  46. package/dist/config/config.js +9 -17
  47. package/dist/config/paths.d.ts +16 -0
  48. package/dist/config/paths.js +34 -0
  49. package/dist/config/pending-auth.d.ts +19 -0
  50. package/dist/config/pending-auth.js +72 -0
  51. package/dist/config/secret-store.d.ts +24 -0
  52. package/dist/config/secret-store.js +123 -0
  53. package/dist/factory.js +2 -1
  54. package/dist/generated/help.d.ts +7218 -2535
  55. package/dist/generated/help.js +10057 -3928
  56. package/dist/generated/openapi-routes.d.ts +52 -8
  57. package/dist/generated/openapi-routes.js +20 -9
  58. package/dist/generated/openapi-types.d.ts +2729 -849
  59. package/dist/root.js +2 -0
  60. package/dist/version.d.ts +1 -0
  61. package/dist/version.js +4 -0
  62. package/package.json +56 -47
@@ -1,4 +1,4 @@
1
- import { type StoredAuth } from './auth-store.js';
1
+ import { type StoredCredential } from './auth-store.js';
2
2
  export type TokenSource = 'BCTRL_API_KEY' | 'stored' | 'none';
3
3
  export type ActiveToken = {
4
4
  token: string;
@@ -7,6 +7,6 @@ export type ActiveToken = {
7
7
  export type BctrlConfig = {
8
8
  apiBaseUrl: string;
9
9
  activeToken: ActiveToken | null;
10
- storedAuth: StoredAuth | null;
10
+ storedAuth: StoredCredential | null;
11
11
  };
12
12
  export declare function loadConfig(env?: NodeJS.ProcessEnv): Promise<BctrlConfig>;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { readStoredAuth } from './auth-store.js';
2
+ import { loadCredential, normalizeApiBaseUrl } from './auth-store.js';
3
3
  const DEFAULT_API_BASE_URL = 'https://api.bctrl.ai/v1';
4
4
  const EnvSchema = z.object({
5
5
  BCTRL_API_KEY: z.string().optional(),
@@ -8,6 +8,7 @@ const EnvSchema = z.object({
8
8
  export async function loadConfig(env = process.env) {
9
9
  const parsed = EnvSchema.parse(env);
10
10
  const apiBaseUrl = normalizeApiBaseUrl(parsed.BCTRL_API_URL ?? DEFAULT_API_BASE_URL);
11
+ // Layer 1: ambient env override (CI/agents). Skips any keychain/file read.
11
12
  const apiKey = parsed.BCTRL_API_KEY?.trim();
12
13
  if (apiKey) {
13
14
  return {
@@ -16,20 +17,11 @@ export async function loadConfig(env = process.env) {
16
17
  storedAuth: null,
17
18
  };
18
19
  }
19
- const storedAuth = await readStoredAuth(env);
20
- const activeToken = resolveStoredToken(apiBaseUrl, storedAuth);
21
- return {
22
- apiBaseUrl,
23
- activeToken,
24
- storedAuth,
25
- };
26
- }
27
- function resolveStoredToken(apiBaseUrl, storedAuth) {
28
- if (storedAuth && normalizeApiBaseUrl(storedAuth.apiBaseUrl) === apiBaseUrl) {
29
- return { token: storedAuth.token, source: 'stored' };
30
- }
31
- return null;
32
- }
33
- function normalizeApiBaseUrl(url) {
34
- return url.replace(/\/+$/, '');
20
+ // Layer 2: stored credential — metadata (file) + secret (keychain/file),
21
+ // scoped to the active API base URL.
22
+ const storedAuth = await loadCredential(env, apiBaseUrl);
23
+ const activeToken = storedAuth
24
+ ? { token: storedAuth.token, source: 'stored' }
25
+ : null;
26
+ return { apiBaseUrl, activeToken, storedAuth };
35
27
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OS-native config directory for the CLI. Resolution precedence:
3
+ * 1. `BCTRL_CONFIG_DIR` — explicit override (tests/dev).
4
+ * 2. `XDG_CONFIG_HOME/bctrl` — honored on every platform for parity with the
5
+ * previous behavior and POSIX expectations.
6
+ * 3. env-paths default (`%APPDATA%\bctrl` / `~/Library/Application Support/bctrl`
7
+ * / `~/.config/bctrl`). `suffix: ''` drops the `-nodejs` suffix.
8
+ */
9
+ export declare function getConfigDir(env?: NodeJS.ProcessEnv): string;
10
+ /**
11
+ * Path to the non-secret credential metadata file. `BCTRL_AUTH_FILE` overrides
12
+ * the exact location (tests/dev escape hatch).
13
+ */
14
+ export declare function getMetadataFilePath(env?: NodeJS.ProcessEnv): string;
15
+ /** Directory for the plaintext-fallback secret files (alongside the metadata file). */
16
+ export declare function getSecretsDir(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,34 @@
1
+ import envPaths from 'env-paths';
2
+ import { dirname, join } from 'node:path';
3
+ const DEFAULT_AUTH_FILE_NAME = 'auth.json';
4
+ /**
5
+ * OS-native config directory for the CLI. Resolution precedence:
6
+ * 1. `BCTRL_CONFIG_DIR` — explicit override (tests/dev).
7
+ * 2. `XDG_CONFIG_HOME/bctrl` — honored on every platform for parity with the
8
+ * previous behavior and POSIX expectations.
9
+ * 3. env-paths default (`%APPDATA%\bctrl` / `~/Library/Application Support/bctrl`
10
+ * / `~/.config/bctrl`). `suffix: ''` drops the `-nodejs` suffix.
11
+ */
12
+ export function getConfigDir(env = process.env) {
13
+ const explicit = env.BCTRL_CONFIG_DIR?.trim();
14
+ if (explicit)
15
+ return explicit;
16
+ const xdg = env.XDG_CONFIG_HOME?.trim();
17
+ if (xdg)
18
+ return join(xdg, 'bctrl');
19
+ return envPaths('bctrl', { suffix: '' }).config;
20
+ }
21
+ /**
22
+ * Path to the non-secret credential metadata file. `BCTRL_AUTH_FILE` overrides
23
+ * the exact location (tests/dev escape hatch).
24
+ */
25
+ export function getMetadataFilePath(env = process.env) {
26
+ const explicit = env.BCTRL_AUTH_FILE?.trim();
27
+ if (explicit)
28
+ return explicit;
29
+ return join(getConfigDir(env), DEFAULT_AUTH_FILE_NAME);
30
+ }
31
+ /** Directory for the plaintext-fallback secret files (alongside the metadata file). */
32
+ export function getSecretsDir(env = process.env) {
33
+ return dirname(getMetadataFilePath(env));
34
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ import type { DeviceStartResponse } from '../api/device-auth.js';
3
+ export declare const PendingDeviceAuthSchema: z.ZodObject<{
4
+ apiBaseUrl: z.ZodString;
5
+ deviceCode: z.ZodString;
6
+ userCode: z.ZodString;
7
+ verificationUri: z.ZodString;
8
+ verificationUriComplete: z.ZodString;
9
+ interval: z.ZodNumber;
10
+ expiresAt: z.ZodString;
11
+ createdAt: z.ZodString;
12
+ }, z.core.$strict>;
13
+ export type PendingDeviceAuth = z.infer<typeof PendingDeviceAuthSchema>;
14
+ export declare function getPendingAuthFilePath(env?: NodeJS.ProcessEnv): string;
15
+ export declare function savePendingDeviceAuth(apiBaseUrl: string, session: DeviceStartResponse, env?: NodeJS.ProcessEnv, now?: () => number): Promise<PendingDeviceAuth>;
16
+ export declare function readPendingDeviceAuth(env?: NodeJS.ProcessEnv): Promise<PendingDeviceAuth | null>;
17
+ export declare function clearPendingDeviceAuth(env?: NodeJS.ProcessEnv): Promise<void>;
18
+ export declare function pendingMatchesApiBaseUrl(pending: PendingDeviceAuth, apiBaseUrl: string): boolean;
19
+ export declare function pendingExpired(pending: PendingDeviceAuth, now?: () => number): boolean;
@@ -0,0 +1,72 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { normalizeApiBaseUrl } from './auth-store.js';
5
+ import { getMetadataFilePath } from './paths.js';
6
+ const PENDING_AUTH_FILE_NAME = 'pending-auth.json';
7
+ export const PendingDeviceAuthSchema = z
8
+ .object({
9
+ apiBaseUrl: z.string().url(),
10
+ deviceCode: z.string().min(1),
11
+ userCode: z.string().min(1),
12
+ verificationUri: z.string().min(1),
13
+ verificationUriComplete: z.string().min(1),
14
+ interval: z.number().int().positive(),
15
+ expiresAt: z.string().datetime(),
16
+ createdAt: z.string().datetime(),
17
+ })
18
+ .strict();
19
+ export function getPendingAuthFilePath(env = process.env) {
20
+ return join(dirname(getMetadataFilePath(env)), PENDING_AUTH_FILE_NAME);
21
+ }
22
+ export async function savePendingDeviceAuth(apiBaseUrl, session, env = process.env, now = Date.now) {
23
+ const createdAtMs = now();
24
+ const pending = {
25
+ apiBaseUrl: normalizeApiBaseUrl(apiBaseUrl),
26
+ deviceCode: session.deviceCode,
27
+ userCode: session.userCode,
28
+ verificationUri: session.verificationUri,
29
+ verificationUriComplete: session.verificationUriComplete,
30
+ interval: session.interval,
31
+ createdAt: new Date(createdAtMs).toISOString(),
32
+ expiresAt: new Date(createdAtMs + session.expiresIn * 1000).toISOString(),
33
+ };
34
+ const path = getPendingAuthFilePath(env);
35
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
36
+ await writeFile(path, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
37
+ return pending;
38
+ }
39
+ export async function readPendingDeviceAuth(env = process.env) {
40
+ const path = getPendingAuthFilePath(env);
41
+ try {
42
+ const parsed = PendingDeviceAuthSchema.safeParse(JSON.parse(await readFile(path, 'utf8')));
43
+ return parsed.success ? parsed.data : null;
44
+ }
45
+ catch (error) {
46
+ if (isFileNotFound(error) || error instanceof SyntaxError)
47
+ return null;
48
+ const reason = error instanceof Error ? error.message : String(error);
49
+ throw new Error(`Failed to read pending BCTRL login at ${path}: ${reason}`);
50
+ }
51
+ }
52
+ export async function clearPendingDeviceAuth(env = process.env) {
53
+ try {
54
+ await rm(getPendingAuthFilePath(env));
55
+ }
56
+ catch (error) {
57
+ if (!isFileNotFound(error))
58
+ throw error;
59
+ }
60
+ }
61
+ export function pendingMatchesApiBaseUrl(pending, apiBaseUrl) {
62
+ return normalizeApiBaseUrl(pending.apiBaseUrl) === normalizeApiBaseUrl(apiBaseUrl);
63
+ }
64
+ export function pendingExpired(pending, now = Date.now) {
65
+ return Date.parse(pending.expiresAt) <= now();
66
+ }
67
+ function isFileNotFound(error) {
68
+ return (typeof error === 'object' &&
69
+ error !== null &&
70
+ 'code' in error &&
71
+ error.code === 'ENOENT');
72
+ }
@@ -0,0 +1,24 @@
1
+ export type SecretBackend = 'keychain' | 'file';
2
+ export interface SecretStore {
3
+ readonly backend: SecretBackend;
4
+ get(account: string): Promise<string | null>;
5
+ set(account: string, secret: string): Promise<void>;
6
+ delete(account: string): Promise<boolean>;
7
+ }
8
+ export type ResolvedSecretStore = {
9
+ store: SecretStore;
10
+ /** True when the OS keychain was wanted but is unavailable, so a 0600 file is used. */
11
+ keychainFallback: boolean;
12
+ };
13
+ /**
14
+ * Pick the secret backend. `BCTRL_SECRET_STORE=file` forces the file store
15
+ * (tests/dev/CI). Otherwise the OS keychain is used when available, falling back
16
+ * — loudly, at the call site — to a 0600 file when it is not.
17
+ */
18
+ export declare function resolveSecretStore(env?: NodeJS.ProcessEnv): ResolvedSecretStore;
19
+ /**
20
+ * Delete a secret from BOTH backends, best-effort, so logout cannot orphan a
21
+ * secret regardless of which backend stored it (or whether the metadata file
22
+ * still exists). Skips the OS keychain entirely when `BCTRL_SECRET_STORE=file`.
23
+ */
24
+ export declare function deleteSecretAllBackends(account: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
@@ -0,0 +1,123 @@
1
+ import { Entry } from '@napi-rs/keyring';
2
+ import { createHash } from 'node:crypto';
3
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { getSecretsDir } from './paths.js';
6
+ // One keychain "service"; the per-credential "account" is the normalized API
7
+ // base URL (see normalizeApiBaseUrl in auth-store), so credentials for prod and
8
+ // a local/dev stack coexist and never collide.
9
+ const KEYRING_SERVICE = 'bctrl';
10
+ class KeychainSecretStore {
11
+ backend = 'keychain';
12
+ entry(account) {
13
+ return new Entry(KEYRING_SERVICE, account);
14
+ }
15
+ async get(account) {
16
+ // Sync getPassword returns null when there is no entry (it does not throw).
17
+ return this.entry(account).getPassword() ?? null;
18
+ }
19
+ async set(account, secret) {
20
+ this.entry(account).setPassword(secret);
21
+ }
22
+ async delete(account) {
23
+ try {
24
+ this.entry(account).deletePassword();
25
+ return true;
26
+ }
27
+ catch {
28
+ return false; // NoEntry — nothing to delete
29
+ }
30
+ }
31
+ }
32
+ class FileSecretStore {
33
+ dir;
34
+ backend = 'file';
35
+ constructor(dir) {
36
+ this.dir = dir;
37
+ }
38
+ fileFor(account) {
39
+ const hash = createHash('sha256').update(account).digest('hex').slice(0, 24);
40
+ return join(this.dir, `bctrl-${hash}.secret`);
41
+ }
42
+ async get(account) {
43
+ try {
44
+ const text = (await readFile(this.fileFor(account), 'utf8')).trim();
45
+ return text.length > 0 ? text : null;
46
+ }
47
+ catch (error) {
48
+ if (isFileNotFound(error))
49
+ return null;
50
+ throw error;
51
+ }
52
+ }
53
+ async set(account, secret) {
54
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
55
+ const file = this.fileFor(account);
56
+ await writeFile(file, `${secret}\n`, { mode: 0o600 });
57
+ await chmod(file, 0o600).catch(() => { });
58
+ }
59
+ async delete(account) {
60
+ try {
61
+ await rm(this.fileFor(account));
62
+ return true;
63
+ }
64
+ catch (error) {
65
+ if (isFileNotFound(error))
66
+ return false;
67
+ throw error;
68
+ }
69
+ }
70
+ }
71
+ function isFileNotFound(error) {
72
+ return (typeof error === 'object' &&
73
+ error !== null &&
74
+ 'code' in error &&
75
+ error.code === 'ENOENT');
76
+ }
77
+ let keychainProbe;
78
+ function keychainAvailable() {
79
+ if (keychainProbe !== undefined)
80
+ return keychainProbe;
81
+ try {
82
+ // A read against a probe account succeeds (returns null) when a keychain
83
+ // backend exists, and throws when none is available (headless Linux/CI).
84
+ new Entry(KEYRING_SERVICE, 'bctrl-availability-probe').getPassword();
85
+ keychainProbe = true;
86
+ }
87
+ catch {
88
+ keychainProbe = false;
89
+ }
90
+ return keychainProbe;
91
+ }
92
+ /**
93
+ * Pick the secret backend. `BCTRL_SECRET_STORE=file` forces the file store
94
+ * (tests/dev/CI). Otherwise the OS keychain is used when available, falling back
95
+ * — loudly, at the call site — to a 0600 file when it is not.
96
+ */
97
+ export function resolveSecretStore(env = process.env) {
98
+ const forced = env.BCTRL_SECRET_STORE?.trim();
99
+ if (forced === 'file') {
100
+ return { store: new FileSecretStore(getSecretsDir(env)), keychainFallback: false };
101
+ }
102
+ if (keychainAvailable()) {
103
+ return { store: new KeychainSecretStore(), keychainFallback: false };
104
+ }
105
+ return { store: new FileSecretStore(getSecretsDir(env)), keychainFallback: true };
106
+ }
107
+ /**
108
+ * Delete a secret from BOTH backends, best-effort, so logout cannot orphan a
109
+ * secret regardless of which backend stored it (or whether the metadata file
110
+ * still exists). Skips the OS keychain entirely when `BCTRL_SECRET_STORE=file`.
111
+ */
112
+ export async function deleteSecretAllBackends(account, env = process.env) {
113
+ let removed = false;
114
+ if (await new FileSecretStore(getSecretsDir(env)).delete(account)) {
115
+ removed = true;
116
+ }
117
+ if (env.BCTRL_SECRET_STORE?.trim() !== 'file' && keychainAvailable()) {
118
+ if (await new KeychainSecretStore().delete(account)) {
119
+ removed = true;
120
+ }
121
+ }
122
+ return removed;
123
+ }
package/dist/factory.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createBctrlApiClient } from './api/client.js';
2
2
  import { loadConfig } from './config/config.js';
3
+ import { CLI_VERSION } from './version.js';
3
4
  export function createFactory(options) {
4
5
  let configPromise;
5
6
  const config = () => {
@@ -7,7 +8,7 @@ export function createFactory(options) {
7
8
  return configPromise;
8
9
  };
9
10
  return {
10
- version: options.version ?? '0.1.3',
11
+ version: options.version ?? CLI_VERSION,
11
12
  io: options.io,
12
13
  config,
13
14
  apiClient: async () => createBctrlApiClient(await config()),