@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
@@ -0,0 +1,19 @@
1
+ import { hostname } from 'node:os';
2
+ import { startDeviceAuth as defaultStartDeviceAuth, } from '../../api/device-auth.js';
3
+ export async function startDeviceLoginSession(deps) {
4
+ const start = deps.startDeviceAuth ?? defaultStartDeviceAuth;
5
+ return start(deps.apiBaseUrl, {
6
+ clientName: 'BCTRL CLI',
7
+ clientKind: 'cli',
8
+ deviceName: safeHostname(),
9
+ });
10
+ }
11
+ function safeHostname() {
12
+ try {
13
+ const name = hostname().trim();
14
+ return name.length > 0 ? name.slice(0, 80) : 'cli';
15
+ }
16
+ catch {
17
+ return 'cli';
18
+ }
19
+ }
@@ -3,12 +3,23 @@ import type { BctrlConfig } from '../../config/config.js';
3
3
  import type { Factory } from '../../factory.js';
4
4
  import type { IOStreams } from '../../io/streams.js';
5
5
  import { validateAuthToken } from '../../api/auth.js';
6
+ import { pollDeviceAuth as defaultPollDeviceAuth, startDeviceAuth as defaultStartDeviceAuth } from '../../api/device-auth.js';
7
+ export type AuthLoginDeviceDeps = {
8
+ startDeviceAuth?: typeof defaultStartDeviceAuth;
9
+ pollDeviceAuth?: typeof defaultPollDeviceAuth;
10
+ now?: () => number;
11
+ sleep?: (ms: number) => Promise<void>;
12
+ };
6
13
  export type AuthLoginOptions = {
7
14
  io: IOStreams;
8
15
  config: () => Promise<BctrlConfig>;
9
16
  withToken?: boolean;
17
+ tokenFile?: string;
18
+ url?: boolean;
19
+ wait?: boolean;
10
20
  validateToken?: typeof validateAuthToken;
11
21
  env?: NodeJS.ProcessEnv;
22
+ deviceLoginDeps?: AuthLoginDeviceDeps;
12
23
  };
13
24
  export declare function createAuthLoginCommand(factory: Factory, run?: (options: AuthLoginOptions) => Promise<void>): Command;
14
25
  export declare function authLoginRun(options: AuthLoginOptions): Promise<void>;
@@ -1,62 +1,179 @@
1
1
  import { Command } from 'commander';
2
- import { writeStoredAuth } from '../../config/auth-store.js';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { saveCredential } from '../../config/auth-store.js';
4
+ import { clearPendingDeviceAuth, pendingExpired, pendingMatchesApiBaseUrl, readPendingDeviceAuth, savePendingDeviceAuth, } from '../../config/pending-auth.js';
3
5
  import { CliError } from '../../runtime/errors.js';
4
6
  import { validateAuthToken } from '../../api/auth.js';
5
- import { readHiddenInput } from '../shared/hidden-input.js';
7
+ import { pollDeviceAuth as defaultPollDeviceAuth, } from '../../api/device-auth.js';
8
+ import { startDeviceLoginSession } from './device-login.js';
6
9
  export function createAuthLoginCommand(factory, run = authLoginRun) {
7
10
  return new Command('login')
8
- .description('Authenticate with a BCTRL API key')
9
- .option('--with-token', 'Read API key from standard input')
11
+ .description('Authenticate with BCTRL')
12
+ .option('--with-token', 'Read an API key from standard input instead of using the browser device flow')
13
+ .option('--token-file <path>', 'Read an API key from a file instead of using the browser device flow')
14
+ .option('--url', 'Start a browser authorization, print the URL, and exit')
15
+ .option('--wait', 'Poll until the browser authorization is approved')
10
16
  .action(async (options) => {
11
17
  await run({
12
18
  io: factory.io,
13
19
  config: factory.config,
14
20
  withToken: options.withToken,
21
+ tokenFile: options.tokenFile,
22
+ url: options.url,
23
+ wait: options.wait,
15
24
  });
16
25
  });
17
26
  }
