@bctrl/cli 0.1.6 → 0.1.8

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 (61) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +113 -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/commands/ai/index.js +10 -11
  12. package/dist/commands/api-key/index.js +3 -8
  13. package/dist/commands/auth/device-login.d.ts +6 -0
  14. package/dist/commands/auth/device-login.js +19 -0
  15. package/dist/commands/auth/login.d.ts +11 -0
  16. package/dist/commands/auth/login.js +140 -20
  17. package/dist/commands/auth/logout.d.ts +2 -0
  18. package/dist/commands/auth/logout.js +19 -2
  19. package/dist/commands/auth/status.d.ts +1 -0
  20. package/dist/commands/auth/status.js +22 -5
  21. package/dist/commands/auth/token.js +6 -0
  22. package/dist/commands/browser-extension/index.js +7 -15
  23. package/dist/commands/file/index.js +8 -7
  24. package/dist/commands/help/index.js +5 -4
  25. package/dist/commands/notification-recipient/index.d.ts +3 -0
  26. package/dist/commands/notification-recipient/index.js +72 -0
  27. package/dist/commands/proxy/index.js +6 -4
  28. package/dist/commands/run/index.js +31 -32
  29. package/dist/commands/runtime/index.js +185 -106
  30. package/dist/commands/shared/help.d.ts +26 -3
  31. package/dist/commands/shared/help.js +87 -23
  32. package/dist/commands/shared/io.d.ts +1 -0
  33. package/dist/commands/shared/io.js +4 -2
  34. package/dist/commands/shared/operation.d.ts +50 -3
  35. package/dist/commands/shared/operation.js +171 -25
  36. package/dist/commands/shared/output.js +3 -7
  37. package/dist/commands/space/index.js +35 -32
  38. package/dist/commands/space/list.js +6 -5
  39. package/dist/commands/subaccount/index.js +12 -14
  40. package/dist/commands/usage/index.js +9 -5
  41. package/dist/commands/vault/index.js +15 -21
  42. package/dist/config/auth-store.d.ts +75 -9
  43. package/dist/config/auth-store.js +133 -27
  44. package/dist/config/config.d.ts +2 -2
  45. package/dist/config/config.js +9 -17
  46. package/dist/config/paths.d.ts +16 -0
  47. package/dist/config/paths.js +34 -0
  48. package/dist/config/pending-auth.d.ts +19 -0
  49. package/dist/config/pending-auth.js +72 -0
  50. package/dist/config/secret-store.d.ts +24 -0
  51. package/dist/config/secret-store.js +123 -0
  52. package/dist/factory.js +2 -1
  53. package/dist/generated/help.d.ts +7424 -2768
  54. package/dist/generated/help.js +10116 -4014
  55. package/dist/generated/openapi-routes.d.ts +52 -8
  56. package/dist/generated/openapi-routes.js +20 -9
  57. package/dist/generated/openapi-types.d.ts +2688 -883
  58. package/dist/root.js +2 -0
  59. package/dist/version.d.ts +1 -0
  60. package/dist/version.js +4 -0
  61. package/package.json +58 -47
@@ -1,80 +1,83 @@
1
1
  import { Command } from 'commander';
2
- import { readJsonFile } from '../shared/io.js';
3
- import { outputFlags, requestOperationAndPrint } from '../shared/operation.js';
2
+ import { buildOperationInput, outputFlags, requestOperationAndPrint } from '../shared/operation.js';
4
3
  import { addOutputFlags } from '../shared/output.js';
5
4
  import { CliError } from '../../runtime/errors.js';
6
5
  import { createSpaceListCommand } from './list.js';
