@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.
- package/README.md +93 -460
- package/dist/api/accounts.d.ts +69 -0
- package/dist/api/accounts.d.ts.map +1 -0
- package/dist/api/accounts.js +42 -0
- package/dist/api/accounts.js.map +1 -0
- package/dist/api/apps.d.ts +50 -0
- package/dist/api/apps.d.ts.map +1 -0
- package/dist/api/apps.js +62 -0
- package/dist/api/apps.js.map +1 -0
- package/dist/api/billing.d.ts +131 -0
- package/dist/api/billing.d.ts.map +1 -0
- package/dist/api/billing.js +42 -0
- package/dist/api/billing.js.map +1 -0
- package/dist/api/flags.d.ts +73 -0
- package/dist/api/flags.d.ts.map +1 -0
- package/dist/api/flags.js +108 -0
- package/dist/api/flags.js.map +1 -0
- package/dist/api/keys.d.ts +48 -0
- package/dist/api/keys.d.ts.map +1 -0
- package/dist/api/keys.js +87 -0
- package/dist/api/keys.js.map +1 -0
- package/dist/auth/browser-open.d.ts +21 -4
- package/dist/auth/browser-open.d.ts.map +1 -1
- package/dist/auth/browser-open.js +45 -65
- package/dist/auth/browser-open.js.map +1 -1
- package/dist/auth/lock.d.ts +8 -0
- package/dist/auth/lock.d.ts.map +1 -0
- package/dist/auth/lock.js +43 -0
- package/dist/auth/lock.js.map +1 -0
- package/dist/auth/loopback.d.ts +8 -12
- package/dist/auth/loopback.d.ts.map +1 -1
- package/dist/auth/loopback.js +40 -45
- package/dist/auth/loopback.js.map +1 -1
- package/dist/auth/session.d.ts +61 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +142 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/sign-in.d.ts +67 -0
- package/dist/auth/sign-in.d.ts.map +1 -0
- package/dist/auth/sign-in.js +113 -0
- package/dist/auth/sign-in.js.map +1 -0
- package/dist/auth/store.d.ts +89 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +214 -0
- package/dist/auth/store.js.map +1 -0
- package/dist/bin.js +0 -2
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts +1 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -17
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +67 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/accounts.command.d.ts +4 -0
- package/dist/commands/accounts.command.d.ts.map +1 -0
- package/dist/commands/accounts.command.js +116 -0
- package/dist/commands/accounts.command.js.map +1 -0
- package/dist/commands/app.command.d.ts +3 -2
- package/dist/commands/app.command.d.ts.map +1 -1
- package/dist/commands/app.command.js +132 -233
- package/dist/commands/app.command.js.map +1 -1
- package/dist/commands/auth.command.d.ts +2 -12
- package/dist/commands/auth.command.d.ts.map +1 -1
- package/dist/commands/auth.command.js +147 -20
- package/dist/commands/auth.command.js.map +1 -1
- package/dist/commands/billing.command.d.ts +4 -0
- package/dist/commands/billing.command.d.ts.map +1 -0
- package/dist/commands/billing.command.js +185 -0
- package/dist/commands/billing.command.js.map +1 -0
- package/dist/commands/check.command.d.ts +2 -1
- package/dist/commands/check.command.d.ts.map +1 -1
- package/dist/commands/check.command.js +11 -14
- package/dist/commands/check.command.js.map +1 -1
- package/dist/commands/context.d.ts +58 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +78 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/flag.command.d.ts +3 -62
- package/dist/commands/flag.command.d.ts.map +1 -1
- package/dist/commands/flag.command.js +181 -864
- package/dist/commands/flag.command.js.map +1 -1
- package/dist/commands/key.command.d.ts +4 -0
- package/dist/commands/key.command.d.ts.map +1 -0
- package/dist/commands/key.command.js +44 -0
- package/dist/commands/key.command.js.map +1 -0
- package/dist/commands/plan.command.d.ts +3 -203
- package/dist/commands/plan.command.d.ts.map +1 -1
- package/dist/commands/plan.command.js +200 -759
- package/dist/commands/plan.command.js.map +1 -1
- package/dist/config.d.ts +19 -101
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +51 -364
- package/dist/config.js.map +1 -1
- package/dist/core/discovery.d.ts +27 -0
- package/dist/core/discovery.d.ts.map +1 -0
- package/dist/core/discovery.js +97 -0
- package/dist/core/discovery.js.map +1 -0
- package/dist/core/errors.d.ts +26 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +33 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/http.d.ts +23 -0
- package/dist/core/http.d.ts.map +1 -0
- package/dist/core/http.js +153 -0
- package/dist/core/http.js.map +1 -0
- package/dist/core/private-files.d.ts +6 -0
- package/dist/core/private-files.d.ts.map +1 -0
- package/dist/core/private-files.js +96 -0
- package/dist/core/private-files.js.map +1 -0
- package/dist/core/routes.d.ts +94 -0
- package/dist/core/routes.d.ts.map +1 -0
- package/dist/core/routes.js +91 -0
- package/dist/core/routes.js.map +1 -0
- package/dist/core/urls.d.ts +15 -0
- package/dist/core/urls.d.ts.map +1 -0
- package/dist/core/urls.js +55 -0
- package/dist/core/urls.js.map +1 -0
- package/dist/gate-rules.d.ts +1 -2
- package/dist/gate-rules.d.ts.map +1 -1
- package/dist/gate-rules.js +4 -5
- package/dist/gate-rules.js.map +1 -1
- package/dist/index.d.ts +23 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/io.d.ts +21 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +59 -0
- package/dist/io.js.map +1 -0
- package/dist/money.d.ts +42 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +112 -0
- package/dist/money.js.map +1 -0
- package/dist/program.d.ts +5 -24
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +25 -113
- package/dist/program.js.map +1 -1
- package/package.json +7 -8
- package/dist/auth/api-client.d.ts +0 -50
- package/dist/auth/api-client.d.ts.map +0 -1
- package/dist/auth/api-client.js +0 -109
- package/dist/auth/api-client.js.map +0 -1
- package/dist/auth/relative-time.d.ts +0 -2
- package/dist/auth/relative-time.d.ts.map +0 -1
- package/dist/auth/relative-time.js +0 -27
- package/dist/auth/relative-time.js.map +0 -1
- package/dist/auth/scopes.d.ts +0 -30
- package/dist/auth/scopes.d.ts.map +0 -1
- package/dist/auth/scopes.js +0 -39
- package/dist/auth/scopes.js.map +0 -1
- package/dist/auth/workspace-client.d.ts +0 -70
- package/dist/auth/workspace-client.d.ts.map +0 -1
- package/dist/auth/workspace-client.js +0 -105
- package/dist/auth/workspace-client.js.map +0 -1
- package/dist/commands/auth/config.command.d.ts +0 -8
- package/dist/commands/auth/config.command.d.ts.map +0 -1
- package/dist/commands/auth/config.command.js +0 -60
- package/dist/commands/auth/config.command.js.map +0 -1
- package/dist/commands/auth/login.command.d.ts +0 -46
- package/dist/commands/auth/login.command.d.ts.map +0 -1
- package/dist/commands/auth/login.command.js +0 -166
- package/dist/commands/auth/login.command.js.map +0 -1
- package/dist/commands/auth/logout.command.d.ts +0 -21
- package/dist/commands/auth/logout.command.d.ts.map +0 -1
- package/dist/commands/auth/logout.command.js +0 -97
- package/dist/commands/auth/logout.command.js.map +0 -1
- package/dist/commands/auth/methods.command.d.ts +0 -68
- package/dist/commands/auth/methods.command.d.ts.map +0 -1
- package/dist/commands/auth/methods.command.js +0 -91
- package/dist/commands/auth/methods.command.js.map +0 -1
- package/dist/commands/auth/status.command.d.ts +0 -18
- package/dist/commands/auth/status.command.d.ts.map +0 -1
- package/dist/commands/auth/status.command.js +0 -104
- package/dist/commands/auth/status.command.js.map +0 -1
- package/dist/commands/auth/use.command.d.ts +0 -17
- package/dist/commands/auth/use.command.d.ts.map +0 -1
- package/dist/commands/auth/use.command.js +0 -47
- package/dist/commands/auth/use.command.js.map +0 -1
- package/dist/commands/branding.command.d.ts +0 -3
- package/dist/commands/branding.command.d.ts.map +0 -1
- package/dist/commands/branding.command.js +0 -42
- package/dist/commands/branding.command.js.map +0 -1
- package/dist/commands/event.command.d.ts +0 -3
- package/dist/commands/event.command.d.ts.map +0 -1
- package/dist/commands/event.command.js +0 -27
- package/dist/commands/event.command.js.map +0 -1
- package/dist/commands/flag-init.command.d.ts +0 -13
- package/dist/commands/flag-init.command.d.ts.map +0 -1
- package/dist/commands/flag-init.command.js +0 -463
- package/dist/commands/flag-init.command.js.map +0 -1
- package/dist/commands/guide.command.d.ts +0 -61
- package/dist/commands/guide.command.d.ts.map +0 -1
- package/dist/commands/guide.command.js +0 -655
- package/dist/commands/guide.command.js.map +0 -1
- package/dist/commands/info.command.d.ts +0 -3
- package/dist/commands/info.command.d.ts.map +0 -1
- package/dist/commands/info.command.js +0 -70
- package/dist/commands/info.command.js.map +0 -1
- package/dist/commands/integration.command.d.ts +0 -35
- package/dist/commands/integration.command.d.ts.map +0 -1
- package/dist/commands/integration.command.js +0 -290
- package/dist/commands/integration.command.js.map +0 -1
- package/dist/commands/journeys.d.ts +0 -43
- package/dist/commands/journeys.d.ts.map +0 -1
- package/dist/commands/journeys.js +0 -221
- package/dist/commands/journeys.js.map +0 -1
- package/dist/commands/ops.command.d.ts +0 -17
- package/dist/commands/ops.command.d.ts.map +0 -1
- package/dist/commands/ops.command.js +0 -129
- package/dist/commands/ops.command.js.map +0 -1
- package/dist/commands/privilege.command.d.ts +0 -16
- package/dist/commands/privilege.command.d.ts.map +0 -1
- package/dist/commands/privilege.command.js +0 -86
- package/dist/commands/privilege.command.js.map +0 -1
- package/dist/commands/role.command.d.ts +0 -3
- package/dist/commands/role.command.d.ts.map +0 -1
- package/dist/commands/role.command.js +0 -101
- package/dist/commands/role.command.js.map +0 -1
- package/dist/commands/runtime-dir.d.ts +0 -16
- package/dist/commands/runtime-dir.d.ts.map +0 -1
- package/dist/commands/runtime-dir.js +0 -18
- package/dist/commands/runtime-dir.js.map +0 -1
- package/dist/commands/setup.command.d.ts +0 -63
- package/dist/commands/setup.command.d.ts.map +0 -1
- package/dist/commands/setup.command.js +0 -148
- package/dist/commands/setup.command.js.map +0 -1
- package/dist/commands/stripe.command.d.ts +0 -29
- package/dist/commands/stripe.command.d.ts.map +0 -1
- package/dist/commands/stripe.command.js +0 -99
- package/dist/commands/stripe.command.js.map +0 -1
- package/dist/commands/tenant.command.d.ts +0 -3
- package/dist/commands/tenant.command.d.ts.map +0 -1
- package/dist/commands/tenant.command.js +0 -135
- package/dist/commands/tenant.command.js.map +0 -1
- package/dist/commands/token.command.d.ts +0 -3
- package/dist/commands/token.command.d.ts.map +0 -1
- package/dist/commands/token.command.js +0 -54
- package/dist/commands/token.command.js.map +0 -1
- package/dist/commands/user.command.d.ts +0 -3
- package/dist/commands/user.command.d.ts.map +0 -1
- package/dist/commands/user.command.js +0 -117
- package/dist/commands/user.command.js.map +0 -1
- package/dist/credentials.d.ts +0 -141
- package/dist/credentials.d.ts.map +0 -1
- package/dist/credentials.js +0 -348
- package/dist/credentials.js.map +0 -1
- package/dist/flag-coherence.d.ts +0 -42
- package/dist/flag-coherence.d.ts.map +0 -1
- package/dist/flag-coherence.js +0 -103
- package/dist/flag-coherence.js.map +0 -1
- package/dist/output.d.ts +0 -17
- package/dist/output.d.ts.map +0 -1
- package/dist/output.js +0 -107
- package/dist/output.js.map +0 -1
- package/dist/preview.d.ts +0 -9
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js +0 -63
- package/dist/preview.js.map +0 -1
- package/dist/product-decisions.d.ts +0 -40
- package/dist/product-decisions.d.ts.map +0 -1
- package/dist/product-decisions.js +0 -52
- package/dist/product-decisions.js.map +0 -1
- package/dist/prompt-urls.d.ts +0 -31
- package/dist/prompt-urls.d.ts.map +0 -1
- package/dist/prompt-urls.js +0 -35
- package/dist/prompt-urls.js.map +0 -1
- package/dist/prompts/auth-master-integration-prompt.md +0 -255
- package/dist/prompts/billing/master.md +0 -332
- package/dist/prompts/decisions/feature-control.md +0 -131
- package/dist/prompts/decisions/going-live.md +0 -60
- package/dist/prompts/decisions/login.md +0 -71
- package/dist/prompts/decisions/look-and-feel.md +0 -68
- package/dist/prompts/decisions/payments.md +0 -97
- package/dist/prompts/decisions/roles.md +0 -78
- package/dist/prompts/decisions/teams.md +0 -61
- package/dist/prompts/fit-together.md +0 -97
- package/dist/prompts/flags/master.md +0 -220
- package/dist/prompts/guide-coverage.json +0 -17
- package/dist/prompts/integration-success.md +0 -106
- package/dist/prompts/mcp-tools.json +0 -90
- package/dist/prompts/mechanisms.md +0 -298
- package/dist/prompts/orientation.md +0 -42
- package/dist/prompts/product-decisions.json +0 -338
- package/dist/resolve.d.ts +0 -110
- package/dist/resolve.d.ts.map +0 -1
- package/dist/resolve.js +0 -260
- package/dist/resolve.js.map +0 -1
- package/dist/shape.d.ts +0 -25
- package/dist/shape.d.ts.map +0 -1
- package/dist/shape.js +0 -74
- package/dist/shape.js.map +0 -1
|
@@ -1,764 +1,205 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
if (
|
|
555
|
-
fields.
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
.
|
|
588
|
-
.
|
|
589
|
-
.option('--
|
|
590
|
-
.action(async (
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|