18
27
  export async function authLoginRun(options) {
19
28
  const config = await options.config();
20
- const { token, source } = await resolveLoginToken(options, config);
21
- if (!token) {
22
- throw new CliError('No API key provided');
29
+ if (options.url) {
30
+ const token = await startPendingBrowserLogin(options, config);
31
+ if (token) {
32
+ await persistResolvedLogin(options, config, {
33
+ token,
34
+ method: 'device',
35
+ source: 'device',
36
+ });
37
+ }
38
+ return;
23
39
  }
40
+ const { token, method, source } = await resolveLogin(options, config);
41
+ await persistResolvedLogin(options, config, { token, method, source });
42
+ }
43
+ async function startPendingBrowserLogin(options, config) {
44
+ if (options.withToken || options.tokenFile) {
45
+ throw new CliError('Use --url without --with-token or --token-file.');
46
+ }
47
+ const session = await startDeviceLoginSession({
48
+ apiBaseUrl: config.apiBaseUrl,
49
+ startDeviceAuth: options.deviceLoginDeps?.startDeviceAuth,
50
+ });
51
+ await savePendingDeviceAuth(config.apiBaseUrl, session, options.env, options.deviceLoginDeps?.now);
52
+ options.io.writeOut(`${session.verificationUriComplete}\n`);
53
+ if (!options.wait)
54
+ return null;
55
+ options.io.writeErr('Waiting for browser approval...\n');
56
+ return completePendingDeviceLogin(options, config, { wait: true });
57
+ }
58
+ async function persistResolvedLogin(options, config, resolution) {
24
59
  const validateToken = options.validateToken ?? validateAuthToken;
25
- const whoami = await validateToken(config.apiBaseUrl, token);
60
+ const whoami = await validateToken(config.apiBaseUrl, resolution.token);
26
61
  const now = new Date().toISOString();
27
- const path = await writeStoredAuth({
62
+ const saved = await saveCredential({
28
63
  apiBaseUrl: config.apiBaseUrl,
29
- token,
64
+ token: resolution.token,
30
65
  whoami,
66
+ method: resolution.method,
31
67
  createdAt: now,
32
68
  validatedAt: now,
33
69
  }, options.env);
34
- if (source === 'env') {
70
+ if (resolution.source === 'env') {
35
71
  options.io.writeErr('Using BCTRL_API_KEY from environment.\n');
36
72
  }
73
+ if (resolution.source === 'file') {
74
+ options.io.writeErr('Using API key from token file.\n');
75
+ }
76
+ if (saved.keychainFallback) {
77
+ options.io.writeErr('Warning: OS keychain unavailable; storing credentials in a 0600 file.\n');
78
+ }
37
79
  options.io.writeErr(`Logged in to ${config.apiBaseUrl}\n`);
38
- options.io.writeErr(`Credentials saved to ${path}\n`);
80
+ options.io.writeErr(`Credentials saved to ${saved.path} (store: ${saved.backend})\n`);
39
81
  options.io.writeErr(`Scope: ${whoami.scope}\n`);
40
82
  options.io.writeErr(`Organization: ${whoami.organizationId}\n`);
41
83
  if (whoami.subaccountId) {
42
84
  options.io.writeErr(`Subaccount: ${whoami.subaccountId}\n`);
43
85
  }
44
- options.io.writeErr(`Default space: ${whoami.defaultSpaceId}\n`);
86
+ options.io.writeErr(`Default space: ${whoami.defaultSpaceId ?? '-'}\n`);
45
87
  if (config.activeToken?.source === 'BCTRL_API_KEY') {
46
88
  options.io.writeErr('BCTRL_API_KEY is set and will take precedence over stored credentials.\n');
47
89
  }
48
90
  }
49
- async function resolveLoginToken(options, config) {
91
+ /**
92
+ * Resolution order for `auth login`: explicit stdin token (`--with-token`) →
93
+ * explicit token file (`--token-file`) → ambient `BCTRL_API_KEY` →
94
+ * pending device authorization completion. A bare `auth login` never creates a
95
+ * fresh browser URL; run `auth login --url` first, authorize it, then run
96
+ * `auth login` to store the minted CLI session.
97
+ */
98
+ async function resolveLogin(options, config) {
99
+ if (options.withToken && options.tokenFile) {
100
+ throw new CliError('Use either --with-token or --token-file, not both.');
101
+ }
50
102
  if (options.withToken) {
51
- return { token: (await readStreamText(options.io.in)).trim(), source: 'stdin' };
103
+ const token = (await readStreamText(options.io.in)).trim();
104
+ if (!token) {
105
+ throw new CliError('No API key provided on standard input.');
106
+ }
107
+ return { token, method: 'api-key', source: 'stdin' };
108
+ }
109
+ if (options.tokenFile) {
110
+ const token = (await readFile(options.tokenFile, 'utf8')).trim();
111
+ if (!token) {
112
+ throw new CliError(`No API key found in ${options.tokenFile}.`);
113
+ }
114
+ return { token, method: 'api-key', source: 'file' };
52
115
  }
53
116
  if (config.activeToken?.source === 'BCTRL_API_KEY') {
54
- return { token: config.activeToken.token, source: 'env' };
117
+ return {
118
+ token: config.activeToken.token,
119
+ method: 'api-key',
120
+ source: 'env',
121
+ };
122
+ }
123
+ const token = await completePendingDeviceLogin(options, config, {
124
+ wait: options.wait ?? false,
125
+ });
126
+ return { token, method: 'device', source: 'device' };
127
+ }
128
+ async function completePendingDeviceLogin(options, config, polling) {
129
+ const now = options.deviceLoginDeps?.now ?? Date.now;
130
+ const pending = await readPendingDeviceAuth(options.env);
131
+ if (!pending || !pendingMatchesApiBaseUrl(pending, config.apiBaseUrl)) {
132
+ throw new CliError('No pending browser login found.\n\n' +
133
+ 'Start one with:\n' +
134
+ ' bctrl auth login --url\n\n' +
135
+ 'Then approve the URL and run:\n' +
136
+ ' bctrl auth login\n\n' +
137
+ 'Or store an API key directly with:\n' +
138
+ ' bctrl auth login --with-token\n' +
139
+ ' bctrl auth login --token-file <path>');
140
+ }
141
+ const poll = options.deviceLoginDeps?.pollDeviceAuth ?? defaultPollDeviceAuth;
142
+ const sleep = options.deviceLoginDeps?.sleep ?? defaultSleep;
143
+ for (;;) {
144
+ if (pendingExpired(pending, now)) {
145
+ await clearPendingDeviceAuth(options.env);
146
+ throw new CliError('The pending browser login expired.\n\n' +
147
+ 'Start a new one with:\n' +
148
+ ' bctrl auth login --url');
149
+ }
150
+ const result = await poll(config.apiBaseUrl, pending.deviceCode);
151
+ switch (result.status) {
152
+ case 'complete':
153
+ await clearPendingDeviceAuth(options.env);
154
+ options.io.writeErr('Approved.\n');
155
+ return result.token;
156
+ case 'access_denied':
157
+ await clearPendingDeviceAuth(options.env);
158
+ throw new CliError('The pending browser login was denied.');
159
+ case 'expired_token':
160
+ await clearPendingDeviceAuth(options.env);
161
+ throw new CliError('The pending browser login expired.\n\n' +
162
+ 'Start a new one with:\n' +
163
+ ' bctrl auth login --url');
164
+ case 'rate_limited':
165
+ case 'authorization_pending':
166
+ if (!polling.wait) {
167
+ throw new CliError('Pending browser login is not approved yet.\n\n' +
168
+ 'Open and approve this URL:\n' +
169
+ ` ${pending.verificationUriComplete}\n\n` +
170
+ 'Then run:\n' +
171
+ ' bctrl auth login');
172
+ }
173
+ await sleep(pending.interval * 1000);
174
+ break;
175
+ }
55
176
  }
56
- return {
57
- token: await readHiddenInput(options.io, 'Paste BCTRL API key: '),
58
- source: 'prompt',
59
- };
60
177
  }
61
178
  async function readStreamText(stream) {
62
179
  const chunks = [];
@@ -65,3 +182,6 @@ async function readStreamText(stream) {
65
182
  }
66
183
  return Buffer.concat(chunks).toString('utf8');
67
184
  }
185
+ function defaultSleep(ms) {
186
+ return new Promise((resolve) => setTimeout(resolve, ms));
187
+ }
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { revokeDeviceSession } from '../../api/device-auth.js';
2
3
  import type { BctrlConfig } from '../../config/config.js';
3
4
  import type { Factory } from '../../factory.js';
4
5
  import type { IOStreams } from '../../io/streams.js';
@@ -6,6 +7,7 @@ export type AuthLogoutOptions = {
6
7
  io: IOStreams;
7
8
  config: () => Promise<BctrlConfig>;
8
9
  env?: NodeJS.ProcessEnv;
10
+ revokeDeviceSession?: typeof revokeDeviceSession;
9
11
  };
10
12
  export declare function createAuthLogoutCommand(factory: Factory, run?: (options: AuthLogoutOptions) => Promise<void>): Command;
11
13
  export declare function authLogoutRun(options: AuthLogoutOptions): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
- import { deleteStoredAuth } from '../../config/auth-store.js';
2
+ import { revokeDeviceSession } from '../../api/device-auth.js';
3
+ import { clearCredential } from '../../config/auth-store.js';
3
4
  export function createAuthLogoutCommand(factory, run = authLogoutRun) {
4
5
  return new Command('logout').description('Remove stored BCTRL credentials').action(async () => {
5
6
  await run({
@@ -10,7 +11,23 @@ export function createAuthLogoutCommand(factory, run = authLogoutRun) {
10
11
  }
11
12
  export async function authLogoutRun(options) {
12
13
  const config = await options.config();
13
- const removed = await deleteStoredAuth(options.env);
14
+ const revokeStoredDeviceSession = options.revokeDeviceSession ?? revokeDeviceSession;
15
+ if (config.storedAuth?.method === 'device') {
16
+ try {
17
+ const result = await revokeStoredDeviceSession(config.apiBaseUrl, config.storedAuth.token);
18
+ if (result.revoked) {
19
+ options.io.writeErr('Revoked connected device session.\n');
20
+ }
21
+ else {
22
+ options.io.writeErr('Connected device session was already revoked or not found.\n');
23
+ }
24
+ }
25
+ catch (error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ options.io.writeErr(`Warning: could not revoke connected device session: ${message}\n`);
28
+ }
29
+ }
30
+ const removed = await clearCredential(options.env, config.apiBaseUrl);
14
31
  if (removed) {
15
32
  options.io.writeErr('Removed stored BCTRL credentials.\n');
16
33
  }
@@ -9,6 +9,7 @@ export type AuthStatusOptions = {
9
9
  config: () => Promise<BctrlConfig>;
10
10
  output?: OutputFlags;
11
11
  validateToken?: typeof validateAuthToken;
12
+ env?: NodeJS.ProcessEnv;
12
13
  };
13
14
  export declare function createAuthStatusCommand(factory: Factory, run?: (options: AuthStatusOptions) => Promise<void>): Command;
14
15
  export declare function authStatusRun(options: AuthStatusOptions): Promise<void>;
@@ -1,5 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { validateAuthToken } from '../../api/auth.js';
3
+ import { isUnauthorizedApiError } from '../../api/errors.js';
4
+ import { clearCredential } from '../../config/auth-store.js';
3
5
  import { addOutputFlags, outputData } from '../shared/output.js';
4
6
  export function createAuthStatusCommand(factory, run = authStatusRun) {
5
7
  return addOutputFlags(new Command('status')
@@ -29,11 +31,23 @@ export async function authStatusRun(options) {
29
31
  return;
30
32
  }
31
33
  const validateToken = options.validateToken ?? validateAuthToken;
32
- const whoami = await validateToken(config.apiBaseUrl, config.activeToken.token);
34
+ let whoami;
35
+ try {
36
+ whoami = await validateToken(config.apiBaseUrl, config.activeToken.token);
37
+ }
38
+ catch (error) {
39
+ if (config.activeToken.source === 'stored' && isUnauthorizedApiError(error)) {
40
+ await clearCredential(options.env, config.apiBaseUrl);
41
+ }
42
+ throw error;
43
+ }
33
44
  const status = {
34
45
  authenticated: true,
35
46
  apiBaseUrl: config.apiBaseUrl,
36
47
  tokenSource: config.activeToken.source,
48
+ ...(config.storedAuth
49
+ ? { backend: config.storedAuth.backend, method: config.storedAuth.method }
50
+ : {}),
37
51
  ...whoami,
38
52
  };
39
53
  if (usesStructuredOutput(options.output)) {
@@ -64,17 +78,20 @@ function writeHumanAuthStatus(io, status) {
64
78
  `Scope: ${status.scope}`,
65
79
  `Organization: ${status.organizationId}`,
66
80
  ...(status.subaccountId ? [`Subaccount: ${status.subaccountId}`] : []),
67
- `Default space: ${status.defaultSpaceId}`,
81
+ `Default space: ${status.defaultSpaceId ?? '-'}`,
68
82
  `Plan: ${status.plan}`,
69
- `Auth: ${formatTokenSource(status.tokenSource)}`,
83
+ `Auth: ${formatTokenSource(status.tokenSource, status.method)}`,
84
+ ...(status.backend
85
+ ? [`Store: ${status.backend === 'keychain' ? 'OS keychain' : 'plaintext file (0600)'}`]
86
+ : []),
70
87
  `API: ${status.apiBaseUrl}`,
71
88
  '',
72
89
  ].join('\n'));
73
90
  }
74
- function formatTokenSource(source) {
91
+ function formatTokenSource(source, method) {
75
92
  if (source === 'BCTRL_API_KEY')
76
93
  return 'environment API key';
77
94
  if (source === 'stored')
78
- return 'saved API key';
95
+ return method === 'device' ? 'saved CLI session' : 'saved API key';
79
96
  return source;
80
97
  }
@@ -20,5 +20,11 @@ export async function authTokenRun(options) {
20
20
  if (options.io.isStdoutTTY() && options.reveal !== true) {
21
21
  throw new CliError('Refusing to print API key to a terminal without --reveal');
22
22
  }
23
+ // `auth token` is a human/debug command — never wire it into agent/MCP flows
24
+ // (it re-introduces the secret into a captured context). Warn loudly when it
25
+ // exfiltrates a secret out of the OS keychain.
26
+ if (options.reveal === true && config.storedAuth?.backend === 'keychain') {
27
+ options.io.writeErr('Warning: revealing a credential stored in the OS keychain.\n');
28
+ }
23
29
  options.io.writeOut(`${config.activeToken.token}\n`);
24
30
  }
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { readBlob, readJsonFile } from '../shared/io.js';
2
+ import { readBlob } from '../shared/io.js';
3
3
  import { addOutputFlags, outputData } from '../shared/output.js';
4
- import { createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, uploadOperationFile, } from '../shared/operation.js';
4
+ import { actingSubaccountOption, createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, uploadOperationFile, } from '../shared/operation.js';
5
5
  export function createBrowserExtensionCommand(factory) {
6
6
  const command = new Command('browser-extension').description('Manage browser extensions');
7
7
  command.addCommand(createOperationListCommand(factory, {
@@ -13,11 +13,11 @@ export function createBrowserExtensionCommand(factory) {
13
13
  .option('--format <format>', 'Filter by format')
14
14
  .option('--source <source>', 'Filter by source'),
15
15
  query: (options) => ({
16
- subaccountId: typeof options.subaccountId === 'string' ? options.subaccountId : undefined,
17
16
  q: typeof options.q === 'string' ? options.q : undefined,
18
17
  format: typeof options.format === 'string' ? options.format : undefined,
19
18
  source: typeof options.source === 'string' ? options.source : undefined,
20
19
  }),
20
+ actingSubaccountId: actingSubaccountOption(),
21
21
  }));
22
22
  command.addCommand(createOperationViewCommand(factory, {
23
23
  operationId: 'browser-extensions.get',
@@ -36,8 +36,8 @@ export function createBrowserExtensionCommand(factory) {
36
36
  fileName: file.fileName,
37
37
  fields: {
38
38
  ...(options.name ? { name: options.name } : {}),
39
- ...(options.subaccountId ? { subaccountId: options.subaccountId } : {}),
40
39
  },
40
+ actingSubaccountId: options.subaccountId,
41
41
  });
42
42
  await outputData(factory.io, result, options);
43
43
  }));
@@ -48,18 +48,14 @@ export function createBrowserExtensionCommand(factory) {
48
48
  configure: (cmd) => cmd
49
49
  .option('--url <url>', 'Extension URL')
50
50
  .option('--name <name>', 'Display name')
51
- .option('--subaccount-id <id>', 'Create under a subaccount when using a parent/org key')
52
- .option('--input <path>', 'Read full JSON request body from file, or - for stdin'),
51
+ .option('--subaccount-id <id>', 'Create under a subaccount when using a parent/org key'),
53
52
  body: async (_args, options) => {
54
- if (typeof options.input === 'string') {
55
- return (await readJsonFile(options.input));
56
- }
57
53
  return {
58
54
  url: options.url,
59
55
  name: options.name,
60
- subaccountId: options.subaccountId,
61
56
  };
62
57
  },
58
+ actingSubaccountId: (_args, options) => actingSubaccountOption()(options),
63
59
  }));
64
60
  command.addCommand(createOperationJsonBodyCommand(factory, {
65
61
  operationId: 'browser-extensions.update',
@@ -67,12 +63,8 @@ export function createBrowserExtensionCommand(factory) {
67
63
  description: 'Update a browser extension',
68
64
  argNames: ['extensionId'],
69
65
  configure: (cmd) => cmd
70
- .option('--name <name>', 'Display name')
71
- .option('--input <path>', 'Read full JSON request body from file, or - for stdin'),
66
+ .option('--name <name>', 'Display name'),
72
67
  body: async (_args, options) => {
73
- if (typeof options.input === 'string') {
74
- return (await readJsonFile(options.input));
75
- }
76
68
  return { name: options.name };
77
69
  },
78
70
  }));
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { readBlob, readJsonFile, writeBinary } from '../shared/io.js';
2
+ import { parseJsonString, readBlob, readJsonFile, writeBinary } from '../shared/io.js';
3
3
  import { parsePositiveInteger } from '../shared/options.js';
4
4
  import { addOutputFlags, outputData } from '../shared/output.js';
5
5
  import { createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, downloadOperation, uploadOperationFile, } from '../shared/operation.js';
@@ -40,8 +40,12 @@ export function createFileCommand(factory) {
40
40
  .argument('<path>')
41
41
  .option('--space <id>', 'Space id; omitted uses caller default space')
42
42
  .option('--path <storagePath>', 'Storage path')
43
- .option('--name <name>', 'Display name')).action(async (path, options) => {
43
+ .option('--name <name>', 'Display name')
44
+ .option('--metadata <json>', 'Metadata as inline JSON')).action(async (path, options) => {
44
45
  const file = await readBlob(path);
46
+ const metadata = options.metadata !== undefined
47
+ ? JSON.stringify(parseJsonString(options.metadata, '--metadata'))
48
+ : undefined;
45
49
  const result = await uploadOperationFile(factory, 'files.upload', {
46
50
  file: file.blob,
47
51
  fileName: options.name ?? file.fileName,
@@ -49,6 +53,7 @@ export function createFileCommand(factory) {
49
53
  fields: {
50
54
  ...(options.path ? { path: options.path } : {}),
51
55
  ...(options.name ? { name: options.name } : {}),
56
+ ...(metadata ? { metadata } : {}),
52
57
  },
53
58
  });
54
59
  await outputData(factory.io, result, options);
@@ -70,12 +75,8 @@ export function createFileCommand(factory) {
70
75
  argNames: ['fileId'],
71
76
  configure: (cmd) => cmd
72
77
  .option('--name <name>', 'Display name')
73
- .option('--metadata-file <path>', 'Metadata JSON file')
74
- .option('--input <path>', 'Read full JSON request body from file, or - for stdin'),
78
+ .option('--metadata-file <path>', 'Metadata JSON file'),
75
79
  body: async (_args, options) => {
76
- if (typeof options.input === 'string') {
77
- return (await readJsonFile(options.input));
78
- }
79
80
  return {
80
81
  name: options.name,
81
82
  metadata: typeof options.metadataFile === 'string'
@@ -1,17 +1,18 @@
1
1
  import { Command } from 'commander';
2
2
  import { addOutputFlags } from '../shared/output.js';
3
- import { outputFlags, requestOperationAndPrint } from '../shared/operation.js';
3
+ import { buildOperationInput, outputFlags, requestOperationAndPrint } from '../shared/operation.js';
4
4
  export function createHelpCommand(factory) {
5
5
  return addOutputFlags(new Command('help')
6
6
  .description('Get BCTRL API help')
7
7
  .option('--topic <topic>', 'Help topic')
8
- .option('--audience <audience>', 'Help audience')).action(async (options) => {
9
- await requestOperationAndPrint(factory, 'help', {
8
+ .option('--audience <audience>', 'Help audience')
9
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (options) => {
10
+ await requestOperationAndPrint(factory, 'help', await buildOperationInput('help', options, {
10
11
  query: {
11
12
  topic: options.topic,
12
13
  audience: options.audience,
13
14
  },
14
15
  output: outputFlags(options),
15
- });
16
+ }));
16
17
  });
17
18
  }
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import type { Factory } from '../../factory.js';
3
+ export declare function createNotificationRecipientCommand(factory: Factory): Command;
@@ -0,0 +1,72 @@
1
+ import { Command, Option } from 'commander';
2
+ import { createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, } from '../shared/operation.js';
3
+ import { parsePositiveInteger } from '../shared/options.js';
4
+ function parseEnabled(value) {
5
+ if (value === true || value === 'true')
6
+ return true;
7
+ if (value === false || value === 'false')
8
+ return false;
9
+ return undefined;
10
+ }
11
+ export function createNotificationRecipientCommand(factory) {
12
+ const command = new Command('notification-recipient').description('Manage notification recipients');
13
+ command.addCommand(createOperationListCommand(factory, {
14
+ operationId: 'notification-recipients.list',
15
+ description: 'List notification recipients',
16
+ configure: (cmd) => cmd
17
+ .addOption(new Option('--type <type>', 'Filter by recipient type').choices([
18
+ 'email',
19
+ 'sms',
20
+ 'whatsapp',
21
+ ]))
22
+ .addOption(new Option('--enabled <enabled>', 'Filter by enabled state').choices(['true', 'false']))
23
+ .option('-L, --limit <number>', 'Maximum number of results to return', parsePositiveInteger)
24
+ .option('--cursor <cursor>', 'Pagination cursor'),
25
+ query: (options) => ({
26
+ type: typeof options.type === 'string' ? options.type : undefined,
27
+ enabled: parseEnabled(options.enabled),
28
+ limit: typeof options.limit === 'number' ? options.limit : undefined,
29
+ cursor: typeof options.cursor === 'string' ? options.cursor : undefined,
30
+ }),
31
+ }));
32
+ command.addCommand(createOperationJsonBodyCommand(factory, {
33
+ operationId: 'notification-recipients.create',
34
+ name: 'create',
35
+ description: 'Create a notification recipient',
36
+ configure: (cmd) => cmd
37
+ .addOption(new Option('--type <type>', 'Recipient type').choices(['email', 'sms', 'whatsapp']))
38
+ .option('--value <value>', 'Email address, SMS number, or WhatsApp number')
39
+ .option('--name <name>', 'Display name'),
40
+ body: async (_args, options) => {
41
+ return {
42
+ type: options.type,
43
+ value: options.value,
44
+ name: options.name,
45
+ };
46
+ },
47
+ }));
48
+ command.addCommand(createOperationJsonBodyCommand(factory, {
49
+ operationId: 'notification-recipients.update',
50
+ name: 'patch',
51
+ description: 'Update a notification recipient',
52
+ argNames: ['recipientId'],
53
+ configure: (cmd) => cmd
54
+ .option('--value <value>', 'Email address, SMS number, or WhatsApp number')
55
+ .option('--name <name>', 'Display name')
56
+ .option('--clear-name', 'Clear the display name')
57
+ .addOption(new Option('--enabled <enabled>', 'Set enabled state').choices(['true', 'false'])),
58
+ body: async (_args, options) => {
59
+ return {
60
+ value: options.value,
61
+ name: options.clearName === true ? null : options.name,
62
+ enabled: parseEnabled(options.enabled),
63
+ };
64
+ },
65
+ }));
66
+ command.addCommand(createOperationDeleteCommand(factory, {
67
+ operationId: 'notification-recipients.delete',
68
+ description: 'Delete a notification recipient',
69
+ argNames: ['recipientId'],
70
+ }));
71
+ return command;
72
+ }
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { addOutputFlags } from '../shared/output.js';
3
- import { addPaginationFlags, createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, outputFlags, requestOperationAndPrint, } from '../shared/operation.js';
3
+ import { addPaginationFlags, buildOperationInput, createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, outputFlags, requestOperationAndPrint, } from '../shared/operation.js';
4
4
  export function createProxyCommand(factory) {
5
5
  const command = new Command('proxy').description('Manage proxies');
6
6
  command.addCommand(createOperationListCommand(factory, {
@@ -24,11 +24,13 @@ export function createProxyCommand(factory) {
24
24
  description: 'Update a proxy',
25
25
  argNames: ['proxyId'],
26
26
  }));
27
- command.addCommand(addOutputFlags(new Command('test').description('Test a proxy').argument('<proxyId>')).action(async (proxyId, options) => {
28
- await requestOperationAndPrint(factory, 'proxies.test', {
27
+ command.addCommand(addOutputFlags(new Command('test').description('Test a proxy').argument('<proxyId>'))
28
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')
29
+ .action(async (proxyId, options) => {
30
+ await requestOperationAndPrint(factory, 'proxies.test', await buildOperationInput('proxies.test', options, {
29
31
  pathParams: { proxyId },
30
32
  output: outputFlags(options),
31
- });
33
+ }));
32
34
  }));
33
35
  command.addCommand(createOperationDeleteCommand(factory, {
34
36
  operationId: 'proxies.delete',