@nebulr-group/bridge-cli 0.6.6 → 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 -304
  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,764 +1,205 @@
1
- import { readFileSync } from 'node:fs';
2
- import { getManagementClient, getManagementHttp } from '../config.js';
3
- import { dryRunPreview } from '../preview.js';
4
- import { outputSuccess, outputError } from '../output.js';
5
- import { shapePlan } from '../shape.js';
6
- import { DecisionNeededError, decisionField } from '../product-decisions.js';
7
- const QUOTA_POLICIES = ['hard', 'metered'];
8
- const QUOTA_KINDS = ['counter', 'gauge'];
9
- const QUOTA_SOURCES = ['membership'];
10
- /** The kind a quota resolves to — same rule as the server. No metric name is special. */
11
- export function quotaKindOf(quota) {
12
- return quota.kind === 'gauge' ? 'gauge' : 'counter';
13
- }
14
- /**
15
- * TBP-699 — every metric configured on any plan, once each, with its kind and
16
- * the plans that limit it. Pure; mirrors the MCP `list_plan_quotas` tool.
17
- */
18
- export function listMetrics(plans) {
19
- const byMetric = new Map();
20
- for (const plan of plans) {
21
- for (const q of plan.quotas ?? []) {
22
- const kind = quotaKindOf(q);
23
- const source = kind === 'gauge' && q.source === 'membership' ? 'membership' : undefined;
24
- const entry = byMetric.get(q.metric) ?? { metric: q.metric, kind, ...(source ? { source } : {}), plans: [] };
25
- if (entry.kind !== kind)
26
- entry.kind = 'mixed';
27
- else if (entry.source !== source)
28
- entry.source = 'mixed';
29
- entry.plans.push({ planKey: String(plan.key), kind, ...(source ? { source } : {}), limit: q.limit, policy: q.policy });
30
- byMetric.set(q.metric, entry);
31
- }
32
- }
33
- return [...byMetric.values()].sort((a, b) => a.metric.localeCompare(b.metric));
34
- }
35
- export function validateQuotaEntry(entry) {
36
- const metric = (entry.metric ?? '').trim();
37
- if (!metric)
38
- throw new Error('--metric is required and must be non-empty.');
39
- const limit = entry.limit;
40
- if (typeof limit !== 'number' ||
41
- !Number.isFinite(limit) ||
42
- !Number.isInteger(limit) ||
43
- limit < 0) {
44
- throw new Error(`--limit must be an integer >= 0, got "${entry.limit}".`);
45
- }
46
- const policy = entry.policy;
47
- if (!QUOTA_POLICIES.includes(policy)) {
48
- throw new Error(`--policy must be one of ${QUOTA_POLICIES.join(', ')}, got "${entry.policy}".`);
49
- }
50
- // TBP-699 — counter vs gauge. Only sent when given, so an omitted kind
51
- // leaves the server's default/stored kind in charge.
52
- let kind;
53
- if (entry.kind !== undefined) {
54
- if (!QUOTA_KINDS.includes(entry.kind)) {
55
- throw new Error(`--kind must be one of ${QUOTA_KINDS.join(', ')}, got "${entry.kind}".`);
56
- }
57
- kind = entry.kind;
58
- }
59
- // TBP-763 — `membership`: Bridge counts the workspace's active members
60
- // (seats). Only a hard gauge can have it.
61
- let source;
62
- if (entry.source === null || entry.source === 'none') {
63
- source = null;
64
- }
65
- else if (entry.source !== undefined) {
66
- if (!QUOTA_SOURCES.includes(entry.source)) {
67
- throw new Error(`--source must be membership (or none), got "${entry.source}".`);
68
- }
69
- source = entry.source;
70
- if (kind !== 'gauge') {
71
- throw new Error(`"${metric}" has --source membership, which only a gauge can have (Bridge answers it with the workspace's active members). Add --kind gauge, or drop --source for a counter the app reports.`);
72
- }
73
- if (policy === 'metered') {
74
- throw new Error(`"${metric}" has --source membership and cannot be metered. Use --policy hard.`);
75
- }
76
- }
77
- if (policy === 'metered' && kind === 'gauge') {
78
- throw new Error(`"${metric}" is a gauge and a gauge cannot be metered: metered billing charges for usage that happened during a period, which only a counter records. Use --policy hard, or --kind counter.`);
79
- }
80
- const withKind = { ...(kind ? { kind } : {}), ...(source !== undefined ? { source } : {}) };
81
- // TBP-275 — metered quotas carry a per-unit price; hard quotas must not.
82
- if (policy === 'metered') {
83
- const amount = entry.priceAmount;
84
- if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
85
- throw new Error(`--price-amount must be a number > 0 for metered quotas, got "${entry.priceAmount}".`);
86
- }
87
- const currency = (entry.currency ?? '').trim().toUpperCase();
88
- if (!currency) {
89
- throw new Error('A currency is required for metered pricing. The plan has no single price currency to derive from — pass --price-currency.');
90
- }
91
- return { metric, limit, policy, ...withKind, pricing: { amount, currency } };
92
- }
93
- if (entry.priceAmount !== undefined) {
94
- throw new Error('--price-amount is only valid with --policy metered.');
95
- }
96
- return { metric, limit, policy, ...withKind };
97
- }
98
- /** Upsert a quota by metric (replace if present, append otherwise). Pure. */
99
- export function upsertQuota(quotas, entry) {
100
- return [...quotas.filter((q) => q.metric !== entry.metric), entry];
101
- }
102
- /** An Error carrying a machine-readable `code` that outputError reports. */
103
- function codedError(code, message) {
104
- return Object.assign(new Error(message), { code });
105
- }
106
- /** Same pattern the server enforces. */
107
- export const FEATURE_KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
108
- /** Validate one feature key client-side, with a message that says how to fix it. */
109
- export function validateFeatureKey(key) {
110
- const k = (key ?? '').trim();
111
- if (!FEATURE_KEY_PATTERN.test(k)) {
112
- throw codedError('INVALID_FEATURE', `Feature key "${key}" is not valid: use lower-case letters, digits and underscores, starting with a letter (e.g. "analytics", "priority_support").`);
113
- }
114
- return k;
115
- }
116
- /** One feature entry; the display name defaults to the key. */
117
- export function buildFeature(key, name) {
118
- const k = validateFeatureKey(key);
119
- const n = (name ?? '').trim();
120
- return { key: k, name: n || k };
121
- }
122
- /**
123
- * Parse `--features`: comma-separated `key` or `key:Display Name` entries,
124
- * e.g. `analytics,sso:Single sign-on`. A repeated key keeps the last entry.
125
- */
126
- export function parseFeaturesOption(raw) {
127
- let features = [];
128
- for (const part of raw.split(',')) {
129
- const entry = part.trim();
130
- if (!entry)
131
- continue;
132
- const i = entry.indexOf(':');
133
- const feature = i === -1 ? buildFeature(entry) : buildFeature(entry.slice(0, i), entry.slice(i + 1));
134
- features = upsertFeature(features, feature);
135
- }
136
- return features;
137
- }
138
- /** Upsert a feature by key (replace in place if present, append otherwise). Pure. */
139
- export function upsertFeature(features, entry) {
140
- const i = features.findIndex((f) => f.key === entry.key);
141
- if (i === -1)
142
- return [...features, entry];
143
- const next = [...features];
144
- next[i] = entry;
145
- return next;
146
- }
147
- /** Remove a feature by key; throws naming the existing keys when absent. Pure. */
148
- export function removeFeature(features, key, planKey) {
149
- const next = features.filter((f) => f.key !== key);
150
- if (next.length === features.length) {
151
- throw codedError('FEATURE_NOT_FOUND', `No feature "${key}" on plan "${planKey}". Existing features: ${features.length ? features.map((f) => f.key).join(', ') : '(none)'}.`);
152
- }
153
- return next;
154
- }
155
- /** A plan's features, read defensively (older servers omit the field). */
156
- export function featuresOf(plan) {
157
- const raw = plan?.features;
158
- if (!Array.isArray(raw))
159
- return [];
160
- return raw
161
- .filter((f) => !!f && typeof f.key === 'string')
162
- .map((f) => ({ key: f.key, name: typeof f.name === 'string' && f.name ? f.name : f.key }));
163
- }
164
- /** A plan as printed: always carries `features` (empty when it sells none). */
165
- function withFeatures(plan) {
166
- if (!plan || typeof plan !== 'object')
167
- return plan;
168
- return { ...plan, features: featuresOf(plan) };
169
- }
170
- const VALID_INTERVALS = [
171
- 'day',
172
- 'week',
173
- 'month',
174
- 'year',
175
- ];
176
- /**
177
- * Validate + normalise one price entry from CLI flags. A plan can carry several
178
- * prices (e.g. monthly + yearly); each is set independently and identified by
179
- * its currency + interval. `--interval` is always required.
180
- */
181
- export function buildPriceEntry(opts) {
182
- const amount = opts.amount;
183
- if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
184
- throw new Error(`--amount must be a number >= 0, got "${opts.amount}".`);
185
- }
186
- const interval = (opts.interval ?? '');
187
- if (!VALID_INTERVALS.includes(interval)) {
188
- throw new Error(`--interval is required and must be one of ${VALID_INTERVALS.join(', ')}, got "${opts.interval}".`);
189
- }
190
- const currency = (opts.currency ?? 'USD').trim().toUpperCase();
191
- if (!currency)
192
- throw new Error('--currency must be non-empty.');
193
- return { amount, currency, recurrenceInterval: interval };
194
- }
195
- /**
196
- * TBP-617 — build the price list for `plan create`.
197
- *
198
- * The server rejects an explicitly empty `prices` array (a plan with no prices
199
- * can never be assigned to a workspace), so `plan create` must never send one.
200
- * Mirrors the MCP `create_plan` tool, which takes one flat price up front as
201
- * `amount` / `interval` / `currency`; further prices go through `plan price set`.
202
- *
203
- * Fails client-side when no price flags were passed, so the user gets the flags
204
- * that fix it instead of a `BAD_USER_INPUT` from the API.
205
- */
206
- export function buildCreatePlanPrices(opts) {
207
- if (opts.amount === undefined && opts.interval === undefined) {
208
- throw new Error('A plan needs at least one price. Pass --amount and --interval ' +
209
- `(one of ${VALID_INTERVALS.join(', ')}), e.g. --amount 29 --interval month. ` +
210
- 'Use --amount 0 for a free or contact-sales tier; --currency defaults to USD.');
211
- }
212
- return [buildPriceEntry(opts)];
213
- }
214
- /**
215
- * TBP-617 — remove one price by currency + interval.
216
- *
217
- * Throws when the price is absent, and when it is the plan's LAST price: the
218
- * server rejects an empty price list with a 400 the user cannot act on, so say
219
- * why here. Mirrors the MCP `remove_plan_price` `LAST_PRICE` guard. Pure.
220
- */
221
- export function removePrice(prices, entry, planKey) {
222
- const currency = entry.currency.trim().toUpperCase();
223
- const next = prices.filter((p) => !(p.currency === currency && p.recurrenceInterval === entry.interval));
224
- if (next.length === prices.length) {
225
- throw new Error(`No ${currency} ${entry.interval} price on plan "${planKey}".`);
226
- }
227
- if (next.length === 0) {
228
- throw new Error(`Cannot remove the only price on plan "${planKey}": a plan needs at least one price. ` +
229
- `Add the replacement first with \`bridge plan price set ${planKey} --amount <n> --interval <interval>\`, ` +
230
- 'or set this price to --amount 0 for a free tier.');
231
- }
232
- return next;
233
- }
234
- /** Upsert a price by (currency + interval): replace if present, append otherwise. Pure. */
235
- export function upsertPrice(prices, entry) {
236
- return [
237
- ...prices.filter((p) => !(p.currency === entry.currency &&
238
- p.recurrenceInterval === entry.recurrenceInterval)),
239
- entry,
240
- ];
241
- }
242
- // ── Product decisions (TBP-713) ──────────────────────────────────────────────
243
- // Mirrors bridge-api's create_plan / set_plan_price / apply_plan: a price,
244
- // interval, currency or trial the developer has not decided stops the command
245
- // before anything is written, naming the same fields as the MCP tool.
246
- /** The distinct currencies of a plan's prices, upper-cased. */
247
- function planCurrencies(prices) {
248
- return [...new Set(prices.map((p) => p.currency?.trim().toUpperCase()).filter((c) => !!c))];
249
- }
250
- /**
251
- * The currency for a price given without one: the plan's one currency when it
252
- * has exactly one; USD for a free (0) price when it has none; otherwise
253
- * undefined — the developer decides. Same rule as the MCP tools. Pure.
254
- */
255
- export function resolvePriceCurrency(given, amount, others) {
256
- if (given !== undefined)
257
- return given.trim().toUpperCase();
258
- const known = planCurrencies(others);
259
- if (known.length === 1)
260
- return known[0];
261
- return amount === 0 && known.length === 0 ? 'USD' : undefined;
262
- }
263
- /** The decisions `plan create` is missing, in the MCP tool's order. Pure. */
264
- export function createPlanDecisions(opts) {
265
- const missing = [];
266
- const trial = opts.trial ?? (opts.trialDays !== undefined ? true : undefined);
267
- const paidOrUnknown = opts.amount === undefined || opts.amount > 0;
268
- if (opts.amount === undefined)
269
- missing.push(decisionField('create_plan', 'amount', '--amount <n>'));
270
- if (opts.interval === undefined)
271
- missing.push(decisionField('create_plan', 'interval', '--interval <interval>'));
272
- if (paidOrUnknown && opts.currency === undefined)
273
- missing.push(decisionField('create_plan', 'currency', '--currency <code>'));
274
- if (paidOrUnknown && trial === undefined)
275
- missing.push(decisionField('create_plan', 'trial', '--trial | --no-trial'));
276
- if (trial === true && opts.trialDays === undefined)
277
- missing.push(decisionField('create_plan', 'trialDays', '--trial-days <n>'));
278
- return missing;
279
- }
280
- /** Fetch a plan + its quotas/prices (read defensively — list() may omit them). */
281
- async function getPlan(key) {
282
- const plans = (await getManagementClient().plans.list());
283
- const plan = plans.find((p) => p.key === key);
284
- if (!plan)
285
- throw new Error(`Plan not found: ${key}`);
286
- const quotas = (plan.quotas ?? []);
287
- const prices = (plan.prices ?? []);
288
- return { plan, quotas, prices, features: featuresOf(plan) };
289
- }
290
- export function registerPlanCommands(program) {
291
- const plan = program.command('plan').description('Manage subscription plans');
292
- plan.command('list')
293
- .description('List all subscription plans')
294
- .action(async () => {
295
- try {
296
- outputSuccess((await getManagementClient().plans.list()).map((p) => withFeatures(shapePlan(p))));
297
- }
298
- catch (err) {
299
- outputError(err);
300
- }
301
- });
302
- plan.command('get')
303
- .description('Get a plan by key (includes usage quotas and included features)')
304
- .argument('<key>', 'Plan key')
305
- .action(async (key) => {
306
- try {
307
- const { plan: found, quotas, features } = await getPlan(key);
308
- outputSuccess(shapePlan({ ...found, quotas, features }));
309
- }
310
- catch (err) {
311
- outputError(err);
312
- }
313
- });
314
- plan.command('create')
315
- .description('Create a new plan with its first price (add more with `plan price set`)')
316
- .requiredOption('--key <key>', 'Plan key')
317
- .requiredOption('--name <name>', 'Plan name')
318
- .option('--amount <amount>', 'First price amount (>= 0). Use 0 for a free or contact-sales tier', parseFloat)
319
- .option('--interval <interval>', `Billing interval for the first price: ${VALID_INTERVALS.join(' | ')}`)
320
- .option('--currency <currency>', 'Currency for the first price (ISO code). Required when --amount is above 0')
321
- .option('--description <desc>', 'Description')
322
- .option('--trial', 'The plan has a free trial (with --trial-days)')
323
- .option('--no-trial', 'The plan has no free trial. One of --trial / --no-trial is required when --amount is above 0')
324
- .option('--trial-days <days>', 'Trial period in days', parseInt)
325
- .option('--features <list>', 'On/off features the plan includes, comma-separated `key` or `key:Display Name`, e.g. "analytics,sso:Single sign-on". ' +
326
- 'Each is `bridge:billing.entitlement.<key>` in flag rules')
327
- .action(async (opts) => {
328
- try {
329
- // TBP-755 — validate before anything else so a bad key never half-creates a plan.
330
- const features = opts.features !== undefined ? parseFeaturesOption(String(opts.features)) : undefined;
331
- // TBP-713 — price, interval, currency and trial are the developer's
332
- // decisions: stop and name them instead of defaulting.
333
- const missing = createPlanDecisions(opts);
334
- if (missing.length) {
335
- throw new DecisionNeededError('bridge plan create', missing, opts.amount === undefined ? 'currency and trial are not needed if the price is 0.' : undefined);
336
- }
337
- // TBP-617 — the server rejects an explicitly empty price list, so build
338
- // (and validate) the first price here rather than sending `prices: []`.
339
- const prices = buildCreatePlanPrices({
340
- amount: opts.amount,
341
- interval: opts.interval,
342
- currency: opts.currency,
343
- });
344
- const trial = opts.trial ?? (opts.trialDays !== undefined ? true : false);
345
- outputSuccess(shapePlan(await getManagementClient().plans.create({
346
- key: opts.key,
347
- name: opts.name,
348
- description: opts.description,
349
- trial,
350
- trialDays: opts.trialDays,
351
- prices,
352
- ...(features && features.length ? { features } : {}),
353
- })));
354
- }
355
- catch (err) {
356
- outputError(err);
357
- }
358
- });
359
- plan.command('update')
360
- .description('Update a plan')
361
- .requiredOption('--key <key>', 'Plan key')
362
- .option('--name <name>', 'Plan name')
363
- .option('--description <desc>', 'Description')
364
- .action(async (opts) => {
365
- try {
366
- const { key, ...data } = opts;
367
- const cleaned = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
368
- outputSuccess(shapePlan(await getManagementClient().plans.update(key, cleaned)));
369
- }
370
- catch (err) {
371
- outputError(err);
372
- }
373
- });
374
- // TBP-806 — only a plan no workspace is on is deleted. One that workspaces
375
- // are on is refused (409 PLAN_IN_USE) with what to do instead: archive it,
376
- // or move (`tenant set-plan`) or delete those workspaces first.
377
- plan.command('delete')
378
- .description('Delete a plan no workspace is on (refused, with the choices, when workspaces are on it)')
379
- .requiredOption('--key <key>', 'Plan key')
380
- .option('--dry-run', 'Show whether the delete would go through, and which workspaces are on the plan, and change nothing')
381
- .action(async (opts) => {
382
- try {
383
- const path = `/v1/account/payments/plan/${encodeURIComponent(opts.key)}`;
384
- if (opts.dryRun) {
385
- outputSuccess(await dryRunPreview(path));
386
- return;
387
- }
388
- outputSuccess(await getManagementHttp().delete(path));
389
- }
390
- catch (err) {
391
- outputError(err);
392
- }
393
- });
394
- plan.command('archive')
395
- .description('Archive a plan: workspaces on it keep it, nobody new can choose it (--undo brings it back)')
396
- .requiredOption('--key <key>', 'Plan key')
397
- .option('--undo', 'Bring an archived plan back')
398
- .action(async (opts) => {
399
- try {
400
- outputSuccess(await getManagementHttp().post(`/v1/account/payments/plan/${encodeURIComponent(opts.key)}/archive`, { archived: !opts.undo }));
401
- }
402
- catch (err) {
403
- outputError(err);
404
- }
405
- });
406
- plan.command('apply')
407
- .description('Set up or reshape one plan in a single write: name, trial, prices and quotas together (creates it when the key is new)')
408
- .requiredOption('--spec <json>', 'The plan as JSON, or @file.json: { key, name?, description?, trial?, trialDays?, ' +
409
- 'prices?: [{ amount, interval, currency? }], quotas?: [{ metric, limit, policy, kind?, source?, priceAmount?, priceCurrency? }], ' +
410
- 'features?: [{ key, name? }], removePrices?: [{ interval, currency? }], removeQuotas?: [metric], removeFeatures?: [key] }. ' +
411
- 'Same shape as the MCP apply_plan tool. Prices, quotas and features are upserted; anything not mentioned is kept')
412
- .action(async (opts) => {
413
- try {
414
- const spec = parsePlanSpec(opts.spec);
415
- const plans = (await getManagementClient().plans.list());
416
- const write = planApplyWrite(spec, plans);
417
- const result = write.created
418
- ? await getManagementClient().plans.create(write.body)
419
- : await getManagementClient().plans.update(spec.key, write.body);
420
- outputSuccess({ created: write.created, plan: shapePlan(result) });
421
- }
422
- catch (err) {
423
- outputError(err);
424
- }
425
- });
426
- registerPriceCommands(plan);
427
- registerQuotaCommands(plan);
428
- registerFeatureCommands(plan);
429
- }
430
- /** An error with a machine-readable code, reported by outputError. */
431
- export class PlanApplyError extends Error {
432
- code;
433
- constructor(code, message) {
434
- super(message);
435
- this.code = code;
436
- this.name = 'PlanApplyError';
437
- }
438
- }
439
- /** Read `--spec` as inline JSON or `@path`. */
440
- export function parsePlanSpec(raw) {
441
- const text = raw.startsWith('@') ? readFileSync(raw.slice(1), 'utf8') : raw;
442
- let spec;
443
- try {
444
- spec = JSON.parse(text);
445
- }
446
- catch (err) {
447
- throw new PlanApplyError('INVALID_SPEC', `--spec is not valid JSON: ${err.message}`);
448
- }
449
- if (!spec || typeof spec !== 'object' || typeof spec.key !== 'string' || !spec.key) {
450
- throw new PlanApplyError('INVALID_SPEC', '--spec needs a "key", e.g. {"key":"pro","name":"Pro","prices":[{"amount":29,"interval":"month"}]}.');
451
- }
452
- return spec;
453
- }
454
- /** The one write `plan apply` makes: a create body or an update body. Pure. */
455
- export function planApplyWrite(spec, plans) {
456
- const key = spec.key;
457
- const current = plans.find((p) => p.key === key);
458
- const isNew = !current;
459
- const before = isNew ? [] : [...((current.prices ?? []))];
460
- let prices = [...before];
461
- const planCurrency = planCurrencies(before).length === 1 ? planCurrencies(before)[0] : 'USD';
462
- for (const r of spec.removePrices ?? []) {
463
- const currency = (r.currency ?? planCurrency).trim().toUpperCase();
464
- const slot = (p) => p.currency === currency && p.recurrenceInterval === r.interval;
465
- if (!prices.some(slot)) {
466
- throw new PlanApplyError('PRICE_NOT_FOUND', `No ${currency} ${r.interval} price on plan "${key}". Nothing was changed.`);
467
- }
468
- prices = prices.filter((p) => !slot(p));
469
- }
470
- // TBP-713 — the product decisions, gathered and reported all at once before
471
- // anything is written; same fields, same order as the MCP apply_plan tool.
472
- const missing = [];
473
- const inputs = spec.prices ?? [];
474
- const settled = [...before, ...inputs.filter((p) => p.currency !== undefined)];
475
- inputs.forEach((p, i) => {
476
- const currency = resolvePriceCurrency(p.currency, p.amount, settled);
477
- if (!currency) {
478
- missing.push(decisionField('apply_plan', 'prices.currency', `"prices[${i}].currency"`, `prices[${i}].currency`));
479
- return;
480
- }
481
- prices = upsertPrice(prices, buildPriceEntry({ amount: p.amount, interval: p.interval, currency }));
482
- });
483
- if (prices.length === 0 && !isNew && missing.length === 0) {
484
- throw new PlanApplyError('LAST_PRICE', `This would remove every price from plan "${key}": a plan needs at least one price. Nothing was changed.`);
485
- }
486
- if (isNew && spec.name === undefined)
487
- missing.push(decisionField('apply_plan', 'name', '"name"'));
488
- if (isNew && inputs.length === 0) {
489
- missing.push(decisionField('apply_plan', 'prices.amount', '"prices[0].amount"', 'prices[0].amount'), decisionField('apply_plan', 'prices.interval', '"prices[0].interval"', 'prices[0].interval'), decisionField('apply_plan', 'prices.currency', '"prices[0].currency"', 'prices[0].currency'));
490
- }
491
- const trial = spec.trial ?? (spec.trialDays !== undefined ? true : undefined);
492
- const paid = inputs.length === 0 || inputs.some((p) => p.amount > 0);
493
- if (isNew && paid && trial === undefined)
494
- missing.push(decisionField('apply_plan', 'trial', '"trial"'));
495
- const currentTrialDays = current?.trialDays;
496
- if (trial === true && spec.trialDays === undefined && !currentTrialDays) {
497
- missing.push(decisionField('apply_plan', 'trialDays', '"trialDays"'));
498
- }
499
- if (missing.length) {
500
- throw new DecisionNeededError('bridge plan apply', missing, isNew ? `Plan "${key}" does not exist yet, so this call would create it.` : undefined);
501
- }
502
- let quotas = isNew ? [] : [...((current.quotas ?? []))];
503
- for (const metric of spec.removeQuotas ?? []) {
504
- if (!quotas.some((q) => q.metric === metric)) {
505
- throw new PlanApplyError('QUOTA_NOT_FOUND', `No quota for metric "${metric}" on plan "${key}". Nothing was changed.`);
506
- }
507
- quotas = quotas.filter((q) => q.metric !== metric);
508
- }
509
- const currencies = [...new Set(prices.map((p) => p.currency.toUpperCase()))];
510
- for (const q of spec.quotas ?? []) {
511
- const existing = quotas.find((e) => e.metric === String(q.metric ?? '').trim());
512
- const kind = q.kind ?? existing?.kind;
513
- let entry;
514
- try {
515
- entry = validateQuotaEntry({
516
- metric: q.metric,
517
- limit: q.limit,
518
- policy: q.policy,
519
- kind,
520
- source: q.source !== undefined ? q.source : kind === 'gauge' ? existing?.source : undefined,
521
- priceAmount: q.priceAmount,
522
- currency: q.priceCurrency ?? (currencies.length === 1 ? currencies[0] : undefined),
523
- });
524
- }
525
- catch (err) {
526
- throw new PlanApplyError('INVALID_QUOTA', err.message);
527
- }
528
- quotas = upsertQuota(quotas, entry);
529
- }
530
- // TBP-755 — features: current, minus removals, with upserts by key.
531
- let features = isNew ? [] : featuresOf(current);
532
- for (const fk of spec.removeFeatures ?? []) {
533
- if (!features.some((f) => f.key === fk)) {
534
- throw new PlanApplyError('FEATURE_NOT_FOUND', `No feature "${fk}" on plan "${key}". Nothing was changed.`);
535
- }
536
- features = features.filter((f) => f.key !== fk);
537
- }
538
- for (const f of spec.features ?? []) {
539
- let entry;
540
- try {
541
- entry = buildFeature(String(f?.key ?? ''), f?.name);
542
- }
543
- catch (err) {
544
- throw new PlanApplyError('INVALID_FEATURE', err.message);
545
- }
546
- features = upsertFeature(features, entry);
547
- }
548
- const featuresTouched = spec.features !== undefined || spec.removeFeatures !== undefined;
1
+ import { readFile } from 'node:fs/promises';
2
+ import { InvalidArgumentError } from 'commander';
3
+ import { plans } from '../api/billing.js';
4
+ import { CliError } from '../core/errors.js';
5
+ import { attachUsage, parseCurrencyInterval, parseFeature, parseLimit, parsePrice, parseUsagePrice } from '../money.js';
6
+ import { action, collect } from './context.js';
7
+ const PRICE_HELP = 'A price, "<amount> <CURRENCY>/<interval>" such as "19.99 EUR/month"; repeat for several';
8
+ const USAGE_HELP = 'A per-unit usage price, "<metric> <amount per unit> <CURRENCY>/<interval> [included <n>]" such as "api.calls 0.002 EUR/month included 10000"; repeat for several';
9
+ function trialDays(value) {
10
+ const n = Number(value);
11
+ if (!Number.isInteger(n) || n < 1 || n > 730)
12
+ throw new InvalidArgumentError('A whole number of days from 1 to 730.');
13
+ return n;
14
+ }
15
+ /** The plan fields the options give. Prices, features and limits are whole lists: given, they replace the plan's. */
16
+ function planFields(opts) {
549
17
  const fields = {};
550
- for (const f of ['name', 'description', 'trialDays']) {
551
- if (spec[f] !== undefined)
552
- fields[f] = spec[f];
553
- }
554
- if (trial !== undefined)
555
- fields.trial = trial;
556
- if (isNew) {
557
- return {
558
- created: true,
559
- body: {
560
- key,
561
- trial: false,
562
- ...fields,
563
- prices,
564
- ...(quotas.length ? { quotas } : {}),
565
- ...(features.length ? { features } : {}),
566
- },
567
- };
568
- }
569
- return {
570
- created: false,
571
- body: {
572
- ...fields,
573
- ...(spec.prices !== undefined || spec.removePrices !== undefined ? { prices } : {}),
574
- ...(spec.quotas !== undefined || spec.removeQuotas !== undefined ? { quotas } : {}),
575
- ...(featuresTouched ? { features } : {}),
576
- },
577
- };
578
- }
579
- // ── plan price (recurring prices) ────────────────────────────────────────────
580
- function registerPriceCommands(plan) {
581
- const price = plan
582
- .command('price')
583
- .description("Manage a plan's recurring prices (one per currency + interval)");
584
- price.command('set')
585
- .description('Add or update a price on a plan (idempotent by currency + interval)')
586
- .argument('<key>', 'Plan key')
587
- .requiredOption('--amount <amount>', 'Price amount (>= 0)', parseFloat)
588
- .requiredOption('--interval <interval>', `Billing interval: ${VALID_INTERVALS.join(' | ')}`)
589
- .option('--currency <currency>', "Currency (ISO code). Omit only to reuse the plan's one existing price currency")
590
- .action(async (key, opts) => {
591
- try {
592
- const { prices } = await getPlan(key);
593
- // TBP-713 — never price in a currency the developer did not choose.
594
- const currency = resolvePriceCurrency(opts.currency, opts.amount, prices);
595
- if (!currency) {
596
- throw new DecisionNeededError('bridge plan price set', [decisionField('set_plan_price', 'currency', '--currency <code>')], `Plan "${key}" has prices in ${planCurrencies(prices).join(', ') || 'no currency yet'}, so there is none to reuse.`);
597
- }
598
- const entry = buildPriceEntry({
599
- amount: opts.amount,
600
- interval: opts.interval,
601
- currency,
18
+ if (opts.name !== undefined)
19
+ fields.name = opts.name;
20
+ if (opts.description !== undefined)
21
+ fields.description = opts.description;
22
+ if (opts.price !== undefined)
23
+ fields.prices = attachUsage(opts.price.map(parsePrice), (opts.usagePrice ?? []).map(parseUsagePrice));
24
+ else if (opts.usagePrice !== undefined)
25
+ throw new CliError('invalid', 'A usage price goes on a price: give the plan\'s prices with --price too, or use `bridge plan usage-price set`.');
26
+ if (opts.feature !== undefined)
27
+ fields.features = opts.feature.map(parseFeature);
28
+ if (opts.limit !== undefined)
29
+ fields.limits = opts.limit.map(parseLimit);
30
+ if (opts.trial === false)
31
+ fields.trial = null;
32
+ else if (opts.trialDays !== undefined)
33
+ fields.trial = { days: opts.trialDays, cardRequired: opts.cardRequired ?? false };
34
+ else if (opts.cardRequired !== undefined)
35
+ throw new CliError('invalid', '--card-required goes with --trial-days.');
36
+ return fields;
37
+ }
38
+ const withDocumentOptions = (cmd) => cmd
39
+ .option('--description <text>', 'What the plan is for')
40
+ .option('--price <price>', PRICE_HELP, collect)
41
+ .option('--usage-price <usage>', USAGE_HELP, collect)
42
+ .option('--feature <feature>', 'A feature the plan includes, "<key>" or "<key>=<name>"; repeat for several', collect)
43
+ .option('--limit <limit>', 'A limit, "<metric>=<n>" or "<metric>=unlimited"; repeat for several', collect)
44
+ .option('--trial-days <days>', 'A free trial of this many days', trialDays)
45
+ .option('--card-required', 'The trial asks for a card up front');
46
+ /** Reads the plan, changes it, writes the changed fields back. */
47
+ async function change(c, appId, key, fn) {
48
+ const plan = await plans.get(c, appId, key);
49
+ return plans.update(c, appId, key, fn(plan));
50
+ }
51
+ const priceId = (price) => `${price.currency}/${price.interval ?? 'once'}`;
52
+ export function registerPlanCommands(program, deps) {
53
+ const plan = program.command('plan').description('Plans: their prices, trials, usage prices, features and limits');
54
+ plan
55
+ .command('list')
56
+ .description('The app\'s plans')
57
+ .option('--archived', 'Include archived plans')
58
+ .action(action(deps, async (ctx, opts) => {
59
+ const { c, appId } = await ctx.forApp();
60
+ ctx.io.result(await plans.list(c, appId, opts.archived === true));
61
+ }));
62
+ plan
63
+ .command('get <key>')
64
+ .description('One plan')
65
+ .action(action(deps, async (ctx, key) => {
66
+ const { c, appId } = await ctx.forApp();
67
+ ctx.io.result(await plans.get(c, appId, key));
68
+ }));
69
+ withDocumentOptions(plan.command('create <key>').description('Create a plan').requiredOption('--name <name>', 'The plan\'s name')).action(action(deps, async (ctx, key, opts) => {
70
+ const fields = planFields(opts);
71
+ const { c, appId } = await ctx.forApp();
72
+ ctx.io.result(await plans.create(c, appId, { ...fields, key, name: opts.name }));
73
+ }));
74
+ withDocumentOptions(plan
75
+ .command('update <key>')
76
+ .description('Change a plan. Lists given (--price, --feature, --limit) replace the plan\'s; the sub-commands change one entry')
77
+ .option('--name <name>', 'The plan\'s name')
78
+ .option('--no-trial', 'Remove the trial')).action(action(deps, async (ctx, key, opts) => {
79
+ const fields = planFields(opts);
80
+ if (Object.keys(fields).length === 0)
81
+ throw new CliError('invalid', 'Nothing to change.');
82
+ const { c, appId } = await ctx.forApp();
83
+ ctx.io.result(await plans.update(c, appId, key, fields));
84
+ }));
85
+ plan
86
+ .command('apply <file>')
87
+ .description('Create or replace a plan from a JSON file: { "key", "name", "prices", "features", "limits", "trial" }')
88
+ .action(action(deps, async (ctx, file) => {
89
+ let document;
90
+ try {
91
+ document = JSON.parse(await readFile(file, 'utf8'));
92
+ }
93
+ catch (error) {
94
+ throw new CliError('invalid', `Could not read ${file} as JSON: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ if (typeof document.key !== 'string' || document.key === '')
97
+ throw new CliError('invalid', `${file} names no plan "key".`);
98
+ const { key, ...rest } = document;
99
+ const { c, appId } = await ctx.forApp();
100
+ ctx.io.result(await plans.replace(c, appId, key, rest));
101
+ }));
102
+ plan
103
+ .command('archive <key>')
104
+ .description('Stop offering a plan: workspaces on it keep it, nobody new can choose it')
105
+ .option('--undo', 'Offer it again')
106
+ .action(action(deps, async (ctx, key, opts) => {
107
+ const { c, appId } = await ctx.forApp();
108
+ ctx.io.result(await plans.archive(c, appId, key, opts.undo !== true));
109
+ }));
110
+ plan
111
+ .command('delete <key>')
112
+ .description('Delete a plan no workspace is on')
113
+ .option('--dry-run', 'Only say whether it can be deleted')
114
+ .action(action(deps, async (ctx, key, opts) => {
115
+ const { c, appId } = await ctx.forApp();
116
+ const preview = await plans.deletionPreview(c, appId, key);
117
+ if (opts.dryRun)
118
+ return ctx.io.result(preview);
119
+ if (!preview.deletable) {
120
+ throw new CliError('conflict', `${String(preview.holders)} workspace(s) are on ${key}, so it cannot be deleted.`, {
121
+ hint: `Archive it instead: \`bridge plan archive ${key}\`.`,
602
122
  });
603
- const next = upsertPrice(prices, entry);
604
- outputSuccess(shapePlan(await getManagementClient().plans.update(key, { prices: next })));
605
- }
606
- catch (err) {
607
- outputError(err);
608
- }
609
- });
610
- price.command('rm')
611
- .description('Remove a price from a plan by interval (+ currency)')
612
- .argument('<key>', 'Plan key')
613
- .requiredOption('--interval <interval>', `Billing interval: ${VALID_INTERVALS.join(' | ')}`)
614
- .option('--currency <currency>', 'Currency (default USD)', 'USD')
615
- .action(async (key, opts) => {
616
- try {
617
- const { prices } = await getPlan(key);
618
- // TBP-617 — removing the last price empties the list, which the server
619
- // rejects with a 400; removePrice says why before we get there.
620
- const next = removePrice(prices, {
621
- currency: String(opts.currency ?? 'USD'),
622
- interval: opts.interval,
623
- }, key);
624
- outputSuccess(shapePlan(await getManagementClient().plans.update(key, { prices: next })));
625
- }
626
- catch (err) {
627
- outputError(err);
628
- }
629
- });
630
- }
631
- // ── plan quota (usage caps) ──────────────────────────────────────────────────
632
- function registerQuotaCommands(plan) {
633
- const quota = plan
634
- .command('quota')
635
- .description('Manage a plan\'s usage quotas (hard | metered caps, counter | gauge kinds)');
636
- quota.command('list')
637
- .description('List the usage quotas on a plan, or with no key every metric across all plans with its kind')
638
- .argument('[key]', 'Plan key (omit to list every metric name and kind across all plans)')
639
- .action(async (key) => {
640
- try {
641
- if (key === undefined) {
642
- const plans = (await getManagementClient().plans.list());
643
- outputSuccess(listMetrics(plans));
644
- return;
645
- }
646
- const { quotas } = await getPlan(key);
647
- outputSuccess((shapePlan({ quotas }).quotas ?? []).map((q) => ({ ...q, kind: quotaKindOf(q) })));
648
- }
649
- catch (err) {
650
- outputError(err);
651
- }
652
- });
653
- quota.command('set')
654
- .description('Add or update a usage quota on a plan')
655
- .argument('<key>', 'Plan key')
656
- .requiredOption('--metric <metric>', 'Metric key (e.g. num.clicks)')
657
- .requiredOption('--limit <n>', 'Limit (integer >= 0). 0 = pure per-unit metered (billed from unit 1)', parseInt)
658
- .requiredOption('--policy <policy>', `Cap policy: ${QUOTA_POLICIES.join(' | ')}`)
659
- .option('--kind <kind>', `${QUOTA_KINDS.join(' | ')}. counter (default) = summed per billing period; gauge = absolute value your app sets, never reset. ` +
660
- "If deleting it frees room, it's a gauge and your app counts it; if it happened, it's a counter and Bridge counts it. " +
661
- 'Omit to keep an existing quota\'s kind')
662
- .option('--source <source>', "membership = Bridge counts the workspace's active members, pending invites included (needs --kind gauge, --policy hard). " +
663
- 'Seats: name the limit (e.g. seats), --kind gauge --source membership; enforce it where invites happen ' +
664
- '(the team page seat-limit setting, or @RequireQuota / bridge.requireQuota on your own invite handler), never with a flag or an entitlement. ' +
665
- 'Omit to keep an existing gauge\'s source; none removes it')
666
- .option('--price-amount <n>', 'Per-unit price for metered quotas (required with --policy metered)', parseFloat)
667
- .option('--price-currency <currency>', 'Currency for the metered price (defaults to the plan\'s price currency when unambiguous)')
668
- .action(async (key, opts) => {
669
- try {
670
- const { quotas, prices } = await getPlan(key);
671
- // Derive the metered currency from the plan's prices when not given and
672
- // unambiguous; the backend enforces currency == a plan price currency.
673
- const planCurrencies = [
674
- ...new Set(prices.map((p) => p.currency?.toUpperCase()).filter(Boolean)),
675
- ];
676
- const currency = opts.priceCurrency?.toUpperCase() ??
677
- (planCurrencies.length === 1 ? planCurrencies[0] : undefined);
678
- // TBP-699 — an omitted --kind keeps the existing quota's kind.
679
- const existing = quotas.find((q) => q.metric === String(opts.metric ?? '').trim());
680
- const kind = opts.kind ?? existing?.kind;
681
- // TBP-763 — an omitted --source keeps an existing gauge's source.
682
- const entry = validateQuotaEntry({
683
- metric: opts.metric,
684
- limit: opts.limit,
685
- policy: opts.policy,
686
- kind,
687
- source: opts.source ?? (kind === 'gauge' ? existing?.source : undefined),
688
- priceAmount: opts.priceAmount,
689
- currency,
690
- });
691
- const next = upsertQuota(quotas, entry);
692
- outputSuccess(shapePlan(await getManagementClient().plans.update(key, { quotas: next })));
693
- }
694
- catch (err) {
695
- outputError(err);
696
- }
697
- });
698
- quota.command('rm')
699
- .description('Remove a usage quota from a plan')
700
- .argument('<key>', 'Plan key')
701
- .requiredOption('--metric <metric>', 'Metric key to remove')
702
- .action(async (key, opts) => {
703
- try {
704
- const { quotas } = await getPlan(key);
705
- const next = quotas.filter((q) => q.metric !== opts.metric);
706
- if (next.length === quotas.length) {
707
- throw new Error(`No quota for metric "${opts.metric}" on plan "${key}".`);
708
- }
709
- outputSuccess(shapePlan(await getManagementClient().plans.update(key, { quotas: next })));
710
- }
711
- catch (err) {
712
- outputError(err);
713
- }
714
- });
715
- }
716
- // ── plan feature (included on/off features, TBP-755) ─────────────────────────
717
- function registerFeatureCommands(plan) {
718
- const feature = plan
719
- .command('feature')
720
- .description('Manage the on/off features a plan includes. Each is `bridge:billing.entitlement.<key>` in flag rules: ' +
721
- 'gate a feature with the rule `bridge:billing.entitlement.<key> eq true`');
722
- feature.command('list')
723
- .description('List the features a plan includes')
724
- .argument('<planKey>', 'Plan key')
725
- .action(async (planKey) => {
726
- try {
727
- outputSuccess((await getPlan(planKey)).features);
728
- }
729
- catch (err) {
730
- outputError(err);
731
- }
732
- });
733
- feature.command('add')
734
- .description('Add a feature to a plan, or rename one it already includes (idempotent by key)')
735
- .argument('<planKey>', 'Plan key')
736
- .argument('<featureKey>', 'Feature key: lower-case letters, digits and underscores, e.g. analytics')
737
- .option('--name <name>', 'Display name shown on the pricing table and upgrade dialog (default: the key)')
738
- .action(async (planKey, featureKey, opts) => {
739
- try {
740
- const entry = buildFeature(featureKey, opts.name);
741
- const { features } = await getPlan(planKey);
742
- const next = upsertFeature(features, entry);
743
- outputSuccess(withFeatures(shapePlan(await getManagementClient().plans.update(planKey, { features: next }))));
744
- }
745
- catch (err) {
746
- outputError(err);
747
- }
748
- });
749
- feature.command('remove')
750
- .description('Remove a feature from a plan')
751
- .argument('<planKey>', 'Plan key')
752
- .argument('<featureKey>', 'Feature key to remove')
753
- .action(async (planKey, featureKey) => {
754
- try {
755
- const { features } = await getPlan(planKey);
756
- const next = removeFeature(features, featureKey, planKey);
757
- outputSuccess(withFeatures(shapePlan(await getManagementClient().plans.update(planKey, { features: next }))));
758
- }
759
- catch (err) {
760
- outputError(err);
761
123
  }
762
- });
124
+ ctx.io.result(await plans.remove(c, appId, key));
125
+ }));
126
+ const price = plan.command('price').description('One price of a plan, by currency and interval');
127
+ price
128
+ .command('set <plan> <price>')
129
+ .description('Add a price, or change the one in its currency and interval ("19.99 EUR/month")')
130
+ .action(action(deps, async (ctx, key, text) => {
131
+ const given = parsePrice(text);
132
+ const { c, appId } = await ctx.forApp();
133
+ ctx.io.result(await change(c, appId, key, (p) => {
134
+ const kept = (p.prices ?? []).find((each) => priceId(each) === priceId(given));
135
+ const next = { ...given, ...(kept?.usage === undefined ? {} : { usage: kept.usage }) };
136
+ return { prices: [...(p.prices ?? []).filter((each) => priceId(each) !== priceId(given)), next] };
137
+ }));
138
+ }));
139
+ price
140
+ .command('remove <plan> <currency-interval>')
141
+ .description('Remove the price in a currency and interval ("EUR/month")')
142
+ .action(action(deps, async (ctx, key, text) => {
143
+ const target = priceId(parseCurrencyInterval(text));
144
+ const { c, appId } = await ctx.forApp();
145
+ ctx.io.result(await change(c, appId, key, (p) => {
146
+ if (!(p.prices ?? []).some((each) => priceId(each) === target))
147
+ throw new CliError('not-found', `${key} has no ${text} price.`);
148
+ return { prices: (p.prices ?? []).filter((each) => priceId(each) !== target) };
149
+ }));
150
+ }));
151
+ const usage = plan.command('usage-price').description('Per-unit usage prices on a plan\'s prices');
152
+ usage
153
+ .command('set <plan> <usage>')
154
+ .description('Add or change a usage price ("api.calls 0.002 EUR/month included 10000")')
155
+ .action(action(deps, async (ctx, key, text) => {
156
+ const given = parseUsagePrice(text);
157
+ const { c, appId } = await ctx.forApp();
158
+ ctx.io.result(await change(c, appId, key, (p) => ({ prices: attachUsage(p.prices ?? [], [given]) })));
159
+ }));
160
+ usage
161
+ .command('remove <plan> <metric>')
162
+ .description('Remove the usage price on a metric from every price of the plan')
163
+ .action(action(deps, async (ctx, key, metric) => {
164
+ const { c, appId } = await ctx.forApp();
165
+ ctx.io.result(await change(c, appId, key, (p) => ({
166
+ prices: (p.prices ?? []).map(({ usage: list, ...rest }) => {
167
+ const left = (list ?? []).filter((each) => each.metric !== metric);
168
+ return left.length === 0 ? rest : { ...rest, usage: left };
169
+ }),
170
+ })));
171
+ }));
172
+ const feature = plan.command('feature').description('The features a plan includes');
173
+ feature
174
+ .command('add <plan> <feature>')
175
+ .description('Include a feature ("<key>" or "<key>=<name>"), or rename one the plan has')
176
+ .action(action(deps, async (ctx, key, text) => {
177
+ const given = parseFeature(text);
178
+ const { c, appId } = await ctx.forApp();
179
+ ctx.io.result(await change(c, appId, key, (p) => ({ features: [...(p.features ?? []).filter((each) => each.key !== given.key), given] })));
180
+ }));
181
+ feature
182
+ .command('remove <plan> <feature>')
183
+ .description('Stop including a feature')
184
+ .action(action(deps, async (ctx, key, featureKey) => {
185
+ const { c, appId } = await ctx.forApp();
186
+ ctx.io.result(await change(c, appId, key, (p) => ({ features: (p.features ?? []).filter((each) => each.key !== featureKey) })));
187
+ }));
188
+ const limit = plan.command('limit').description('How much of a metric a plan allows');
189
+ limit
190
+ .command('set <plan> <limit>')
191
+ .description('Set a limit ("api.calls=10000" or "seats=unlimited")')
192
+ .action(action(deps, async (ctx, key, text) => {
193
+ const given = parseLimit(text);
194
+ const { c, appId } = await ctx.forApp();
195
+ ctx.io.result(await change(c, appId, key, (p) => ({ limits: [...(p.limits ?? []).filter((each) => each.metric !== given.metric), given] })));
196
+ }));
197
+ limit
198
+ .command('remove <plan> <metric>')
199
+ .description('Remove a limit')
200
+ .action(action(deps, async (ctx, key, metric) => {
201
+ const { c, appId } = await ctx.forApp();
202
+ ctx.io.result(await change(c, appId, key, (p) => ({ limits: (p.limits ?? []).filter((each) => each.metric !== metric) })));
203
+ }));
763
204
  }
764
205
  //# sourceMappingURL=plan.command.js.map