7
6
  export function createSpaceCommand(factory) {
8
7
  const command = new Command('space').description('Manage BCTRL spaces');
9
8
  command.addCommand(createSpaceListCommand(factory));
10
- command.addCommand(addOutputFlags(new Command('get').description('View a space').argument('<spaceId>')).action(async (spaceId, options) => {
11
- await requestOperationAndPrint(factory, 'spaces.get', {
9
+ command.addCommand(addOutputFlags(new Command('get')
10
+ .description('View a space')
11
+ .argument('<spaceId>')
12
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (spaceId, options) => {
13
+ await requestOperationAndPrint(factory, 'spaces.get', await buildOperationInput('spaces.get', options, {
12
14
  pathParams: { spaceId },
13
15
  output: outputFlags(options),
14
- });
16
+ }));
15
17
  }));
16
18
  command.addCommand(addOutputFlags(new Command('create')
17
19
  .description('Create a space')
18
20
  .option('--name <name>', 'Space name')
19
21
  .option('--subaccount-id <id>', 'Subaccount id')
20
- .option('--input <path>', 'Read JSON request body from file, or - for stdin')).action(async (options) => {
21
- await requestOperationAndPrint(factory, 'spaces.create', {
22
- body: typeof options.input === 'string'
23
- ? (await readJsonFile(options.input))
24
- : {
25
- name: options.name,
26
- subaccountId: options.subaccountId,
27
- },
22
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')
23
+ .option('--body <json>', 'Request body as JSON (inline, @file, or - for stdin)')).action(async (options) => {
24
+ await requestOperationAndPrint(factory, 'spaces.create', await buildOperationInput('spaces.create', options, {
25
+ body: {
26
+ name: options.name,
27
+ },
28
+ actingSubaccountId: options.subaccountId,
28
29
  output: outputFlags(options),
29
- });
30
+ }));
30
31
  }));
31
32
  command.addCommand(addOutputFlags(new Command('patch')
32
33
  .description('Edit a space')
33
34
  .argument('<spaceId>')
34
35
  .option('--name <name>', 'Space name')
35
- .option('--input <path>', 'Read JSON request body from file, or - for stdin')).action(async (spaceId, options) => {
36
- await requestOperationAndPrint(factory, 'spaces.update', {
36
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')
37
+ .option('--body <json>', 'Request body as JSON (inline, @file, or - for stdin)')).action(async (spaceId, options) => {
38
+ await requestOperationAndPrint(factory, 'spaces.update', await buildOperationInput('spaces.update', options, {
37
39
  pathParams: { spaceId },
38
- body: typeof options.input === 'string'
39
- ? (await readJsonFile(options.input))
40
- : { name: options.name },
40
+ body: { name: options.name },
41
41
  output: outputFlags(options),
42
- });
42
+ }));
43
43
  }));
44
44
  command.addCommand(addOutputFlags(new Command('delete')
45
45
  .description('Delete a space')
46
46
  .argument('<spaceId>')
47
- .option('-y, --yes', 'Confirm deletion')).action(async (spaceId, options) => {
47
+ .option('-y, --yes', 'Confirm deletion')
48
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (spaceId, options) => {
48
49
  if (options.yes !== true) {
49
50
  throw new CliError('Refusing to delete without --yes');
50
51
  }
51
- await requestOperationAndPrint(factory, 'spaces.delete', {
52
+ await requestOperationAndPrint(factory, 'spaces.delete', await buildOperationInput('spaces.delete', options, {
52
53
  pathParams: { spaceId },
53
54
  output: outputFlags(options),
54
- });
55
+ }));
55
56
  }));
56
57
  command.addCommand(createSpaceEnvironmentCommand(factory));
57
58
  return command;
58
59
  }
59
60
  function createSpaceEnvironmentCommand(factory) {
60
61
  const command = new Command('env').description('Manage a space environment');
61
- command.addCommand(addOutputFlags(new Command('get').description('Get a space environment').argument('<spaceId>')).action(async (spaceId, options) => {
62
- await requestOperationAndPrint(factory, 'spaces.environment.get', {
62
+ command.addCommand(addOutputFlags(new Command('get')
63
+ .description('Get a space environment')
64
+ .argument('<spaceId>')
65
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (spaceId, options) => {
66
+ await requestOperationAndPrint(factory, 'spaces.environment.get', await buildOperationInput('spaces.environment.get', options, {
63
67
  pathParams: { spaceId },
64
68
  output: outputFlags(options),
65
- });
69
+ }));
66
70
  }));
67
71
  command.addCommand(addOutputFlags(new Command('patch')
68
72
  .description('Update a space environment')
69
73
  .argument('<spaceId>')
70
- .option('--input <path>', 'Read JSON request body from file, or - for stdin')).action(async (spaceId, options) => {
71
- await requestOperationAndPrint(factory, 'spaces.environment.update', {
74
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')
75
+ .option('--body <json>', 'Request body as JSON (inline, @file, or - for stdin)')).action(async (spaceId, options) => {
76
+ await requestOperationAndPrint(factory, 'spaces.environment.update', await buildOperationInput('spaces.environment.update', options, {
72
77
  pathParams: { spaceId },
73
- body: typeof options.input === 'string'
74
- ? (await readJsonFile(options.input))
75
- : {},
78
+ body: {},
76
79
  output: outputFlags(options),
77
- });
80
+ }));
78
81
  }));
79
82
  return command;
80
83
  }
@@ -1,17 +1,18 @@
1
1
  import { Command } from 'commander';
2
- import { addPaginationFlags, outputFlags, requestOperationAndPrint } from '../shared/operation.js';
2
+ import { addPaginationFlags, buildOperationInput, outputFlags, requestOperationAndPrint, } from '../shared/operation.js';
3
3
  import { addOutputFlags } from '../shared/output.js';
4
4
  export function createSpaceListCommand(factory) {
5
5
  return addOutputFlags(addPaginationFlags(new Command('list')
6
6
  .description('List spaces')
7
- .option('--subaccount <id>', 'Filter by subaccount id'))).action(async (options) => {
8
- await requestOperationAndPrint(factory, 'spaces.list', {
7
+ .option('--subaccount <id>', 'Filter by subaccount id')
8
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)'))).action(async (options) => {
9
+ await requestOperationAndPrint(factory, 'spaces.list', await buildOperationInput('spaces.list', options, {
9
10
  query: {
10
- subaccountId: options.subaccount,
11
11
  limit: options.limit,
12
12
  cursor: options.cursor,
13
13
  },
14
+ actingSubaccountId: options.subaccount,
14
15
  output: outputFlags(options),
15
- });
16
+ }));
16
17
  });
17
18
  }
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { readJsonFile } from '../shared/io.js';
3
3
  import { parsePositiveInteger } from '../shared/options.js';
4
4
  import { addOutputFlags } from '../shared/output.js';
5
- import { addPaginationFlags, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, outputFlags, requestOperationAndPrint, } from '../shared/operation.js';
5
+ import { addPaginationFlags, buildOperationInput, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, outputFlags, requestOperationAndPrint, } from '../shared/operation.js';
6
6
  export function createSubaccountCommand(factory) {
7
7
  const command = new Command('subaccount').description('Manage subaccounts');
8
8
  command.addCommand(createOperationListCommand(factory, {
@@ -37,11 +37,12 @@ export function createSubaccountCommand(factory) {
37
37
  command.addCommand(addOutputFlags(new Command('archive')
38
38
  .description('Archive a subaccount')
39
39
  .argument('<subaccountId>')
40
- .requiredOption('-y, --yes', 'Confirm archive')).action(async (subaccountId, options) => {
41
- await requestOperationAndPrint(factory, 'subaccounts.archive', {
40
+ .requiredOption('-y, --yes', 'Confirm archive')
41
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (subaccountId, options) => {
42
+ await requestOperationAndPrint(factory, 'subaccounts.archive', await buildOperationInput('subaccounts.archive', options, {
42
43
  pathParams: { subaccountId },
43
44
  output: outputFlags(options),
44
- });
45
+ }));
45
46
  }));
46
47
  command.addCommand(createSubaccountUsageCommand(factory));
47
48
  return command;
@@ -55,12 +56,8 @@ function createSubaccountWriteCommand(factory, name, operationId, argNames = [])
55
56
  configure: (cmd) => cmd
56
57
  .option('--name <name>', 'Subaccount name')
57
58
  .option('--external-id <id>', 'External id')
58
- .option('--metadata-file <path>', 'Metadata JSON file')
59
- .option('--input <path>', 'Read full JSON request body from file, or - for stdin'),
59
+ .option('--metadata-file <path>', 'Metadata JSON file'),
60
60
  body: async (_args, options) => {
61
- if (typeof options.input === 'string') {
62
- return (await readJsonFile(options.input));
63
- }
64
61
  return {
65
62
  name: options.name,
66
63
  externalId: options.externalId,
@@ -76,18 +73,19 @@ function createSubaccountUsageCommand(factory) {
76
73
  .description('Show subaccount usage')
77
74
  .argument('[id]')
78
75
  .option('-L, --limit <number>', 'Maximum number of usage records to return', parsePositiveInteger)
79
- .option('--cursor <cursor>', 'Pagination cursor')).action(async (id, options) => {
76
+ .option('--cursor <cursor>', 'Pagination cursor')
77
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (id, options) => {
80
78
  if (id) {
81
- await requestOperationAndPrint(factory, 'subaccounts.get', {
79
+ await requestOperationAndPrint(factory, 'subaccounts.get', await buildOperationInput('subaccounts.get', options, {
82
80
  pathParams: { subaccountId: id },
83
81
  query: { include: ['usage'] },
84
82
  output: outputFlags(options),
85
- });
83
+ }));
86
84
  return;
87
85
  }
88
- await requestOperationAndPrint(factory, 'subaccounts.usage.list', {
86
+ await requestOperationAndPrint(factory, 'subaccounts.usage.list', await buildOperationInput('subaccounts.usage.list', options, {
89
87
  query: { limit: options.limit, cursor: options.cursor },
90
88
  output: outputFlags(options),
91
- });
89
+ }));
92
90
  });
93
91
  }
@@ -1,13 +1,17 @@
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 createUsageCommand(factory) {
5
5
  const command = addOutputFlags(new Command('usage').description('Get organization usage'));
6
- command.action(async (options) => {
7
- await requestOperationAndPrint(factory, 'usage.get', { output: outputFlags(options) });
6
+ command
7
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')
8
+ .action(async (options) => {
9
+ await requestOperationAndPrint(factory, 'usage.get', await buildOperationInput('usage.get', options, { output: outputFlags(options) }));
8
10
  });
9
- command.addCommand(addOutputFlags(new Command('get').description('Get organization usage')).action(async (options) => {
10
- await requestOperationAndPrint(factory, 'usage.get', { output: outputFlags(options) });
11
+ command.addCommand(addOutputFlags(new Command('get').description('Get organization usage'))
12
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')
13
+ .action(async (options) => {
14
+ await requestOperationAndPrint(factory, 'usage.get', await buildOperationInput('usage.get', options, { output: outputFlags(options) }));
11
15
  }));
12
16
  return command;
13
17
  }
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { CliError } from '../../runtime/errors.js';
3
- import { readJsonFile, readText } from '../shared/io.js';
3
+ import { readText } from '../shared/io.js';
4
4
  import { parsePositiveInteger } from '../shared/options.js';
5
5
  import { addOutputFlags } from '../shared/output.js';
6
- import { createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, outputFlags, requestOperationAndPrint, } from '../shared/operation.js';
6
+ import { buildOperationInput, createOperationDeleteCommand, createOperationJsonBodyCommand, createOperationListCommand, createOperationViewCommand, outputFlags, requestOperationAndPrint, } from '../shared/operation.js';
7
7
  export function createVaultCommand(factory) {
8
8
  const command = new Command('vault').description('Manage vault secrets and TOTP codes');
9
9
  command.addCommand(createOperationListCommand(factory, {
@@ -42,14 +42,15 @@ function createVaultReadCommand(factory) {
42
42
  return addOutputFlags(new Command('read')
43
43
  .description('Read a vault secret value')
44
44
  .argument('<key>')
45
- .option('--reveal', 'Allow printing a secret to an interactive terminal')).action(async (key, options) => {
45
+ .option('--reveal', 'Allow printing a secret to an interactive terminal')
46
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (key, options) => {
46
47
  if (factory.io.isStdoutTTY() && options.reveal !== true) {
47
48
  throw new CliError('Refusing to print a secret to a terminal without --reveal');
48
49
  }
49
- await requestOperationAndPrint(factory, 'vault.secrets.value', {
50
+ await requestOperationAndPrint(factory, 'vault.secrets.value', await buildOperationInput('vault.secrets.value', options, {
50
51
  pathParams: { key },
51
52
  output: outputFlags(options),
52
- });
53
+ }));
53
54
  });
54
55
  }
55
56
  function createVaultSetCommand(factory) {
@@ -68,12 +69,8 @@ function createVaultSetCommand(factory) {
68
69
  .option('--totp-secret <secret>', 'TOTP seed')
69
70
  .option('--label <label>', 'Display label')
70
71
  .option('--origin <origin...>', 'Allowed origin')
71
- .option('--origin-pattern <pattern...>', 'Allowed origin pattern')
72
- .option('--input <path>', 'Read full JSON request body from file, or - for stdin'),
72
+ .option('--origin-pattern <pattern...>', 'Allowed origin pattern'),
73
73
  body: async (args, options) => {
74
- if (typeof options.input === 'string') {
75
- return (await readJsonFile(options.input));
76
- }
77
74
  const password = typeof options.passwordFromFile === 'string'
78
75
  ? (await readText(options.passwordFromFile)).trimEnd()
79
76
  : options.password;
@@ -97,7 +94,7 @@ function createVaultSetCommand(factory) {
97
94
  }
98
95
  if (type === 'value') {
99
96
  if (typeof value !== 'string') {
100
- throw new CliError('Vault value secrets require --value, --value-from-file, or --input');
97
+ throw new CliError('Vault value secrets require --value, --value-from-file, or --body');
101
98
  }
102
99
  return {
103
100
  ...common,
@@ -106,7 +103,7 @@ function createVaultSetCommand(factory) {
106
103
  };
107
104
  }
108
105
  if (typeof options.username !== 'string' || typeof password !== 'string') {
109
- throw new CliError('Vault login secrets require --username and --password, --password-from-file, or --input');
106
+ throw new CliError('Vault login secrets require --username and --password, --password-from-file, or --body');
110
107
  }
111
108
  return {
112
109
  ...common,
@@ -137,12 +134,8 @@ function createVaultPatchCommand(factory) {
137
134
  .option('--origin <origin...>', 'Allowed origin')
138
135
  .option('--clear-origins', 'Remove origins')
139
136
  .option('--origin-pattern <pattern...>', 'Allowed origin pattern')
140
- .option('--clear-origin-patterns', 'Remove origin patterns')
141
- .option('--input <path>', 'Read full JSON request body from file, or - for stdin'),
137
+ .option('--clear-origin-patterns', 'Remove origin patterns'),
142
138
  body: async (_args, options) => {
143
- if (typeof options.input === 'string') {
144
- return (await readJsonFile(options.input));
145
- }
146
139
  const password = typeof options.passwordFromFile === 'string'
147
140
  ? (await readText(options.passwordFromFile)).trimEnd()
148
141
  : options.password;
@@ -159,7 +152,7 @@ function createVaultPatchCommand(factory) {
159
152
  originPatterns: options.clearOriginPatterns === true ? null : options.originPattern,
160
153
  };
161
154
  if (!Object.values(body).some((value) => value !== undefined)) {
162
- throw new CliError('Vault patch requires at least one field or --input');
155
+ throw new CliError('Vault patch requires at least one field or --body');
163
156
  }
164
157
  return body;
165
158
  },
@@ -168,10 +161,11 @@ function createVaultPatchCommand(factory) {
168
161
  function createVaultTotpCommand(factory) {
169
162
  return addOutputFlags(new Command('totp')
170
163
  .description('Generate the current TOTP code for a vault secret')
171
- .argument('<key>')).action(async (key, options) => {
172
- await requestOperationAndPrint(factory, 'vault.secrets.totp', {
164
+ .argument('<key>')
165
+ .option('--params <json>', 'Path/query parameters as a JSON object (inline, @file, or - for stdin)')).action(async (key, options) => {
166
+ await requestOperationAndPrint(factory, 'vault.secrets.totp', await buildOperationInput('vault.secrets.totp', options, {
173
167
  pathParams: { key },
174
168
  output: outputFlags(options),
175
- });
169
+ }));
176
170
  });
177
171
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { type SecretBackend } from './secret-store.js';
2
3
  export declare const StoredWhoamiSchema: z.ZodObject<{
3
4
  email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
4
5
  scope: z.ZodEnum<{
@@ -7,14 +8,33 @@ export declare const StoredWhoamiSchema: z.ZodObject<{
7
8
  }>;
8
9
  organizationId: z.ZodString;
9
10
  subaccountId: z.ZodNullable<z.ZodString>;
10
- defaultSpaceId: z.ZodString;
11
+ defaultSpaceId: z.ZodNullable<z.ZodString>;
12
+ effectiveScope: z.ZodObject<{
13
+ scope: z.ZodEnum<{
14
+ organization: "organization";
15
+ subaccount: "subaccount";
16
+ }>;
17
+ organizationId: z.ZodString;
18
+ subaccountId: z.ZodNullable<z.ZodString>;
19
+ defaultSpaceId: z.ZodNullable<z.ZodString>;
20
+ }, z.core.$strict>;
11
21
  keyId: z.ZodString;
12
22
  plan: z.ZodString;
13
23
  }, z.core.$strict>;
14
24
  export type StoredWhoami = z.infer<typeof StoredWhoamiSchema>;
15
- export declare const StoredAuthSchema: z.ZodObject<{
25
+ /** How the stored credential was obtained. `device` is reserved for the device-login flow. */
26
+ export declare const AuthMethodSchema: z.ZodEnum<{
27
+ "api-key": "api-key";
28
+ device: "device";
29
+ }>;
30
+ export type AuthMethod = z.infer<typeof AuthMethodSchema>;
31
+ /**
32
+ * On-disk credential metadata. This file NEVER contains the secret — the token
33
+ * lives in the OS keychain (or a separate 0600 secret file when keychain is
34
+ * unavailable). See secret-store.ts.
35
+ */
36
+ export declare const StoredAuthMetadataSchema: z.ZodObject<{
16
37
  apiBaseUrl: z.ZodString;
17
- token: z.ZodString;
18
38
  whoami: z.ZodObject<{
19
39
  email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
20
40
  scope: z.ZodEnum<{
@@ -23,15 +43,61 @@ export declare const StoredAuthSchema: z.ZodObject<{
23
43
  }>;
24
44
  organizationId: z.ZodString;
25
45
  subaccountId: z.ZodNullable<z.ZodString>;
26
- defaultSpaceId: z.ZodString;
46
+ defaultSpaceId: z.ZodNullable<z.ZodString>;
47
+ effectiveScope: z.ZodObject<{
48
+ scope: z.ZodEnum<{
49
+ organization: "organization";
50
+ subaccount: "subaccount";
51
+ }>;
52
+ organizationId: z.ZodString;
53
+ subaccountId: z.ZodNullable<z.ZodString>;
54
+ defaultSpaceId: z.ZodNullable<z.ZodString>;
55
+ }, z.core.$strict>;
27
56
  keyId: z.ZodString;
28
57
  plan: z.ZodString;
29
58
  }, z.core.$strict>;
59
+ method: z.ZodEnum<{
60
+ "api-key": "api-key";
61
+ device: "device";
62
+ }>;
63
+ backend: z.ZodEnum<{
64
+ keychain: "keychain";
65
+ file: "file";
66
+ }>;
30
67
  createdAt: z.ZodString;
31
68
  validatedAt: z.ZodString;
32
69
  }, z.core.$strict>;
33
- export type StoredAuth = z.infer<typeof StoredAuthSchema>;
34
- export declare function getAuthFilePath(env?: NodeJS.ProcessEnv): string;
35
- export declare function readStoredAuth(env?: NodeJS.ProcessEnv): Promise<StoredAuth | null>;
36
- export declare function writeStoredAuth(auth: StoredAuth, env?: NodeJS.ProcessEnv): Promise<string>;
37
- export declare function deleteStoredAuth(env?: NodeJS.ProcessEnv): Promise<boolean>;
70
+ export type StoredAuthMetadata = z.infer<typeof StoredAuthMetadataSchema>;
71
+ /** Metadata + the resolved secret, assembled in memory only. */
72
+ export type StoredCredential = StoredAuthMetadata & {
73
+ token: string;
74
+ };
75
+ export declare function normalizeApiBaseUrl(url: string): string;
76
+ export declare function readStoredMetadata(env?: NodeJS.ProcessEnv): Promise<StoredAuthMetadata | null>;
77
+ /**
78
+ * Resolve the full stored credential (metadata + secret) for the given active
79
+ * API base URL. Returns null when there is no metadata, when it is for a
80
+ * different base URL, or when the secret is missing (e.g. the keychain entry was
81
+ * deleted out from under a stale metadata file) — all of which mean "logged out".
82
+ */
83
+ export declare function loadCredential(env?: NodeJS.ProcessEnv, activeApiBaseUrl?: string): Promise<StoredCredential | null>;
84
+ export type SaveCredentialResult = {
85
+ path: string;
86
+ backend: SecretBackend;
87
+ keychainFallback: boolean;
88
+ };
89
+ export declare function saveCredential(input: {
90
+ apiBaseUrl: string;
91
+ token: string;
92
+ whoami: StoredWhoami;
93
+ method: AuthMethod;
94
+ createdAt: string;
95
+ validatedAt: string;
96
+ }, env?: NodeJS.ProcessEnv): Promise<SaveCredentialResult>;
97
+ /**
98
+ * Remove a stored credential. Deletes the secret from BOTH backends keyed by the
99
+ * (normalized) API base URL — independent of whether the metadata file exists,
100
+ * so a corrupt/missing metadata file can never orphan the secret — then removes
101
+ * the metadata file. Returns whether anything was removed.
102
+ */
103
+ export declare function clearCredential(env: NodeJS.ProcessEnv | undefined, apiBaseUrl: string): Promise<boolean>;
@@ -1,61 +1,164 @@
1
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { z } from 'zod';
5
- const DEFAULT_AUTH_FILE_NAME = 'auth.json';
4
+ import { getMetadataFilePath } from './paths.js';
5
+ import { deleteSecretAllBackends, resolveSecretStore, } from './secret-store.js';
6
+ const StoredEffectiveScopeSchema = z
7
+ .object({
8
+ scope: z.enum(['organization', 'subaccount']),
9
+ organizationId: z.string().min(1),
10
+ subaccountId: z.string().min(1).nullable(),
11
+ defaultSpaceId: z.string().min(1).nullable(),
12
+ })
13
+ .strict();
6
14
  export const StoredWhoamiSchema = z
7
15
  .object({
8
16
  email: z.string().email().nullable().optional(),
9
17
  scope: z.enum(['organization', 'subaccount']),
10
18
  organizationId: z.string().min(1),
11
19
  subaccountId: z.string().min(1).nullable(),
12
- defaultSpaceId: z.string().min(1),
20
+ defaultSpaceId: z.string().min(1).nullable(),
21
+ effectiveScope: StoredEffectiveScopeSchema,
13
22
  keyId: z.string().min(1),
14
23
  plan: z.string().min(1),
15
24
  })
16
25
  .strict();
17
- export const StoredAuthSchema = z
26
+ /** How the stored credential was obtained. `device` is reserved for the device-login flow. */
27
+ export const AuthMethodSchema = z.enum(['api-key', 'device']);
28
+ /**
29
+ * On-disk credential metadata. This file NEVER contains the secret — the token
30
+ * lives in the OS keychain (or a separate 0600 secret file when keychain is
31
+ * unavailable). See secret-store.ts.
32
+ */
33
+ export const StoredAuthMetadataSchema = z
18
34
  .object({
19
35
  apiBaseUrl: z.string().url(),
20
- token: z.string().min(1),
21
36
  whoami: StoredWhoamiSchema,
37
+ method: AuthMethodSchema,
38
+ backend: z.enum(['keychain', 'file']),
22
39
  createdAt: z.string().datetime(),
23
40
  validatedAt: z.string().datetime(),
24
41
  })
25
42
  .strict();
26
- export function getAuthFilePath(env = process.env) {
27
- if (env.BCTRL_AUTH_FILE?.trim()) {
28
- return env.BCTRL_AUTH_FILE.trim();
29
- }
30
- const configDir = env.BCTRL_CONFIG_DIR?.trim() ??
31
- (env.XDG_CONFIG_HOME?.trim()
32
- ? join(env.XDG_CONFIG_HOME.trim(), 'bctrl')
33
- : join(homedir(), '.config', 'bctrl'));
34
- return join(configDir, DEFAULT_AUTH_FILE_NAME);
43
+ const AUTH_WRITE_MARKER_FILE_NAME = 'auth.write';
44
+ const AUTH_WRITE_WAIT_TIMEOUT_MS = 2000;
45
+ const AUTH_WRITE_WAIT_INTERVAL_MS = 100;
46
+ const AUTH_WRITE_STALE_MS = 30000;
47
+ export function normalizeApiBaseUrl(url) {
48
+ return url.replace(/\/+$/, '');
35
49
  }
36
- export async function readStoredAuth(env = process.env) {
37
- const path = getAuthFilePath(env);
50
+ export async function readStoredMetadata(env = process.env) {
51
+ const path = getMetadataFilePath(env);
38
52
  try {
39
- const text = await readFile(path, 'utf8');
40
- return StoredAuthSchema.parse(JSON.parse(text));
53
+ const parsed = StoredAuthMetadataSchema.safeParse(JSON.parse(await readFile(path, 'utf8')));
54
+ // A schema mismatch means a legacy/corrupt metadata file (e.g. a pre-keychain
55
+ // auth.json that embedded a token). Treat it as "logged out" rather than
56
+ // throwing, so the CLI degrades to re-login instead of erroring on startup.
57
+ return parsed.success ? parsed.data : null;
41
58
  }
42
59
  catch (error) {
43
60
  if (isFileNotFound(error))
44
61
  return null;
62
+ if (error instanceof SyntaxError)
63
+ return null;
45
64
  const reason = error instanceof Error ? error.message : String(error);
46
- throw new Error(`Failed to read BCTRL auth file at ${path}: ${reason}`);
65
+ throw new Error(`Failed to read BCTRL auth metadata at ${path}: ${reason}`);
47
66
  }
48
67
  }
49
- export async function writeStoredAuth(auth, env = process.env) {
50
- const path = getAuthFilePath(env);
68
+ /**
69
+ * Resolve the full stored credential (metadata + secret) for the given active
70
+ * API base URL. Returns null when there is no metadata, when it is for a
71
+ * different base URL, or when the secret is missing (e.g. the keychain entry was
72
+ * deleted out from under a stale metadata file) — all of which mean "logged out".
73
+ */
74
+ export async function loadCredential(env = process.env, activeApiBaseUrl) {
75
+ const meta = await readStoredMetadataAfterPendingWrite(env);
76
+ if (!meta)
77
+ return null;
78
+ if (activeApiBaseUrl &&
79
+ normalizeApiBaseUrl(meta.apiBaseUrl) !== normalizeApiBaseUrl(activeApiBaseUrl)) {
80
+ return null;
81
+ }
82
+ const { store } = resolveSecretStore(env);
83
+ const token = await store.get(normalizeApiBaseUrl(meta.apiBaseUrl));
84
+ if (!token)
85
+ return null;
86
+ return { ...meta, token };
87
+ }
88
+ export async function saveCredential(input, env = process.env) {
89
+ await markCredentialWriteStarted(env);
90
+ const { store, keychainFallback } = resolveSecretStore(env);
91
+ try {
92
+ await store.set(normalizeApiBaseUrl(input.apiBaseUrl), input.token);
93
+ const metadata = {
94
+ apiBaseUrl: input.apiBaseUrl,
95
+ whoami: input.whoami,
96
+ method: input.method,
97
+ backend: store.backend,
98
+ createdAt: input.createdAt,
99
+ validatedAt: input.validatedAt,
100
+ };
101
+ const path = getMetadataFilePath(env);
102
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
103
+ await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
104
+ return { path, backend: store.backend, keychainFallback };
105
+ }
106
+ finally {
107
+ await clearCredentialWriteMarker(env);
108
+ }
109
+ }
110
+ /**
111
+ * Remove a stored credential. Deletes the secret from BOTH backends keyed by the
112
+ * (normalized) API base URL — independent of whether the metadata file exists,
113
+ * so a corrupt/missing metadata file can never orphan the secret — then removes
114
+ * the metadata file. Returns whether anything was removed.
115
+ */
116
+ export async function clearCredential(env = process.env, apiBaseUrl) {
117
+ const secretRemoved = await deleteSecretAllBackends(normalizeApiBaseUrl(apiBaseUrl), env);
118
+ let metadataRemoved = false;
119
+ try {
120
+ await rm(getMetadataFilePath(env));
121
+ metadataRemoved = true;
122
+ }
123
+ catch (error) {
124
+ if (!isFileNotFound(error))
125
+ throw error;
126
+ }
127
+ return secretRemoved || metadataRemoved;
128
+ }
129
+ function getAuthWriteMarkerPath(env = process.env) {
130
+ return join(dirname(getMetadataFilePath(env)), AUTH_WRITE_MARKER_FILE_NAME);
131
+ }
132
+ async function markCredentialWriteStarted(env) {
133
+ const path = getAuthWriteMarkerPath(env);
51
134
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
52
- await writeFile(path, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
53
- return path;
135
+ await writeFile(path, new Date().toISOString(), { mode: 0o600 });
136
+ }
137
+ async function clearCredentialWriteMarker(env) {
138
+ try {
139
+ await rm(getAuthWriteMarkerPath(env));
140
+ }
141
+ catch (error) {
142
+ if (!isFileNotFound(error))
143
+ throw error;
144
+ }
54
145
  }
55
- export async function deleteStoredAuth(env = process.env) {
146
+ async function readStoredMetadataAfterPendingWrite(env) {
147
+ const startedAt = Date.now();
148
+ for (;;) {
149
+ const meta = await readStoredMetadata(env);
150
+ if (meta)
151
+ return meta;
152
+ if (!(await isCredentialWriteInProgress(env)) || Date.now() - startedAt >= AUTH_WRITE_WAIT_TIMEOUT_MS) {
153
+ return null;
154
+ }
155
+ await sleep(AUTH_WRITE_WAIT_INTERVAL_MS);
156
+ }
157
+ }
158
+ async function isCredentialWriteInProgress(env) {
56
159
  try {
57
- await rm(getAuthFilePath(env));
58
- return true;
160
+ const marker = await stat(getAuthWriteMarkerPath(env));
161
+ return Date.now() - marker.mtimeMs < AUTH_WRITE_STALE_MS;
59
162
  }
60
163
  catch (error) {
61
164
  if (isFileNotFound(error))
@@ -63,6 +166,9 @@ export async function deleteStoredAuth(env = process.env) {
63
166
  throw error;
64
167
  }
65
168
  }
169
+ function sleep(ms) {
170
+ return new Promise((resolve) => setTimeout(resolve, ms));
171
+ }
66
172
  function isFileNotFound(error) {
67
173
  return (typeof error === 'object' &&
68
174
  error !== null &&