@nebulr-group/bridge-cli 0.6.5 → 1.0.0-alpha.0

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 (293) hide show
  1. package/README.md +93 -460
  2. package/dist/api/accounts.d.ts +69 -0
  3. package/dist/api/accounts.d.ts.map +1 -0
  4. package/dist/api/accounts.js +42 -0
  5. package/dist/api/accounts.js.map +1 -0
  6. package/dist/api/apps.d.ts +50 -0
  7. package/dist/api/apps.d.ts.map +1 -0
  8. package/dist/api/apps.js +62 -0
  9. package/dist/api/apps.js.map +1 -0
  10. package/dist/api/billing.d.ts +131 -0
  11. package/dist/api/billing.d.ts.map +1 -0
  12. package/dist/api/billing.js +42 -0
  13. package/dist/api/billing.js.map +1 -0
  14. package/dist/api/flags.d.ts +73 -0
  15. package/dist/api/flags.d.ts.map +1 -0
  16. package/dist/api/flags.js +108 -0
  17. package/dist/api/flags.js.map +1 -0
  18. package/dist/api/keys.d.ts +48 -0
  19. package/dist/api/keys.d.ts.map +1 -0
  20. package/dist/api/keys.js +87 -0
  21. package/dist/api/keys.js.map +1 -0
  22. package/dist/auth/browser-open.d.ts +21 -4
  23. package/dist/auth/browser-open.d.ts.map +1 -1
  24. package/dist/auth/browser-open.js +45 -65
  25. package/dist/auth/browser-open.js.map +1 -1
  26. package/dist/auth/lock.d.ts +8 -0
  27. package/dist/auth/lock.d.ts.map +1 -0
  28. package/dist/auth/lock.js +43 -0
  29. package/dist/auth/lock.js.map +1 -0
  30. package/dist/auth/loopback.d.ts +8 -12
  31. package/dist/auth/loopback.d.ts.map +1 -1
  32. package/dist/auth/loopback.js +40 -45
  33. package/dist/auth/loopback.js.map +1 -1
  34. package/dist/auth/session.d.ts +61 -0
  35. package/dist/auth/session.d.ts.map +1 -0
  36. package/dist/auth/session.js +142 -0
  37. package/dist/auth/session.js.map +1 -0
  38. package/dist/auth/sign-in.d.ts +67 -0
  39. package/dist/auth/sign-in.d.ts.map +1 -0
  40. package/dist/auth/sign-in.js +113 -0
  41. package/dist/auth/sign-in.js.map +1 -0
  42. package/dist/auth/store.d.ts +89 -0
  43. package/dist/auth/store.d.ts.map +1 -0
  44. package/dist/auth/store.js +214 -0
  45. package/dist/auth/store.js.map +1 -0
  46. package/dist/bin.js +0 -2
  47. package/dist/bin.js.map +1 -1
  48. package/dist/cli.d.ts +1 -2
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +13 -17
  51. package/dist/cli.js.map +1 -1
  52. package/dist/client.d.ts +31 -0
  53. package/dist/client.d.ts.map +1 -0
  54. package/dist/client.js +67 -0
  55. package/dist/client.js.map +1 -0
  56. package/dist/commands/accounts.command.d.ts +4 -0
  57. package/dist/commands/accounts.command.d.ts.map +1 -0
  58. package/dist/commands/accounts.command.js +116 -0
  59. package/dist/commands/accounts.command.js.map +1 -0
  60. package/dist/commands/app.command.d.ts +3 -2
  61. package/dist/commands/app.command.d.ts.map +1 -1
  62. package/dist/commands/app.command.js +132 -233
  63. package/dist/commands/app.command.js.map +1 -1
  64. package/dist/commands/auth.command.d.ts +2 -12
  65. package/dist/commands/auth.command.d.ts.map +1 -1
  66. package/dist/commands/auth.command.js +147 -20
  67. package/dist/commands/auth.command.js.map +1 -1
  68. package/dist/commands/billing.command.d.ts +4 -0
  69. package/dist/commands/billing.command.d.ts.map +1 -0
  70. package/dist/commands/billing.command.js +185 -0
  71. package/dist/commands/billing.command.js.map +1 -0
  72. package/dist/commands/check.command.d.ts +2 -1
  73. package/dist/commands/check.command.d.ts.map +1 -1
  74. package/dist/commands/check.command.js +11 -14
  75. package/dist/commands/check.command.js.map +1 -1
  76. package/dist/commands/context.d.ts +58 -0
  77. package/dist/commands/context.d.ts.map +1 -0
  78. package/dist/commands/context.js +78 -0
  79. package/dist/commands/context.js.map +1 -0
  80. package/dist/commands/flag.command.d.ts +3 -62
  81. package/dist/commands/flag.command.d.ts.map +1 -1
  82. package/dist/commands/flag.command.js +181 -864
  83. package/dist/commands/flag.command.js.map +1 -1
  84. package/dist/commands/key.command.d.ts +4 -0
  85. package/dist/commands/key.command.d.ts.map +1 -0
  86. package/dist/commands/key.command.js +44 -0
  87. package/dist/commands/key.command.js.map +1 -0
  88. package/dist/commands/plan.command.d.ts +3 -203
  89. package/dist/commands/plan.command.d.ts.map +1 -1
  90. package/dist/commands/plan.command.js +200 -759
  91. package/dist/commands/plan.command.js.map +1 -1
  92. package/dist/config.d.ts +19 -101
  93. package/dist/config.d.ts.map +1 -1
  94. package/dist/config.js +51 -364
  95. package/dist/config.js.map +1 -1
  96. package/dist/core/discovery.d.ts +27 -0
  97. package/dist/core/discovery.d.ts.map +1 -0
  98. package/dist/core/discovery.js +97 -0
  99. package/dist/core/discovery.js.map +1 -0
  100. package/dist/core/errors.d.ts +26 -0
  101. package/dist/core/errors.d.ts.map +1 -0
  102. package/dist/core/errors.js +33 -0
  103. package/dist/core/errors.js.map +1 -0
  104. package/dist/core/http.d.ts +23 -0
  105. package/dist/core/http.d.ts.map +1 -0
  106. package/dist/core/http.js +153 -0
  107. package/dist/core/http.js.map +1 -0
  108. package/dist/core/private-files.d.ts +6 -0
  109. package/dist/core/private-files.d.ts.map +1 -0
  110. package/dist/core/private-files.js +96 -0
  111. package/dist/core/private-files.js.map +1 -0
  112. package/dist/core/routes.d.ts +94 -0
  113. package/dist/core/routes.d.ts.map +1 -0
  114. package/dist/core/routes.js +91 -0
  115. package/dist/core/routes.js.map +1 -0
  116. package/dist/core/urls.d.ts +15 -0
  117. package/dist/core/urls.d.ts.map +1 -0
  118. package/dist/core/urls.js +55 -0
  119. package/dist/core/urls.js.map +1 -0
  120. package/dist/gate-rules.d.ts +1 -2
  121. package/dist/gate-rules.d.ts.map +1 -1
  122. package/dist/gate-rules.js +4 -5
  123. package/dist/gate-rules.js.map +1 -1
  124. package/dist/index.d.ts +23 -3
  125. package/dist/index.d.ts.map +1 -1
  126. package/dist/index.js +18 -3
  127. package/dist/index.js.map +1 -1
  128. package/dist/io.d.ts +21 -0
  129. package/dist/io.d.ts.map +1 -0
  130. package/dist/io.js +59 -0
  131. package/dist/io.js.map +1 -0
  132. package/dist/money.d.ts +42 -0
  133. package/dist/money.d.ts.map +1 -0
  134. package/dist/money.js +112 -0
  135. package/dist/money.js.map +1 -0
  136. package/dist/program.d.ts +5 -24
  137. package/dist/program.d.ts.map +1 -1
  138. package/dist/program.js +25 -113
  139. package/dist/program.js.map +1 -1
  140. package/package.json +7 -8
  141. package/dist/auth/api-client.d.ts +0 -50
  142. package/dist/auth/api-client.d.ts.map +0 -1
  143. package/dist/auth/api-client.js +0 -109
  144. package/dist/auth/api-client.js.map +0 -1
  145. package/dist/auth/relative-time.d.ts +0 -2
  146. package/dist/auth/relative-time.d.ts.map +0 -1
  147. package/dist/auth/relative-time.js +0 -27
  148. package/dist/auth/relative-time.js.map +0 -1
  149. package/dist/auth/scopes.d.ts +0 -30
  150. package/dist/auth/scopes.d.ts.map +0 -1
  151. package/dist/auth/scopes.js +0 -39
  152. package/dist/auth/scopes.js.map +0 -1
  153. package/dist/auth/workspace-client.d.ts +0 -70
  154. package/dist/auth/workspace-client.d.ts.map +0 -1
  155. package/dist/auth/workspace-client.js +0 -105
  156. package/dist/auth/workspace-client.js.map +0 -1
  157. package/dist/commands/auth/config.command.d.ts +0 -8
  158. package/dist/commands/auth/config.command.d.ts.map +0 -1
  159. package/dist/commands/auth/config.command.js +0 -60
  160. package/dist/commands/auth/config.command.js.map +0 -1
  161. package/dist/commands/auth/login.command.d.ts +0 -46
  162. package/dist/commands/auth/login.command.d.ts.map +0 -1
  163. package/dist/commands/auth/login.command.js +0 -166
  164. package/dist/commands/auth/login.command.js.map +0 -1
  165. package/dist/commands/auth/logout.command.d.ts +0 -21
  166. package/dist/commands/auth/logout.command.d.ts.map +0 -1
  167. package/dist/commands/auth/logout.command.js +0 -97
  168. package/dist/commands/auth/logout.command.js.map +0 -1
  169. package/dist/commands/auth/methods.command.d.ts +0 -68
  170. package/dist/commands/auth/methods.command.d.ts.map +0 -1
  171. package/dist/commands/auth/methods.command.js +0 -91
  172. package/dist/commands/auth/methods.command.js.map +0 -1
  173. package/dist/commands/auth/status.command.d.ts +0 -18
  174. package/dist/commands/auth/status.command.d.ts.map +0 -1
  175. package/dist/commands/auth/status.command.js +0 -104
  176. package/dist/commands/auth/status.command.js.map +0 -1
  177. package/dist/commands/auth/use.command.d.ts +0 -17
  178. package/dist/commands/auth/use.command.d.ts.map +0 -1
  179. package/dist/commands/auth/use.command.js +0 -47
  180. package/dist/commands/auth/use.command.js.map +0 -1
  181. package/dist/commands/branding.command.d.ts +0 -3
  182. package/dist/commands/branding.command.d.ts.map +0 -1
  183. package/dist/commands/branding.command.js +0 -42
  184. package/dist/commands/branding.command.js.map +0 -1
  185. package/dist/commands/event.command.d.ts +0 -3
  186. package/dist/commands/event.command.d.ts.map +0 -1
  187. package/dist/commands/event.command.js +0 -27
  188. package/dist/commands/event.command.js.map +0 -1
  189. package/dist/commands/flag-init.command.d.ts +0 -13
  190. package/dist/commands/flag-init.command.d.ts.map +0 -1
  191. package/dist/commands/flag-init.command.js +0 -463
  192. package/dist/commands/flag-init.command.js.map +0 -1
  193. package/dist/commands/guide.command.d.ts +0 -61
  194. package/dist/commands/guide.command.d.ts.map +0 -1
  195. package/dist/commands/guide.command.js +0 -655
  196. package/dist/commands/guide.command.js.map +0 -1
  197. package/dist/commands/info.command.d.ts +0 -3
  198. package/dist/commands/info.command.d.ts.map +0 -1
  199. package/dist/commands/info.command.js +0 -70
  200. package/dist/commands/info.command.js.map +0 -1
  201. package/dist/commands/integration.command.d.ts +0 -35
  202. package/dist/commands/integration.command.d.ts.map +0 -1
  203. package/dist/commands/integration.command.js +0 -290
  204. package/dist/commands/integration.command.js.map +0 -1
  205. package/dist/commands/journeys.d.ts +0 -43
  206. package/dist/commands/journeys.d.ts.map +0 -1
  207. package/dist/commands/journeys.js +0 -221
  208. package/dist/commands/journeys.js.map +0 -1
  209. package/dist/commands/ops.command.d.ts +0 -17
  210. package/dist/commands/ops.command.d.ts.map +0 -1
  211. package/dist/commands/ops.command.js +0 -129
  212. package/dist/commands/ops.command.js.map +0 -1
  213. package/dist/commands/privilege.command.d.ts +0 -16
  214. package/dist/commands/privilege.command.d.ts.map +0 -1
  215. package/dist/commands/privilege.command.js +0 -86
  216. package/dist/commands/privilege.command.js.map +0 -1
  217. package/dist/commands/role.command.d.ts +0 -3
  218. package/dist/commands/role.command.d.ts.map +0 -1
  219. package/dist/commands/role.command.js +0 -101
  220. package/dist/commands/role.command.js.map +0 -1
  221. package/dist/commands/runtime-dir.d.ts +0 -16
  222. package/dist/commands/runtime-dir.d.ts.map +0 -1
  223. package/dist/commands/runtime-dir.js +0 -18
  224. package/dist/commands/runtime-dir.js.map +0 -1
  225. package/dist/commands/setup.command.d.ts +0 -63
  226. package/dist/commands/setup.command.d.ts.map +0 -1
  227. package/dist/commands/setup.command.js +0 -148
  228. package/dist/commands/setup.command.js.map +0 -1
  229. package/dist/commands/stripe.command.d.ts +0 -29
  230. package/dist/commands/stripe.command.d.ts.map +0 -1
  231. package/dist/commands/stripe.command.js +0 -99
  232. package/dist/commands/stripe.command.js.map +0 -1
  233. package/dist/commands/tenant.command.d.ts +0 -3
  234. package/dist/commands/tenant.command.d.ts.map +0 -1
  235. package/dist/commands/tenant.command.js +0 -135
  236. package/dist/commands/tenant.command.js.map +0 -1
  237. package/dist/commands/token.command.d.ts +0 -3
  238. package/dist/commands/token.command.d.ts.map +0 -1
  239. package/dist/commands/token.command.js +0 -54
  240. package/dist/commands/token.command.js.map +0 -1
  241. package/dist/commands/user.command.d.ts +0 -3
  242. package/dist/commands/user.command.d.ts.map +0 -1
  243. package/dist/commands/user.command.js +0 -117
  244. package/dist/commands/user.command.js.map +0 -1
  245. package/dist/credentials.d.ts +0 -141
  246. package/dist/credentials.d.ts.map +0 -1
  247. package/dist/credentials.js +0 -348
  248. package/dist/credentials.js.map +0 -1
  249. package/dist/flag-coherence.d.ts +0 -42
  250. package/dist/flag-coherence.d.ts.map +0 -1
  251. package/dist/flag-coherence.js +0 -103
  252. package/dist/flag-coherence.js.map +0 -1
  253. package/dist/output.d.ts +0 -17
  254. package/dist/output.d.ts.map +0 -1
  255. package/dist/output.js +0 -107
  256. package/dist/output.js.map +0 -1
  257. package/dist/preview.d.ts +0 -9
  258. package/dist/preview.d.ts.map +0 -1
  259. package/dist/preview.js +0 -63
  260. package/dist/preview.js.map +0 -1
  261. package/dist/product-decisions.d.ts +0 -40
  262. package/dist/product-decisions.d.ts.map +0 -1
  263. package/dist/product-decisions.js +0 -52
  264. package/dist/product-decisions.js.map +0 -1
  265. package/dist/prompt-urls.d.ts +0 -31
  266. package/dist/prompt-urls.d.ts.map +0 -1
  267. package/dist/prompt-urls.js +0 -35
  268. package/dist/prompt-urls.js.map +0 -1
  269. package/dist/prompts/auth-master-integration-prompt.md +0 -255
  270. package/dist/prompts/billing/master.md +0 -332
  271. package/dist/prompts/decisions/feature-control.md +0 -131
  272. package/dist/prompts/decisions/going-live.md +0 -60
  273. package/dist/prompts/decisions/login.md +0 -71
  274. package/dist/prompts/decisions/look-and-feel.md +0 -68
  275. package/dist/prompts/decisions/payments.md +0 -97
  276. package/dist/prompts/decisions/roles.md +0 -78
  277. package/dist/prompts/decisions/teams.md +0 -61
  278. package/dist/prompts/fit-together.md +0 -97
  279. package/dist/prompts/flags/master.md +0 -220
  280. package/dist/prompts/guide-coverage.json +0 -17
  281. package/dist/prompts/integration-success.md +0 -106
  282. package/dist/prompts/mcp-tools.json +0 -90
  283. package/dist/prompts/mechanisms.md +0 -298
  284. package/dist/prompts/orientation.md +0 -42
  285. package/dist/prompts/product-decisions.json +0 -338
  286. package/dist/resolve.d.ts +0 -110
  287. package/dist/resolve.d.ts.map +0 -1
  288. package/dist/resolve.js +0 -260
  289. package/dist/resolve.js.map +0 -1
  290. package/dist/shape.d.ts +0 -25
  291. package/dist/shape.d.ts.map +0 -1
  292. package/dist/shape.js +0 -74
  293. package/dist/shape.js.map +0 -1
@@ -1,874 +1,191 @@
1
- // TBP-192 / TBP-236 — Flag CLI parity for FF 2.0.
2
- //
3
- // The CLI's `flag` subcommand exposes the 2.0 mental model:
4
- // - Three-state model: off | on | on-with-rule
5
- // - Multi-type values: boolean | string | number | json
6
- // - Inline rules (branches + conditions + rolloutPct)
7
- // - Scheduling (data-only; the runner is TBP-189 — we just set/clear the field)
8
- // - Local eval helper for debugging
9
- //
10
- // TBP-236: the CLI's rule format is now the canonical auth-core one. The
11
- // `Rule` / `Branch` / `Condition` types and the `evaluateRule` / `validateRule`
12
- // evaluator are imported from @nebulr-group/bridge-auth-core — there is no
13
- // CLI-local rule shape anymore. Conditions always carry a plural `values`
14
- // array on the wire, and operators come from the package's locked vocabulary
15
- // (`OPERATORS`: eq | neq | contains | not_contains | in | not_in | gt | lt |
16
- // between | regex | exists | not_exists). Legacy CLI inputs (singular
17
- // `value`, operator names like `equals` / `starts_with` / `gte`) are migrated
18
- // at parse time by `normalizeRuleInput()` below, with a deprecation warning
19
- // on stderr for legacy operator names.
20
- import { readFileSync, writeFileSync } from 'node:fs';
21
- import { OPERATORS, evaluateRule, isOperator, validateRule, } from '@nebulr-group/bridge-auth-core';
22
- import { getManagementClient } from '../config.js';
23
- import { warningsForFlags, withFlagWarnings } from '../flag-coherence.js';
24
- import { dryRunPreview } from '../preview.js';
25
- import { outputSuccess, outputError } from '../output.js';
26
- import { resolveFlag, resolveFlagId } from '../resolve.js';
27
- import { registerFlagInitCommand } from './flag-init.command.js';
28
- // Re-export the canonical validator so existing consumers/tests keep a single
29
- // import site for rule validation alongside the CLI parse helpers.
30
- export { validateRule };
31
- const FLAG_STATES = ['off', 'on', 'on-with-rule'];
32
- const FLAG_VALUE_TYPES = ['boolean', 'string', 'number', 'json'];
33
- // ── Public registration ─────────────────────────────────────────────────────
34
- export function registerFlagCommands(program) {
35
- const flag = program
36
- .command('flag')
37
- .description([
38
- 'Manage feature flags (Feature Flags 2.0 mental model)',
39
- '',
40
- ' state off | on | on-with-rule',
41
- ' values boolean | string | number | json (typed at flag level)',
42
- ' rule branches[] + otherwiseValue + rolloutPct (first-match-wins)',
43
- ].join('\n'));
44
- registerList(flag);
45
- registerGet(flag);
46
- registerCreate(flag);
47
- registerUpdate(flag);
48
- registerToggle(flag);
49
- registerDelete(flag);
50
- registerEval(flag);
51
- registerSchedule(flag);
52
- registerExport(flag);
53
- registerImport(flag);
54
- registerFlagInitCommand(flag);
55
- }
56
- // ── list ────────────────────────────────────────────────────────────────────
57
- function registerList(flag) {
58
- flag
59
- .command('list')
60
- .description('List feature flags (shows state + value type)')
61
- .action(async () => {
62
- try {
63
- const flags = await getManagementClient().flags.list();
64
- // Normalize so callers always see the same shape even if the API
65
- // returns the legacy fields only.
66
- // TBP-709 — a flag that cannot mean what it looks like carries warnings.
67
- const warnings = await warningsForFlags(flags.map((f) => ({ ...f, state: f.state ?? deriveState(f) })));
68
- const rows = flags.map((f, i) => ({
69
- id: f.id,
70
- key: f.key,
71
- description: f.description,
72
- state: f.state ?? deriveState(f),
73
- valueType: f.valueType ?? 'boolean',
74
- enabled: f.enabled,
75
- ...(warnings[i].length ? { warnings: warnings[i] } : {}),
76
- }));
77
- outputSuccess(rows);
78
- }
79
- catch (err) {
80
- outputError(err);
81
- }
82
- });
83
- }
84
- // ── get ─────────────────────────────────────────────────────────────────────
85
- function registerGet(flag) {
86
- flag
87
- .command('get')
88
- .argument('<key>', 'Flag key')
89
- .description('Get a flag (rich 2.0 shape: state, value type, rule, observability)')
90
- .action(async (key) => {
91
- try {
92
- const all = await getManagementClient().flags.list();
93
- const found = all.find((f) => f.key === key);
94
- if (!found) {
95
- outputError(new Error(`Flag not found: ${key}`));
96
- return;
97
- }
98
- outputSuccess(await withFlagWarnings({
99
- id: found.id,
100
- key: found.key,
101
- description: found.description,
102
- state: found.state ?? deriveState(found),
103
- valueType: found.valueType ?? 'boolean',
104
- offValue: found.offValue,
105
- onValue: found.onValue,
106
- rule: found.rule,
107
- schedule: found.schedule,
108
- observability: {
109
- evalCount: found.evalCount,
110
- lastEvalAt: found.lastEvalAt,
111
- },
112
- // Echo the legacy fields too — useful while 1.0 admin clients exist.
113
- legacy: {
114
- enabled: found.enabled,
115
- defaultValue: found.defaultValue,
116
- targetValue: found.targetValue,
117
- },
118
- }));
119
- }
120
- catch (err) {
121
- outputError(err);
122
- }
123
- });
124
- }
125
- // ── create ──────────────────────────────────────────────────────────────────
126
- function registerCreate(flag) {
127
- flag
128
- .command('create')
129
- .description('Create a new feature flag (2.0 fields)')
130
- .requiredOption('--key <key>', 'Flag key')
131
- .option('--description <desc>', 'Description')
132
- .option('--state <state>', `Three-state model: ${FLAG_STATES.join(' | ')} (default: off)`)
133
- .option('--value-type <type>', `Flag value type: ${FLAG_VALUE_TYPES.join(' | ')} (default: boolean)`)
134
- .option('--on-value <value>', 'Value returned when state=on (parsed per --value-type; JSON string for json type)')
135
- .option('--off-value <value>', 'Value returned when state=off (parsed per --value-type)')
136
- .option('--rule <json>', 'Rule as a JSON string: {"branches":[...],"otherwiseValue":...,"rolloutPct":100}')
137
- // Legacy 1.0 fields kept so existing scripts don't break.
138
- .option('--enabled', 'Legacy: enable the flag', false)
139
- .option('--default-value', 'Legacy: default value when no segment matches', false)
140
- .action(async (opts) => {
141
- try {
142
- // `--key` is a requiredOption above, so `key` is always present at
143
- // runtime even though buildFlagWritePayload's generic return type
144
- // can't express that. The `unknown` step is just to convince TS the
145
- // intent is deliberate.
146
- const payload = buildFlagWritePayload(opts);
147
- const flag = await getManagementClient().flags.create(payload);
148
- outputSuccess(await withFlagWarnings(flag));
149
- }
150
- catch (err) {
151
- outputError(err);
152
- }
153
- });
154
- }
155
- // ── update ──────────────────────────────────────────────────────────────────
156
- function registerUpdate(flag) {
157
- flag
158
- .command('update')
159
- .description('Update a feature flag by --key or --id (2.0 fields)')
160
- .option('--key <key>', 'Flag key to address (alternative to --id)')
161
- .option('--id <id>', 'Flag ID to address (alternative to --key)')
162
- .option('--new-key <key>', 'Rename the flag to this key')
163
- .option('--description <desc>', 'Description')
164
- .option('--state <state>', `Three-state model: ${FLAG_STATES.join(' | ')}`)
165
- .option('--value-type <type>', `Flag value type: ${FLAG_VALUE_TYPES.join(' | ')}`)
166
- .option('--on-value <value>', 'Value returned when state=on (parsed per --value-type)')
167
- .option('--off-value <value>', 'Value returned when state=off (parsed per --value-type)')
168
- .option('--rule <json>', 'Rule as a JSON string (must validate via validateRule)')
169
- .option('--clear-rule', 'Remove the existing rule')
170
- // Legacy 1.0
171
- .option('--enabled <bool>', 'Legacy: enable/disable', (v) => v === 'true')
172
- .option('--default-value <bool>', 'Legacy: default value', (v) => v === 'true')
173
- .action(async (opts) => {
174
- try {
175
- // TBP-586. `--key` is overloaded here for backwards compatibility:
176
- // before key-addressing existed, `flag update --id X --key Y` renamed
177
- // the flag to Y, and that still works. So `--key` means "address this
178
- // flag" only when `--id` is absent; alongside `--id` it keeps its old
179
- // rename meaning. `--new-key` renames in either addressing mode.
180
- const addressedById = opts.id !== undefined;
181
- const renameTo = opts.newKey !== undefined
182
- ? opts.newKey
183
- : (addressedById ? opts.key : undefined);
184
- const id = await resolveFlagId({
185
- id: opts.id,
186
- key: addressedById ? undefined : opts.key,
187
- });
188
- const payload = buildFlagWritePayload({ ...opts, key: renameTo }, { partial: true });
189
- if (opts.clearRule) {
190
- payload.rule = null;
191
- }
192
- const flag = await getManagementClient().flags.update(id, payload);
193
- outputSuccess(await withFlagWarnings(flag));
194
- }
195
- catch (err) {
196
- outputError(err);
197
- }
198
- });
199
- }
200
- // ── toggle (kept from 1.0; convenience for state on/off without typing JSON) ─
201
- //
202
- // TBP-548. The command used to call `flags.toggle()`, which PUT the FF 1.0
203
- // `{ enabled }` boolean. FF 2.0 dropped that field from the flag document and
204
- // evaluates on `state` alone, and the server's write DTO strips undeclared
205
- // keys — so the body arrived empty, the API answered 200 with the untouched
206
- // flag, and the CLI printed `success: true` for a flag that had not moved.
207
- //
208
- // The mapping below is the one the MCP `toggle_feature_flag` tool settled on
209
- // (TBP-587), so the two surfaces over the same endpoint agree:
210
- //
211
- // --enabled true → state "on" (everyone gets onValue)
212
- // --enabled false → state "off" (everyone gets offValue)
213
- //
214
- // with one refusal: a flag already at "on-with-rule" is not turned ON here.
215
- // "on" stops the rule being consulted and hands onValue to every caller, which
216
- // widens the audience instead of flipping a switch — `flag update --state on`
217
- // is where you say that deliberately. Turning such a flag OFF stays allowed:
218
- // the rule document is preserved, so it is reversible.
219
- /** Error carrying a machine-readable `code` for `outputError` to report. */
220
- class FlagCommandError extends Error {
221
- code;
222
- constructor(code, message) {
223
- super(message);
224
- this.name = 'FlagCommandError';
225
- this.code = code;
226
- }
227
- }
228
- function registerToggle(flag) {
229
- flag
230
- .command('toggle')
231
- .description('Quick toggle a flag on or off by --key or --id — sets state=on / state=off (the rule is kept)')
232
- .option('--key <key>', 'Flag key to address (alternative to --id)')
233
- .option('--id <id>', 'Flag ID to address (alternative to --key)')
234
- .requiredOption('--enabled <bool>', 'true = state on, false = state off', (v) => v === 'true')
235
- .action(async (opts) => {
236
- try {
237
- const enabled = opts.enabled;
238
- const current = await resolveFlag({ id: opts.id, key: opts.key });
239
- const currentState = current.state ?? deriveState(current);
240
- if (currentState === 'on-with-rule' && enabled) {
241
- throw new FlagCommandError('FLAG_HAS_TARGETING', `Flag '${current.key}' is state=on-with-rule: a targeting rule decides who gets it. ` +
242
- 'Turning it fully on would give it to everyone and stop the rule being consulted, ' +
243
- 'so nothing was changed. It is already on for whoever the rule matches. To widen it ' +
244
- 'to everyone deliberately, run `bridge flag update --key ' +
245
- `${current.key} --state on\` (the rule is preserved and you can set it back to ` +
246
- 'on-with-rule later).');
247
- }
248
- const wanted = enabled ? 'on' : 'off';
249
- const result = await getManagementClient().flags.update(current.id, { state: wanted });
250
- // Read the write back rather than trusting the 200. The original bug
251
- // was invisible precisely because nobody checked that it landed.
252
- const resultingState = result?.state ?? (result ? deriveState(result) : undefined);
253
- if (resultingState !== wanted) {
254
- throw new FlagCommandError('TOGGLE_NOT_APPLIED', `Requested state='${wanted}' for '${current.key}' but the flag came back as ` +
255
- `state='${resultingState ?? 'unknown'}'. The change did NOT take effect. ` +
256
- 'Re-read it with `bridge flag get ' +
257
- `${current.key}\`, then set the state explicitly with \`bridge flag update\`.`);
258
- }
259
- outputSuccess(await withFlagWarnings(result));
260
- }
261
- catch (err) {
262
- outputError(err);
263
- }
264
- });
265
- }
266
- // ── delete ──────────────────────────────────────────────────────────────────
267
- function registerDelete(flag) {
268
- flag
269
- .command('delete')
270
- .description('Delete a feature flag by --key or --id')
271
- .option('--key <key>', 'Flag key to address (alternative to --id)')
272
- .option('--id <id>', 'Flag ID to address (alternative to --key)')
273
- .option('--dry-run', 'Show what deleting it would do, and delete nothing')
274
- .action(async (opts) => {
275
- try {
276
- const id = await resolveFlagId({ id: opts.id, key: opts.key });
277
- if (opts.dryRun) {
278
- outputSuccess(await dryRunPreview(`/v1/admin/flags/flag/${encodeURIComponent(id)}`));
279
- return;
280
- }
281
- await getManagementClient().flags.delete(id);
282
- outputSuccess({ deleted: true, id });
283
- }
284
- catch (err) {
285
- outputError(err);
286
- }
287
- });
288
- }
289
- // ── eval (local SDK-style evaluation, for debugging) ────────────────────────
290
- function registerEval(flag) {
291
- flag
292
- .command('eval')
293
- .argument('<key>', 'Flag key')
294
- .description('Locally evaluate a flag against a synthetic context (debug helper)')
295
- .option('--identity <id>', 'Eval identity (required when rolloutPct < 100)')
296
- .option('--attribute <kv...>', 'One or more key=value attributes (use multiple flags or space-separate)')
297
- .action(async (key, opts) => {
298
- try {
299
- const all = await getManagementClient().flags.list();
300
- const found = all.find((f) => f.key === key);
301
- if (!found) {
302
- outputError(new Error(`Flag not found: ${key}`));
303
- return;
304
- }
305
- const attributes = parseAttributes(opts.attribute ?? []);
306
- const ctx = { identity: opts.identity, attributes };
307
- const cached = {
308
- key: found.key,
309
- state: found.state ?? deriveState(found),
310
- valueType: found.valueType ?? 'boolean',
311
- offValue: found.offValue ?? false,
312
- onValue: found.onValue ?? true,
313
- // `FlagResponse.rule` is the canonical auth-core `Rule` — no
314
- // bridging cast needed (TBP-236).
315
- rule: found.rule ?? undefined,
316
- };
317
- const result = evaluateLocally(cached, ctx);
318
- // TBP-709 — same coherence warnings as the MCP evaluate_feature_flag.
319
- const [warnings] = await warningsForFlags([cached]);
320
- outputSuccess({
321
- flag: cached.key,
322
- state: cached.state,
323
- context: ctx,
324
- result,
325
- warnings,
326
- });
327
- }
328
- catch (err) {
329
- outputError(err);
330
- }
331
- });
332
- }
333
- // ── schedule ────────────────────────────────────────────────────────────────
334
- function registerSchedule(flag) {
335
- const schedule = flag
336
- .command('schedule')
337
- .description('Manage scheduled state changes (TBP-189 — runner not yet active)');
338
- schedule
339
- .command('set')
340
- .argument('<key>', 'Flag key')
341
- .description('Schedule a state transition at an ISO timestamp')
342
- .requiredOption('--at <iso>', 'ISO-8601 timestamp (e.g. 2026-05-20T09:00:00Z)')
343
- .requiredOption('--state <state>', `Target state: ${FLAG_STATES.join(' | ')}`)
344
- .action(async (key, opts) => {
345
- try {
346
- if (!FLAG_STATES.includes(opts.state)) {
347
- throw new Error(`Invalid --state: ${opts.state}. Must be one of ${FLAG_STATES.join(', ')}.`);
348
- }
349
- const at = new Date(opts.at);
350
- if (Number.isNaN(at.getTime())) {
351
- throw new Error(`Invalid --at: ${opts.at} is not a valid ISO-8601 timestamp.`);
352
- }
353
- const id = await resolveFlagId({ key });
354
- const schedule = { at: at.toISOString(), state: opts.state };
355
- const update = { schedule };
356
- const result = await getManagementClient().flags.update(id, update);
357
- outputSuccess(result);
358
- }
359
- catch (err) {
360
- outputError(err);
361
- }
362
- });
363
- schedule
364
- .command('clear')
365
- .argument('<key>', 'Flag key')
366
- .description('Clear an existing schedule')
367
- .action(async (key) => {
368
- try {
369
- const id = await resolveFlagId({ key });
370
- const update = { schedule: null };
371
- const result = await getManagementClient().flags.update(id, update);
372
- outputSuccess(result);
373
- }
374
- catch (err) {
375
- outputError(err);
376
- }
377
- });
378
- }
379
- /** Build the canonical, portable doc from a remote flag record. Pure. */
380
- export function toFlagDoc(f) {
381
- return {
382
- key: f.key,
383
- description: f.description,
384
- state: f.state ?? deriveState(f),
385
- valueType: f.valueType ?? 'boolean',
386
- offValue: f.offValue,
387
- onValue: f.onValue,
388
- rule: f.rule,
389
- schedule: f.schedule,
390
- };
391
- }
392
- /** Build a create/update write payload from a doc. Pure. */
393
- export function buildWritePayloadFromDoc(doc) {
394
- const payload = {
395
- key: doc.key,
396
- state: doc.state,
397
- valueType: doc.valueType,
1
+ import { readFile } from 'node:fs/promises';
2
+ import { InvalidArgumentError } from 'commander';
3
+ import { FLAG_STATES, VALUE_TYPES, flags, parseCondition, parseRule, parseValue } from '../api/flags.js';
4
+ import { CliError } from '../core/errors.js';
5
+ import { action, collect } from './context.js';
6
+ function oneOf(list, what) {
7
+ return (value) => {
8
+ if (!list.includes(value))
9
+ throw new InvalidArgumentError(`${what} is one of ${list.join(', ')}.`);
10
+ return value;
398
11
  };
399
- if (doc.description !== undefined)
400
- payload.description = doc.description;
401
- if (doc.offValue !== undefined)
402
- payload.offValue = doc.offValue;
403
- if (doc.onValue !== undefined)
404
- payload.onValue = doc.onValue;
405
- if (doc.rule !== undefined)
406
- payload.rule = doc.rule;
407
- if (doc.schedule !== undefined)
408
- payload.schedule = doc.schedule;
409
- return payload;
410
12
  }
411
- /** Validate + normalize a doc parsed from a file. Throws on the first problem. */
412
- export function validateFlagDoc(input, index) {
413
- if (!input || typeof input !== 'object') {
414
- throw new Error(`flags[${index}] must be an object.`);
415
- }
416
- const d = input;
417
- if (typeof d.key !== 'string' || d.key.length === 0) {
418
- throw new Error(`flags[${index}] is missing a non-empty "key".`);
419
- }
420
- const state = d.state ?? 'off';
421
- if (!FLAG_STATES.includes(state)) {
422
- throw new Error(`flags[${index}] ("${d.key}") invalid state "${String(d.state)}". One of ${FLAG_STATES.join(', ')}.`);
423
- }
424
- const valueType = d.valueType ?? 'boolean';
425
- if (!FLAG_VALUE_TYPES.includes(valueType)) {
426
- throw new Error(`flags[${index}] ("${d.key}") invalid valueType "${String(d.valueType)}". One of ${FLAG_VALUE_TYPES.join(', ')}.`);
427
- }
428
- let rule = d.rule;
429
- if (d.rule !== undefined && d.rule !== null) {
430
- let normalized;
431
- try {
432
- normalized = normalizeRuleInput(d.rule);
433
- }
434
- catch (err) {
435
- throw new Error(`flags[${index}] ("${d.key}") rule failed validation:\n - ${err instanceof Error ? err.message : String(err)}`);
436
- }
437
- const errs = validateRule(normalized);
438
- if (errs.length > 0) {
439
- throw new Error(`flags[${index}] ("${d.key}") rule failed validation:\n${errs.map((e) => ` - ${e.message}`).join('\n')}`);
440
- }
441
- rule = normalized;
442
- }
13
+ function percent(value) {
14
+ const n = Number(value);
15
+ if (!Number.isFinite(n) || n < 0 || n > 100)
16
+ throw new InvalidArgumentError('A share from 0 to 100.');
17
+ return n;
18
+ }
19
+ async function ruleFrom(opts) {
20
+ if (opts.rule !== undefined && opts.ruleFile !== undefined)
21
+ throw new CliError('invalid', 'Give the rule with --rule or --rule-file, not both.');
22
+ if (opts.rule !== undefined)
23
+ return parseRule(opts.rule);
24
+ if (opts.ruleFile !== undefined)
25
+ return parseRule(await readFile(opts.ruleFile, 'utf8'));
26
+ return undefined;
27
+ }
28
+ const withFlagOptions = (cmd) => cmd
29
+ .option('--description <text>', 'What the flag gates')
30
+ .option('--on-value <value>', 'The value while it is on (default true)')
31
+ .option('--off-value <value>', 'The value while it is off (default false)')
32
+ .option('--state <state>', `${FLAG_STATES.join(' | ')}`, oneOf(FLAG_STATES, '--state'))
33
+ .option('--rule <json>', 'The rule as JSON: { "branches": [...], "otherwiseValue": … }')
34
+ .option('--rule-file <file>', 'The rule from a JSON file')
35
+ .option('--when <condition>', 'A condition of a one-branch rule, "<attribute> <operator> <value>", such as "privileges contains USER_WRITE"; repeat to AND them', collect)
36
+ .option('--rollout <percent>', 'With --when: only this share of the people it matches', percent)
37
+ .option('--public', 'Visitors who are not signed in may read it');
38
+ /** A one-branch rule from `--when` conditions: matches give the on value, everyone else the off value. */
39
+ function whenRule(conditions, onValue, offValue, rollout) {
443
40
  return {
444
- key: d.key,
445
- description: d.description,
446
- state,
447
- valueType,
448
- offValue: d.offValue,
449
- onValue: d.onValue,
450
- rule,
451
- schedule: d.schedule,
41
+ branches: [{ conditions: conditions.map(parseCondition), returnValue: onValue, ...(rollout === undefined ? {} : { rolloutPct: rollout }) }],
42
+ otherwiseValue: offValue,
452
43
  };
453
44
  }
454
- const DOC_FIELDS = [
455
- 'description',
456
- 'state',
457
- 'valueType',
458
- 'offValue',
459
- 'onValue',
460
- 'rule',
461
- 'schedule',
462
- ];
463
- function docChanges(a, b) {
464
- return DOC_FIELDS.filter((f) => JSON.stringify(a[f] ?? null) !== JSON.stringify(b[f] ?? null));
465
- }
466
- /** Diff file docs against remote flags (matched by key). Pure. */
467
- export function diffFlags(remote, docs, opts = {}) {
468
- const byKey = new Map(remote.map((f) => [f.key, f]));
469
- const fileKeys = new Set(docs.map((d) => d.key));
470
- const diff = { create: [], update: [], prune: [], unchanged: [] };
471
- for (const doc of docs) {
472
- const r = byKey.get(doc.key);
473
- if (!r) {
474
- diff.create.push(doc);
475
- continue;
476
- }
477
- const changes = docChanges(toFlagDoc(r), doc);
478
- if (changes.length === 0)
479
- diff.unchanged.push(doc.key);
480
- else
481
- diff.update.push({ id: r.id, doc, changes });
482
- }
483
- if (opts.prune) {
484
- for (const r of remote) {
485
- if (!fileKeys.has(r.key)) {
486
- diff.prune.push({ id: r.id, key: r.key });
487
- }
488
- }
489
- }
490
- return diff;
491
- }
492
- function registerExport(flag) {
493
- flag
494
- .command('export')
495
- .description('Export all feature flags as code (canonical 2.0 JSON)')
496
- .option('--out <file>', 'Write to a file instead of stdout')
497
- .action(async (opts) => {
498
- try {
499
- const all = (await getManagementClient().flags.list());
500
- const flags = all.map(toFlagDoc).sort((a, b) => a.key.localeCompare(b.key));
501
- const bundle = { version: 1, flags };
502
- if (opts.out) {
503
- writeFileSync(opts.out, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
504
- outputSuccess({ written: opts.out, count: flags.length });
505
- }
506
- else {
507
- outputSuccess(bundle);
508
- }
509
- }
510
- catch (err) {
511
- outputError(err);
512
- }
513
- });
514
- }
515
- function registerImport(flag) {
516
- flag
517
- .command('import')
518
- .argument('<file>', 'Path to a flags JSON file (from `flag export`)')
519
- .description('Apply feature flags from a file (create + update; --prune deletes extras)')
520
- .option('--dry-run', 'Show the diff without applying any changes', false)
521
- .option('--prune', 'Delete remote flags not present in the file', false)
522
- .action(async (file, opts) => {
523
- try {
524
- let raw;
525
- try {
526
- raw = JSON.parse(readFileSync(file, 'utf8'));
527
- }
528
- catch (err) {
529
- throw new Error(`Could not read/parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
530
- }
531
- const list = Array.isArray(raw) ? raw : raw.flags;
532
- if (!Array.isArray(list)) {
533
- throw new Error('File must be an array of flags or an object with a "flags" array.');
534
- }
535
- const docs = list.map((d, i) => validateFlagDoc(d, i));
536
- const remote = (await getManagementClient().flags.list());
537
- const diff = diffFlags(remote, docs, { prune: opts.prune });
538
- if (opts.dryRun) {
539
- outputSuccess({
540
- dryRun: true,
541
- create: diff.create.map((d) => d.key),
542
- update: diff.update.map((u) => ({ key: u.doc.key, changes: u.changes })),
543
- prune: diff.prune.map((p) => p.key),
544
- unchanged: diff.unchanged,
545
- });
546
- return;
547
- }
548
- const mgmt = getManagementClient();
549
- for (const doc of diff.create) {
550
- await mgmt.flags.create(buildWritePayloadFromDoc(doc));
551
- }
552
- for (const u of diff.update) {
553
- await mgmt.flags.update(u.id, buildWritePayloadFromDoc(u.doc));
554
- }
555
- for (const p of diff.prune) {
556
- await mgmt.flags.delete(p.id);
557
- }
558
- outputSuccess({
559
- created: diff.create.map((d) => d.key),
560
- updated: diff.update.map((u) => u.doc.key),
561
- pruned: diff.prune.map((p) => p.key),
562
- unchanged: diff.unchanged,
563
- });
564
- }
565
- catch (err) {
566
- outputError(err);
567
- }
568
- });
569
- }
570
- export function buildFlagWritePayload(opts, { partial = false } = {}) {
571
- const payload = {};
572
- if (opts.key !== undefined)
573
- payload.key = opts.key;
574
- if (opts.description !== undefined)
575
- payload.description = opts.description;
576
- if (opts.state !== undefined) {
577
- if (!FLAG_STATES.includes(opts.state)) {
578
- throw new Error(`Invalid --state: ${opts.state}. Must be one of ${FLAG_STATES.join(', ')}.`);
579
- }
580
- payload.state = opts.state;
581
- }
582
- let valueType;
583
- if (opts.valueType !== undefined) {
584
- if (!FLAG_VALUE_TYPES.includes(opts.valueType)) {
585
- throw new Error(`Invalid --value-type: ${opts.valueType}. Must be one of ${FLAG_VALUE_TYPES.join(', ')}.`);
586
- }
587
- valueType = opts.valueType;
588
- payload.valueType = valueType;
589
- }
590
- if (opts.onValue !== undefined) {
591
- payload.onValue = coerceValue(opts.onValue, valueType ?? 'boolean');
592
- }
593
- if (opts.offValue !== undefined) {
594
- payload.offValue = coerceValue(opts.offValue, valueType ?? 'boolean');
595
- }
596
- if (opts.rule !== undefined) {
597
- const parsed = parseRuleArg(opts.rule);
598
- payload.rule = parsed;
599
- }
600
- // Legacy fields. In partial (update) mode, only include if explicitly set —
601
- // commander returns `false` as default for boolean flags, which would
602
- // otherwise clobber server state on every update.
603
- if (!partial || opts.enabled !== undefined) {
604
- if (opts.enabled !== undefined)
605
- payload.enabled = opts.enabled;
606
- }
607
- if (!partial || opts.defaultValue !== undefined) {
608
- if (opts.defaultValue !== undefined)
609
- payload.defaultValue = opts.defaultValue;
610
- }
611
- return payload;
612
- }
613
- export function coerceValue(raw, type) {
614
- switch (type) {
615
- case 'boolean':
616
- if (raw === 'true')
617
- return true;
618
- if (raw === 'false')
619
- return false;
620
- throw new Error(`Invalid boolean value "${raw}" — expected "true" or "false".`);
621
- case 'number': {
622
- const n = Number(raw);
623
- if (!Number.isFinite(n))
624
- throw new Error(`Invalid number value "${raw}".`);
625
- return n;
626
- }
627
- case 'string':
628
- return raw;
629
- case 'json':
630
- try {
631
- return JSON.parse(raw);
632
- }
633
- catch (err) {
634
- throw new Error(`Invalid JSON value: ${err instanceof Error ? err.message : String(err)}`);
635
- }
636
- }
637
- }
638
- export function parseRuleArg(raw) {
639
- let parsed;
640
- try {
641
- parsed = JSON.parse(raw);
642
- }
643
- catch (err) {
644
- throw new Error(`--rule must be valid JSON. Parse error: ${err instanceof Error ? err.message : String(err)}`);
645
- }
646
- let rule;
647
- try {
648
- rule = normalizeRuleInput(parsed);
649
- }
650
- catch (err) {
651
- throw new Error(`--rule failed validation:\n - ${err instanceof Error ? err.message : String(err)}`);
652
- }
653
- const errors = validateRule(rule);
654
- if (errors.length > 0) {
655
- throw new Error(`--rule failed validation:\n${errors.map((e) => ` - ${e.message}`).join('\n')}`);
656
- }
657
- return rule;
658
- }
659
- // ── Parse-time rule normalization (TBP-236) ─────────────────────────────────
660
- //
661
- // The CLI historically accepted conditions of shape { attribute, operator,
662
- // value } with operator names like 'equals' / 'starts_with' / 'gte'. The
663
- // canonical auth-core format is { attribute, operator, values: [...] } with
664
- // the locked operator vocabulary in `OPERATORS`. `normalizeRuleInput` migrates
665
- // legacy input to the canonical shape at parse time:
666
- // - scalar `value` → plural `values` array (`"value": "pro"` → `values: ["pro"]`)
667
- // - legacy operator names → canonical ones (deprecation warning on stderr)
668
- // Structurally invalid input (non-object rule, missing otherwiseValue, ...)
669
- // throws; unknown operators are passed through for `validateRule` to report.
670
- /** Legacy operator names that map 1:1 onto a canonical operator. */
671
- const LEGACY_OPERATOR_ALIASES = {
672
- equals: 'eq',
673
- equal: 'eq',
674
- not_equals: 'neq',
675
- not_equal: 'neq',
676
- notEquals: 'neq',
677
- does_not_contain: 'not_contains',
678
- notContains: 'not_contains',
679
- greater_than: 'gt',
680
- greaterThan: 'gt',
681
- less_than: 'lt',
682
- lessThan: 'lt',
683
- matches: 'regex',
684
- };
685
- function warnDeprecatedOperator(legacy, canonical, note) {
686
- process.stderr.write(`Warning: rule operator "${legacy}" is deprecated; mapped to "${canonical}"${note ? ` (${note})` : ''}. ` +
687
- `Canonical operators: ${OPERATORS.join(', ')}.\n`);
688
- }
689
- function escapeRegExp(s) {
690
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
691
- }
692
- /**
693
- * Map a (possibly legacy) operator name + values to the canonical vocabulary.
694
- * Some legacy operators have no 1:1 canonical equivalent and are rewritten
695
- * with adjusted values:
696
- * starts_with x → regex ^x ends_with x → regex x$
697
- * gte x → between [x, Number.MAX_VALUE]
698
- * lte x → between [-Number.MAX_VALUE, x]
699
- * Unknown names are passed through so validateRule reports them.
700
- */
701
- function resolveOperator(op, values) {
702
- if (isOperator(op))
703
- return { operator: op, values };
704
- const alias = LEGACY_OPERATOR_ALIASES[op];
705
- if (alias) {
706
- warnDeprecatedOperator(op, alias);
707
- return { operator: alias, values };
708
- }
709
- switch (op) {
710
- case 'starts_with':
711
- case 'startsWith':
712
- case 'begins_with':
713
- case 'beginsWith':
714
- warnDeprecatedOperator(op, 'regex', 'value rewritten to an anchored ^… pattern');
715
- return { operator: 'regex', values: [`^${escapeRegExp(String(values[0] ?? ''))}`] };
716
- case 'ends_with':
717
- case 'endsWith':
718
- warnDeprecatedOperator(op, 'regex', 'value rewritten to an anchored …$ pattern');
719
- return { operator: 'regex', values: [`${escapeRegExp(String(values[0] ?? ''))}$`] };
720
- case 'gte':
721
- case 'greater_than_or_equal':
722
- case 'greaterThanOrEqual':
723
- warnDeprecatedOperator(op, 'between', 'rewritten to between [value, Number.MAX_VALUE]');
724
- return { operator: 'between', values: [values[0] ?? null, Number.MAX_VALUE] };
725
- case 'lte':
726
- case 'less_than_or_equal':
727
- case 'lessThanOrEqual':
728
- warnDeprecatedOperator(op, 'between', 'rewritten to between [-Number.MAX_VALUE, value]');
729
- return { operator: 'between', values: [-Number.MAX_VALUE, values[0] ?? null] };
730
- default:
731
- // Unknown operator — pass through; validateRule will flag it.
732
- return { operator: op, values };
733
- }
734
- }
735
- function normalizeConditionInput(input, bi, ci) {
736
- if (!input || typeof input !== 'object') {
737
- throw new Error(`Branch ${bi} condition ${ci} must be an object.`);
738
- }
739
- const c = input;
740
- if (typeof c.attribute !== 'string' || c.attribute.length === 0) {
741
- throw new Error(`Branch ${bi} condition ${ci} missing 'attribute'.`);
742
- }
743
- if (typeof c.operator !== 'string' || c.operator.length === 0) {
744
- throw new Error(`Branch ${bi} condition ${ci} missing 'operator'.`);
745
- }
746
- let values;
747
- if (c.values !== undefined) {
748
- if (!Array.isArray(c.values)) {
749
- throw new Error(`Branch ${bi} condition ${ci} 'values' must be an array.`);
750
- }
751
- values = [...c.values];
752
- }
753
- else if ('value' in c) {
754
- // Legacy singular `value` — migrate to plural `values` at parse time.
755
- const v = c.value;
756
- values = Array.isArray(v) ? [...v] : [v];
757
- }
758
- else {
759
- values = [];
760
- }
761
- const resolved = resolveOperator(c.operator, values);
762
- return { attribute: c.attribute, operator: resolved.operator, values: resolved.values };
763
- }
764
- /**
765
- * Normalize untrusted rule input (from --rule JSON or a flags file) to the
766
- * canonical auth-core `Rule` shape. Throws on structural problems; operator
767
- * validity is left to `validateRule` on the returned rule.
768
- */
769
- export function normalizeRuleInput(input) {
770
- if (!input || typeof input !== 'object') {
771
- throw new Error('Rule must be an object.');
772
- }
773
- const r = input;
774
- if (!('otherwiseValue' in r)) {
775
- throw new Error('Rule missing otherwiseValue.');
776
- }
777
- const rawBranches = r.branches ?? [];
778
- if (!Array.isArray(rawBranches)) {
779
- throw new Error('`branches` must be an array.');
780
- }
781
- const branches = rawBranches.map((branch, bi) => {
782
- if (!branch || typeof branch !== 'object') {
783
- throw new Error(`Branch ${bi} must be an object.`);
784
- }
785
- const b = branch;
786
- if (!('returnValue' in b)) {
787
- throw new Error(`Branch ${bi} is missing returnValue.`);
788
- }
789
- const rawConds = b.conditions ?? [];
790
- if (!Array.isArray(rawConds)) {
791
- throw new Error(`Branch ${bi} 'conditions' must be an array.`);
792
- }
793
- return {
794
- conditions: rawConds.map((c, ci) => normalizeConditionInput(c, bi, ci)),
795
- returnValue: b.returnValue,
45
+ async function ruleOption(opts, onValue, offValue) {
46
+ const given = await ruleFrom(opts);
47
+ if (opts.when !== undefined) {
48
+ if (given !== undefined)
49
+ throw new CliError('invalid', 'Give the rule with --when or with --rule / --rule-file, not both.');
50
+ return whenRule(opts.when, onValue, offValue, opts.rollout);
51
+ }
52
+ if (opts.rollout !== undefined)
53
+ throw new CliError('invalid', '--rollout goes with --when.');
54
+ return given;
55
+ }
56
+ /** The state a rule change leaves the flag in: the rule runs, unless the flag is switched off. */
57
+ function stateWithRule(flag) {
58
+ return flag.state === 'off' ? 'off' : 'on-with-rule';
59
+ }
60
+ function noteIfOff(ctx, flag) {
61
+ if (flag.state === 'off')
62
+ ctx.io.info(`${flag.key} is off, so its rule does not run yet. \`bridge flag restore ${flag.key}\` or \`bridge flag update ${flag.key} --state on-with-rule\` turns it on.`);
63
+ }
64
+ export function registerFlagCommands(program, deps) {
65
+ const flag = program.command('flag').description('Feature flags and their rules: every gate in app code is a flag');
66
+ flag.command('list').description('The app\'s flags').action(action(deps, async (ctx) => {
67
+ const { c, appId } = await ctx.forApp();
68
+ ctx.io.result(await flags.list(c, appId));
69
+ }));
70
+ flag.command('get <key>').description('One flag, with its rule').action(action(deps, async (ctx, key) => {
71
+ const { c, appId } = await ctx.forApp();
72
+ ctx.io.result(await flags.get(c, appId, key));
73
+ }));
74
+ withFlagOptions(flag.command('create <key>').description('Create a flag').option('--type <type>', `The values' type: ${VALUE_TYPES.join(' | ')} (default boolean)`, oneOf(VALUE_TYPES, '--type'))).action(action(deps, async (ctx, key, opts) => {
75
+ const type = opts.type ?? 'boolean';
76
+ const onValue = opts.onValue === undefined ? (type === 'boolean' ? true : undefined) : parseValue(opts.onValue, type);
77
+ const offValue = opts.offValue === undefined ? (type === 'boolean' ? false : undefined) : parseValue(opts.offValue, type);
78
+ if (onValue === undefined || offValue === undefined)
79
+ throw new CliError('invalid', `A ${type} flag needs --on-value and --off-value.`);
80
+ const rule = await ruleOption(opts, onValue, offValue);
81
+ const fields = {
82
+ key,
83
+ valueType: type,
84
+ onValue,
85
+ offValue,
86
+ state: opts.state ?? (rule === undefined ? 'off' : 'on-with-rule'),
87
+ ...(rule === undefined ? {} : { rule }),
88
+ ...(opts.description === undefined ? {} : { description: opts.description }),
89
+ ...(opts.public === undefined ? {} : { public: opts.public }),
796
90
  };
797
- });
798
- const rule = {
799
- branches,
800
- otherwiseValue: r.otherwiseValue,
801
- // Canonical `Rule` carries rolloutPct explicitly; default to 100 (full
802
- // rollout) when the input omits it. Out-of-range values are kept so
803
- // validateRule reports them.
804
- rolloutPct: r.rolloutPct === undefined ? 100 : r.rolloutPct,
805
- };
806
- if (typeof r.groupRef === 'string' && r.groupRef.length > 0) {
807
- rule.groupRef = r.groupRef;
808
- }
809
- return rule;
810
- }
811
- export function parseAttributes(pairs) {
812
- const out = {};
813
- for (const pair of pairs) {
814
- const idx = pair.indexOf('=');
815
- if (idx === -1)
816
- throw new Error(`Invalid --attribute "${pair}" — expected key=value.`);
817
- const key = pair.slice(0, idx);
818
- const raw = pair.slice(idx + 1);
819
- // Try to parse as JSON first (covers numbers, booleans, structured); fall
820
- // back to the raw string.
821
- try {
822
- out[key] = JSON.parse(raw);
823
- }
824
- catch {
825
- out[key] = raw;
826
- }
827
- }
828
- return out;
829
- }
830
- // ── Local evaluator (delegates to auth-core's canonical evaluator) ──────────
831
- export function evaluateLocally(cached, ctx) {
832
- switch (cached.state) {
833
- case 'off':
834
- return {
835
- value: cached.offValue,
836
- variantIndex: -1,
837
- matched: false,
838
- excludedByRollout: false,
91
+ const { c, appId } = await ctx.forApp();
92
+ ctx.io.result(await flags.create(c, appId, fields));
93
+ }));
94
+ withFlagOptions(flag.command('update <key>').description('Change a flag').option('--new-key <key>', 'Rename it').option('--private', 'Only signed-in people may read it')).action(action(deps, async (ctx, key, opts) => {
95
+ const given = await ruleFrom(opts);
96
+ if (opts.when !== undefined && given !== undefined)
97
+ throw new CliError('invalid', 'Give the rule with --when or with --rule / --rule-file, not both.');
98
+ if (opts.rollout !== undefined && opts.when === undefined)
99
+ throw new CliError('invalid', '--rollout goes with --when.');
100
+ const { c, appId } = await ctx.forApp();
101
+ const changed = await flags.update(c, appId, key, (current) => {
102
+ const onValue = opts.onValue === undefined ? current.onValue : parseValue(opts.onValue, current.valueType);
103
+ const offValue = opts.offValue === undefined ? current.offValue : parseValue(opts.offValue, current.valueType);
104
+ const rule = opts.when === undefined ? given : whenRule(opts.when, onValue, offValue, opts.rollout);
105
+ const fields = {
106
+ ...(opts.newKey === undefined ? {} : { key: opts.newKey }),
107
+ ...(opts.description === undefined ? {} : { description: opts.description }),
108
+ ...(opts.onValue === undefined ? {} : { onValue }),
109
+ ...(opts.offValue === undefined ? {} : { offValue }),
110
+ ...(opts.public ? { public: true } : opts.private ? { public: false } : {}),
111
+ ...(rule === undefined ? {} : { rule }),
839
112
  };
840
- case 'on':
841
- return {
842
- value: cached.onValue,
843
- variantIndex: 0,
844
- matched: true,
845
- excludedByRollout: false,
113
+ const state = opts.state ?? (rule === undefined ? undefined : stateWithRule(current));
114
+ if (state !== undefined)
115
+ fields.state = state;
116
+ if (Object.keys(fields).length === 0)
117
+ throw new CliError('invalid', 'Nothing to change.');
118
+ return fields;
119
+ });
120
+ if (given !== undefined || opts.when !== undefined)
121
+ noteIfOff(ctx, changed);
122
+ ctx.io.result(changed);
123
+ }));
124
+ flag.command('on <key>').description('Everyone gets the on value').action(action(deps, async (ctx, key) => {
125
+ const { c, appId } = await ctx.forApp();
126
+ ctx.io.result(await flags.update(c, appId, key, { state: 'on' }));
127
+ }));
128
+ flag.command('off <key>').description('Switch the flag off (the kill switch). Its rule is kept, and `restore` brings it back').action(action(deps, async (ctx, key) => {
129
+ const { c, appId } = await ctx.forApp();
130
+ ctx.io.result(await flags.kill(c, appId, key));
131
+ }));
132
+ flag.command('restore <key>').description('Undo `off`: the flag goes back to what it was').action(action(deps, async (ctx, key) => {
133
+ const { c, appId } = await ctx.forApp();
134
+ ctx.io.result(await flags.restore(c, appId, key));
135
+ }));
136
+ flag
137
+ .command('delete <key>')
138
+ .requiredOption('--yes', 'Confirm')
139
+ .action(action(deps, async (ctx, key) => {
140
+ const { c, appId } = await ctx.forApp();
141
+ ctx.io.result(await flags.remove(c, appId, key));
142
+ }));
143
+ const rule = flag.command('rule').description('A flag\'s rule: ordered branches of AND-ed conditions, the first match wins');
144
+ rule.command('show <key>').action(action(deps, async (ctx, key) => {
145
+ const { c, appId } = await ctx.forApp();
146
+ const found = await flags.get(c, appId, key);
147
+ ctx.io.result({ key: found.key, state: found.state, rule: found.rule });
148
+ }));
149
+ rule
150
+ .command('set <key>')
151
+ .description('Replace the rule, from --rule or --rule-file')
152
+ .option('--rule <json>', 'The rule as JSON')
153
+ .option('--rule-file <file>', 'The rule from a JSON file')
154
+ .action(action(deps, async (ctx, key, opts) => {
155
+ const given = await ruleFrom(opts);
156
+ if (given === undefined)
157
+ throw new CliError('invalid', 'Give the rule with --rule or --rule-file.');
158
+ const { c, appId } = await ctx.forApp();
159
+ const changed = await flags.update(c, appId, key, (current) => ({ rule: given, state: stateWithRule(current) }));
160
+ noteIfOff(ctx, changed);
161
+ ctx.io.result(changed);
162
+ }));
163
+ rule
164
+ .command('add <key>')
165
+ .description('Add a branch to the rule: people matching every --when condition get --value (default: the on value)')
166
+ .requiredOption('--when <condition>', '"<attribute> <operator> <value>", such as "bridge:billing.entitlement.analytics eq true"; repeat to AND them', collect)
167
+ .option('--value <value>', 'What the branch gives')
168
+ .option('--rollout <percent>', 'Only this share of the people it matches', percent)
169
+ .option('--first', 'Put the branch first instead of last')
170
+ .action(action(deps, async (ctx, key, opts) => {
171
+ const conditions = opts.when.map(parseCondition);
172
+ const { c, appId } = await ctx.forApp();
173
+ const changed = await flags.update(c, appId, key, (current) => {
174
+ const branch = {
175
+ conditions,
176
+ returnValue: opts.value === undefined ? current.onValue : parseValue(opts.value, current.valueType),
177
+ ...(opts.rollout === undefined ? {} : { rolloutPct: opts.rollout }),
846
178
  };
847
- case 'on-with-rule':
848
- if (!cached.rule) {
849
- return {
850
- value: cached.onValue,
851
- variantIndex: -1,
852
- matched: false,
853
- excludedByRollout: false,
854
- };
855
- }
856
- return evaluateRule(cached.rule, cached.key, ctx);
857
- }
858
- }
859
- // ── Helpers ─────────────────────────────────────────────────────────────────
860
- /**
861
- * Derive a 2.0 state from a legacy 1.0 flag shape. Used so `flag list` /
862
- * `flag get` always show a state column, even against an older bridge-api
863
- * that has not yet rolled out 2.0 fields.
864
- */
865
- function deriveState(f) {
866
- if (typeof f.state === 'string' && FLAG_STATES.includes(f.state)) {
867
- return f.state;
868
- }
869
- if (f.enabled === false)
870
- return 'off';
871
- const segs = f.segments ?? [];
872
- return segs.length > 0 ? 'on-with-rule' : 'on';
179
+ const existing = current.rule ?? { branches: [], otherwiseValue: current.offValue };
180
+ const branches = opts.first ? [branch, ...existing.branches] : [...existing.branches, branch];
181
+ return { rule: { ...existing, branches }, state: stateWithRule(current) };
182
+ });
183
+ noteIfOff(ctx, changed);
184
+ ctx.io.result(changed);
185
+ }));
186
+ rule.command('clear <key>').description('Remove the rule: while on, everyone gets the on value').action(action(deps, async (ctx, key) => {
187
+ const { c, appId } = await ctx.forApp();
188
+ ctx.io.result(await flags.update(c, appId, key, (current) => ({ rule: null, ...(current.state === 'on-with-rule' ? { state: 'on' } : {}) })));
189
+ }));
873
190
  }
874
191
  //# sourceMappingURL=flag.command.js.map