@farthershore/cli 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +32 -3
- package/dist/commands/apply.js +74 -20
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +59 -0
- package/dist/commands/feature.d.ts +3 -0
- package/dist/commands/feature.js +80 -0
- package/dist/commands/helpers.d.ts +15 -0
- package/dist/commands/helpers.js +93 -0
- package/dist/commands/login.js +47 -38
- package/dist/commands/meter.d.ts +3 -0
- package/dist/commands/meter.js +94 -0
- package/dist/commands/plan-transition.d.ts +40 -0
- package/dist/commands/plan-transition.js +504 -0
- package/dist/commands/plan.d.ts +3 -0
- package/dist/commands/plan.js +185 -0
- package/dist/commands/product.d.ts +3 -0
- package/dist/commands/product.js +101 -0
- package/dist/commands/transition.d.ts +3 -0
- package/dist/commands/transition.js +63 -0
- package/dist/index.js +14 -0
- package/dist/types.d.ts +2 -0
- package/package.json +3 -3
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import * as output from "../output.js";
|
|
6
|
+
import { loadProductYaml, validateProductYaml } from "./validate.js";
|
|
7
|
+
const CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
8
|
+
const DEFAULT_ACTION = "preserve_current_period";
|
|
9
|
+
const DEFAULT_WHEN = {
|
|
10
|
+
price_increase: "preserve_current_period",
|
|
11
|
+
price_decrease: "switch_immediately",
|
|
12
|
+
feature_added: "switch_immediately",
|
|
13
|
+
feature_removed: "preserve_current_period",
|
|
14
|
+
limit_increased: "switch_immediately",
|
|
15
|
+
limit_reduced: "preserve_current_period",
|
|
16
|
+
credit_increased: "switch_immediately",
|
|
17
|
+
credit_reduced: "preserve_current_period",
|
|
18
|
+
rating_changed: "preserve_current_period",
|
|
19
|
+
strategy_changed: "preserve_current_period",
|
|
20
|
+
plan_removed: "preserve_current_period",
|
|
21
|
+
plan_added: "switch_immediately",
|
|
22
|
+
};
|
|
23
|
+
export function analyzePlanTransition(options) {
|
|
24
|
+
const from = asRecord(options.fromSpec);
|
|
25
|
+
const to = asRecord(options.toSpec);
|
|
26
|
+
const policy = readPolicy(to);
|
|
27
|
+
const changes = [];
|
|
28
|
+
const errors = [];
|
|
29
|
+
const warnings = [];
|
|
30
|
+
const fromStrategy = readStrategy(from);
|
|
31
|
+
const toStrategy = readStrategy(to);
|
|
32
|
+
if (fromStrategy !== toStrategy) {
|
|
33
|
+
changes.push(makeChange(policy, {
|
|
34
|
+
kind: "strategy_changed",
|
|
35
|
+
from: fromStrategy,
|
|
36
|
+
to: toStrategy,
|
|
37
|
+
message: `Product strategy changes from ${fromStrategy ?? "unset"} to ${toStrategy ?? "unset"}`,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
const fromPlans = planMap(from);
|
|
41
|
+
const toPlans = planMap(to);
|
|
42
|
+
for (const [planKey, oldPlan] of fromPlans) {
|
|
43
|
+
const nextPlan = toPlans.get(planKey);
|
|
44
|
+
if (!nextPlan) {
|
|
45
|
+
changes.push(makeChange(policy, {
|
|
46
|
+
kind: "plan_removed",
|
|
47
|
+
planKey,
|
|
48
|
+
from: planKey,
|
|
49
|
+
to: null,
|
|
50
|
+
message: `Plan "${planKey}" was removed`,
|
|
51
|
+
}));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
comparePlan(planKey, oldPlan, nextPlan, policy, changes);
|
|
55
|
+
}
|
|
56
|
+
for (const planKey of toPlans.keys()) {
|
|
57
|
+
if (!fromPlans.has(planKey)) {
|
|
58
|
+
changes.push(makeChange(policy, {
|
|
59
|
+
kind: "plan_added",
|
|
60
|
+
planKey,
|
|
61
|
+
from: null,
|
|
62
|
+
to: planKey,
|
|
63
|
+
message: `Plan "${planKey}" was added`,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
compareRatings(from, to, policy, changes);
|
|
68
|
+
for (const change of changes) {
|
|
69
|
+
if (change.kind === "plan_removed" &&
|
|
70
|
+
change.subscriberAction !== "new_subscribers_only") {
|
|
71
|
+
const replacement = planReplacement(to, change.planKey);
|
|
72
|
+
if (!replacement) {
|
|
73
|
+
errors.push(`Plan "${change.planKey}": removed plan must define archive.transitionTo or use new_subscribers_only`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (change.requiresAcknowledgment) {
|
|
77
|
+
errors.push(acknowledgmentMessage(change.kind));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (changes.length === 0) {
|
|
81
|
+
warnings.push("No billing-affecting changes detected");
|
|
82
|
+
}
|
|
83
|
+
const agentHints = buildAgentHints(changes, errors);
|
|
84
|
+
return {
|
|
85
|
+
valid: errors.length === 0,
|
|
86
|
+
from: options.fromLabel ?? "from",
|
|
87
|
+
to: options.toLabel ?? "product.yaml",
|
|
88
|
+
strategy: { from: fromStrategy, to: toStrategy },
|
|
89
|
+
policy,
|
|
90
|
+
changes,
|
|
91
|
+
errors,
|
|
92
|
+
warnings,
|
|
93
|
+
agentHints,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function registerPlanTransitionCommand(program) {
|
|
97
|
+
program
|
|
98
|
+
.command("plan-transition")
|
|
99
|
+
.description("Preview billing-affecting product.yaml changes and the subscriber transition policy they imply. Designed for agents and CI before apply.")
|
|
100
|
+
.option("--from <file>", "Base product.yaml to compare from")
|
|
101
|
+
.option("--to <file>", "Proposed product.yaml to compare to", "product.yaml")
|
|
102
|
+
.option("--from-git <ref>", "Read base product.yaml from a git ref, e.g. main:product.yaml")
|
|
103
|
+
.option("--format <format>", "Output format: table or json", "table")
|
|
104
|
+
.action((opts) => {
|
|
105
|
+
const toPath = resolve(opts.to);
|
|
106
|
+
const toLoaded = loadProductYaml(toPath);
|
|
107
|
+
if (!toLoaded.ok) {
|
|
108
|
+
fail(`Could not read proposed product.yaml: ${toLoaded.message}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const localValidation = validateProductYaml(toLoaded.spec);
|
|
112
|
+
if (!localValidation.valid) {
|
|
113
|
+
printValidationFailure(localValidation.errors, opts.format);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const base = loadBaseSpec(opts);
|
|
117
|
+
if (!base.ok) {
|
|
118
|
+
fail(base.message);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const analysis = analyzePlanTransition({
|
|
122
|
+
fromSpec: base.spec,
|
|
123
|
+
toSpec: toLoaded.spec,
|
|
124
|
+
fromLabel: base.label,
|
|
125
|
+
toLabel: opts.to,
|
|
126
|
+
});
|
|
127
|
+
if (opts.format === "json") {
|
|
128
|
+
console.log(output.json(analysis));
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
printHumanAnalysis(analysis);
|
|
132
|
+
}
|
|
133
|
+
if (CI) {
|
|
134
|
+
for (const err of analysis.errors) {
|
|
135
|
+
console.log(`::error file=product.yaml::${err}`);
|
|
136
|
+
}
|
|
137
|
+
for (const warning of analysis.warnings) {
|
|
138
|
+
console.log(`::warning file=product.yaml::${warning}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!analysis.valid)
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function comparePlan(planKey, oldPlan, nextPlan, policy, changes) {
|
|
146
|
+
const oldPrice = monthlyPrice(oldPlan);
|
|
147
|
+
const nextPrice = monthlyPrice(nextPlan);
|
|
148
|
+
if (nextPrice > oldPrice) {
|
|
149
|
+
changes.push(makeChange(policy, {
|
|
150
|
+
kind: "price_increase",
|
|
151
|
+
planKey,
|
|
152
|
+
from: oldPrice,
|
|
153
|
+
to: nextPrice,
|
|
154
|
+
message: `Plan "${planKey}" price increases from ${oldPrice} to ${nextPrice}`,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
else if (nextPrice < oldPrice) {
|
|
158
|
+
changes.push(makeChange(policy, {
|
|
159
|
+
kind: "price_decrease",
|
|
160
|
+
planKey,
|
|
161
|
+
from: oldPrice,
|
|
162
|
+
to: nextPrice,
|
|
163
|
+
message: `Plan "${planKey}" price decreases from ${oldPrice} to ${nextPrice}`,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
compareStringSet(planKey, "feature", features(oldPlan), features(nextPlan), policy, changes);
|
|
167
|
+
compareLimits(planKey, oldPlan, nextPlan, policy, changes);
|
|
168
|
+
compareCredits(planKey, oldPlan, nextPlan, policy, changes);
|
|
169
|
+
}
|
|
170
|
+
function compareStringSet(planKey, label, oldValues, nextValues, policy, changes) {
|
|
171
|
+
for (const value of nextValues) {
|
|
172
|
+
if (!oldValues.has(value)) {
|
|
173
|
+
changes.push(makeChange(policy, {
|
|
174
|
+
kind: "feature_added",
|
|
175
|
+
planKey,
|
|
176
|
+
feature: value,
|
|
177
|
+
from: null,
|
|
178
|
+
to: value,
|
|
179
|
+
message: `Plan "${planKey}" adds ${label} "${value}"`,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const value of oldValues) {
|
|
184
|
+
if (!nextValues.has(value)) {
|
|
185
|
+
changes.push(makeChange(policy, {
|
|
186
|
+
kind: "feature_removed",
|
|
187
|
+
planKey,
|
|
188
|
+
feature: value,
|
|
189
|
+
from: value,
|
|
190
|
+
to: null,
|
|
191
|
+
message: `Plan "${planKey}" removes ${label} "${value}"`,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function compareLimits(planKey, oldPlan, nextPlan, policy, changes) {
|
|
197
|
+
const oldLimits = limitsByMeter(oldPlan);
|
|
198
|
+
const nextLimits = limitsByMeter(nextPlan);
|
|
199
|
+
for (const [meter, nextCapacity] of nextLimits) {
|
|
200
|
+
const oldCapacity = oldLimits.get(meter);
|
|
201
|
+
if (oldCapacity === undefined)
|
|
202
|
+
continue;
|
|
203
|
+
if (nextCapacity > oldCapacity) {
|
|
204
|
+
changes.push(makeChange(policy, {
|
|
205
|
+
kind: "limit_increased",
|
|
206
|
+
planKey,
|
|
207
|
+
meter,
|
|
208
|
+
from: oldCapacity,
|
|
209
|
+
to: nextCapacity,
|
|
210
|
+
message: `Plan "${planKey}" limit for "${meter}" increases from ${oldCapacity} to ${nextCapacity}`,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
else if (nextCapacity < oldCapacity) {
|
|
214
|
+
changes.push(makeChange(policy, {
|
|
215
|
+
kind: "limit_reduced",
|
|
216
|
+
planKey,
|
|
217
|
+
meter,
|
|
218
|
+
from: oldCapacity,
|
|
219
|
+
to: nextCapacity,
|
|
220
|
+
message: `Plan "${planKey}" limit for "${meter}" reduces from ${oldCapacity} to ${nextCapacity}`,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function compareCredits(planKey, oldPlan, nextPlan, policy, changes) {
|
|
226
|
+
const oldCredits = includedCredits(oldPlan);
|
|
227
|
+
const nextCredits = includedCredits(nextPlan);
|
|
228
|
+
if (nextCredits > oldCredits) {
|
|
229
|
+
changes.push(makeChange(policy, {
|
|
230
|
+
kind: "credit_increased",
|
|
231
|
+
planKey,
|
|
232
|
+
from: oldCredits,
|
|
233
|
+
to: nextCredits,
|
|
234
|
+
message: `Plan "${planKey}" credits increase from ${oldCredits} to ${nextCredits}`,
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
else if (nextCredits < oldCredits) {
|
|
238
|
+
changes.push(makeChange(policy, {
|
|
239
|
+
kind: "credit_reduced",
|
|
240
|
+
planKey,
|
|
241
|
+
from: oldCredits,
|
|
242
|
+
to: nextCredits,
|
|
243
|
+
message: `Plan "${planKey}" credits reduce from ${oldCredits} to ${nextCredits}`,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function compareRatings(from, to, policy, changes) {
|
|
248
|
+
const oldRatings = ratingsByMeter(from);
|
|
249
|
+
const nextRatings = ratingsByMeter(to);
|
|
250
|
+
for (const [meter, nextRating] of nextRatings) {
|
|
251
|
+
const oldRating = oldRatings.get(meter);
|
|
252
|
+
if (oldRating !== undefined &&
|
|
253
|
+
stableJson(oldRating) !== stableJson(nextRating)) {
|
|
254
|
+
changes.push(makeChange(policy, {
|
|
255
|
+
kind: "rating_changed",
|
|
256
|
+
meter,
|
|
257
|
+
from: oldRating,
|
|
258
|
+
to: nextRating,
|
|
259
|
+
message: `Meter "${meter}" rating changed`,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function makeChange(policy, input) {
|
|
265
|
+
const subscriberAction = actionFor(policy, input.kind);
|
|
266
|
+
return {
|
|
267
|
+
...input,
|
|
268
|
+
subscriberAction,
|
|
269
|
+
requiresAcknowledgment: requiresAcknowledgment(input.kind, subscriberAction),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function actionFor(policy, kind) {
|
|
273
|
+
return policy.when[kind] ?? policy.default;
|
|
274
|
+
}
|
|
275
|
+
function requiresAcknowledgment(kind, action) {
|
|
276
|
+
if (action !== "switch_immediately" &&
|
|
277
|
+
action !== "switch_immediately_prorate") {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
return (kind === "price_increase" ||
|
|
281
|
+
kind === "feature_removed" ||
|
|
282
|
+
kind === "limit_reduced" ||
|
|
283
|
+
kind === "credit_reduced");
|
|
284
|
+
}
|
|
285
|
+
function acknowledgmentMessage(kind) {
|
|
286
|
+
if (kind === "price_increase") {
|
|
287
|
+
return "Immediate price increases require billing.subscriberChangePolicy.allowImmediatePriceIncrease: true";
|
|
288
|
+
}
|
|
289
|
+
return `Immediate ${kind} requires billing.subscriberChangePolicy.allowImmediateEntitlementReduction: true`;
|
|
290
|
+
}
|
|
291
|
+
function readPolicy(spec) {
|
|
292
|
+
const billing = asRecord(spec.billing);
|
|
293
|
+
const policy = asRecord(billing.subscriberChangePolicy);
|
|
294
|
+
const when = asRecord(policy.when);
|
|
295
|
+
const defaultAction = subscriberAction(policy.default) ?? DEFAULT_ACTION;
|
|
296
|
+
return {
|
|
297
|
+
default: defaultAction,
|
|
298
|
+
proration: typeof policy.proration === "string" ? policy.proration : "none",
|
|
299
|
+
when: Object.fromEntries(Object.entries(DEFAULT_WHEN).map(([key, fallback]) => [
|
|
300
|
+
key,
|
|
301
|
+
subscriberAction(when[key]) ?? fallback ?? defaultAction,
|
|
302
|
+
])),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function subscriberAction(value) {
|
|
306
|
+
if (value === "preserve_current_period" ||
|
|
307
|
+
value === "switch_immediately" ||
|
|
308
|
+
value === "switch_immediately_prorate" ||
|
|
309
|
+
value === "new_subscribers_only") {
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
function readStrategy(spec) {
|
|
315
|
+
const billing = asRecord(spec.billing);
|
|
316
|
+
return typeof billing.strategy === "string" ? billing.strategy : undefined;
|
|
317
|
+
}
|
|
318
|
+
function planMap(spec) {
|
|
319
|
+
const plans = Array.isArray(spec.plans) ? spec.plans : [];
|
|
320
|
+
const map = new Map();
|
|
321
|
+
for (const plan of plans) {
|
|
322
|
+
const record = asRecord(plan);
|
|
323
|
+
if (typeof record.key === "string")
|
|
324
|
+
map.set(record.key, record);
|
|
325
|
+
}
|
|
326
|
+
return map;
|
|
327
|
+
}
|
|
328
|
+
function monthlyPrice(plan) {
|
|
329
|
+
const price = asRecord(plan.price);
|
|
330
|
+
const pricing = asRecord(plan.pricing);
|
|
331
|
+
if (typeof price.monthly === "number")
|
|
332
|
+
return price.monthly;
|
|
333
|
+
return typeof pricing.monthlyPriceCents === "number"
|
|
334
|
+
? pricing.monthlyPriceCents
|
|
335
|
+
: 0;
|
|
336
|
+
}
|
|
337
|
+
function features(plan) {
|
|
338
|
+
const compact = Array.isArray(plan.features) ? plan.features : [];
|
|
339
|
+
const gates = asRecord(plan.featureGates);
|
|
340
|
+
const values = compact.filter((value) => typeof value === "string");
|
|
341
|
+
for (const [key, enabled] of Object.entries(gates)) {
|
|
342
|
+
if (enabled === true)
|
|
343
|
+
values.push(key);
|
|
344
|
+
}
|
|
345
|
+
return new Set(values);
|
|
346
|
+
}
|
|
347
|
+
function limitsByMeter(plan) {
|
|
348
|
+
const limits = Array.isArray(plan.limits) ? plan.limits : [];
|
|
349
|
+
const map = new Map();
|
|
350
|
+
for (const rawLimit of limits) {
|
|
351
|
+
const limit = asRecord(rawLimit);
|
|
352
|
+
const meter = stringValue(limit.meter) ?? stringValue(limit.dimension);
|
|
353
|
+
if (!meter || typeof limit.capacity !== "number")
|
|
354
|
+
continue;
|
|
355
|
+
map.set(meter, limit.capacity);
|
|
356
|
+
}
|
|
357
|
+
return map;
|
|
358
|
+
}
|
|
359
|
+
function includedCredits(plan) {
|
|
360
|
+
const credits = asRecord(plan.credits);
|
|
361
|
+
return typeof credits.monthlyIncludedMicros === "number"
|
|
362
|
+
? credits.monthlyIncludedMicros
|
|
363
|
+
: 0;
|
|
364
|
+
}
|
|
365
|
+
function ratingsByMeter(spec) {
|
|
366
|
+
const usage = asRecord(spec.usage);
|
|
367
|
+
const meters = asRecord(usage.meters);
|
|
368
|
+
const map = new Map();
|
|
369
|
+
for (const [meter, raw] of Object.entries(meters)) {
|
|
370
|
+
const rating = asRecord(raw).rating;
|
|
371
|
+
if (rating !== undefined)
|
|
372
|
+
map.set(meter, rating);
|
|
373
|
+
}
|
|
374
|
+
return map;
|
|
375
|
+
}
|
|
376
|
+
function planReplacement(spec, planKey) {
|
|
377
|
+
if (!planKey)
|
|
378
|
+
return undefined;
|
|
379
|
+
const plan = planMap(spec).get(planKey);
|
|
380
|
+
const archive = asRecord(plan?.archive);
|
|
381
|
+
return typeof archive.transitionTo === "string"
|
|
382
|
+
? archive.transitionTo
|
|
383
|
+
: undefined;
|
|
384
|
+
}
|
|
385
|
+
function asRecord(value) {
|
|
386
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
387
|
+
? value
|
|
388
|
+
: {};
|
|
389
|
+
}
|
|
390
|
+
function stringValue(value) {
|
|
391
|
+
return typeof value === "string" ? value : undefined;
|
|
392
|
+
}
|
|
393
|
+
function stableJson(value) {
|
|
394
|
+
if (Array.isArray(value))
|
|
395
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
396
|
+
if (value && typeof value === "object") {
|
|
397
|
+
return `{${Object.entries(value)
|
|
398
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
399
|
+
.map(([key, val]) => `${JSON.stringify(key)}:${stableJson(val)}`)
|
|
400
|
+
.join(",")}}`;
|
|
401
|
+
}
|
|
402
|
+
return JSON.stringify(value);
|
|
403
|
+
}
|
|
404
|
+
function buildAgentHints(changes, errors) {
|
|
405
|
+
const hints = [];
|
|
406
|
+
if (errors.length > 0) {
|
|
407
|
+
hints.push("Fix errors before running farthershore apply.");
|
|
408
|
+
}
|
|
409
|
+
if (changes.some((change) => change.kind === "plan_removed")) {
|
|
410
|
+
hints.push("For removed plans with active subscribers, add archive.transitionTo or set the relevant policy to new_subscribers_only.");
|
|
411
|
+
}
|
|
412
|
+
if (changes.some((change) => change.requiresAcknowledgment)) {
|
|
413
|
+
hints.push("Avoid immediate harmful changes unless the YAML explicitly acknowledges them.");
|
|
414
|
+
}
|
|
415
|
+
if (changes.length > 0) {
|
|
416
|
+
hints.push("New subscribers use the proposed product.yaml immediately; listed subscriberAction values apply to existing subscribers.");
|
|
417
|
+
}
|
|
418
|
+
return hints;
|
|
419
|
+
}
|
|
420
|
+
function loadBaseSpec(opts) {
|
|
421
|
+
if (opts.fromGit) {
|
|
422
|
+
try {
|
|
423
|
+
const content = execSync(`git show ${shellQuote(opts.fromGit)}`, {
|
|
424
|
+
encoding: "utf-8",
|
|
425
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
426
|
+
});
|
|
427
|
+
return { ok: true, spec: YAML.parse(content), label: opts.fromGit };
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return { ok: false, message: `Could not read ${opts.fromGit} from git` };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (opts.from) {
|
|
434
|
+
const fromPath = resolve(opts.from);
|
|
435
|
+
const loaded = loadProductYaml(fromPath);
|
|
436
|
+
if (!loaded.ok)
|
|
437
|
+
return { ok: false, message: loaded.message };
|
|
438
|
+
return { ok: true, spec: loaded.spec, label: opts.from };
|
|
439
|
+
}
|
|
440
|
+
for (const ref of ["main:product.yaml", "master:product.yaml"]) {
|
|
441
|
+
try {
|
|
442
|
+
const content = execSync(`git show ${ref}`, {
|
|
443
|
+
encoding: "utf-8",
|
|
444
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
445
|
+
});
|
|
446
|
+
return { ok: true, spec: YAML.parse(content), label: ref };
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (existsSync(resolve("product.yaml"))) {
|
|
453
|
+
return {
|
|
454
|
+
ok: false,
|
|
455
|
+
message: "No base product.yaml found. Pass --from old-product.yaml or --from-git main:product.yaml.",
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
message: "No product.yaml found. Pass --to product.yaml.",
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function shellQuote(value) {
|
|
464
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
465
|
+
}
|
|
466
|
+
function printHumanAnalysis(analysis) {
|
|
467
|
+
output.heading(`Plan transition: ${analysis.from} → ${analysis.to}`);
|
|
468
|
+
console.log(`Strategy: ${analysis.strategy.from ?? "unset"} → ${analysis.strategy.to ?? "unset"}`);
|
|
469
|
+
console.log(`Default existing-subscriber action: ${analysis.policy.default}`);
|
|
470
|
+
if (analysis.changes.length === 0) {
|
|
471
|
+
output.success("No billing-affecting changes detected");
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
for (const change of analysis.changes) {
|
|
475
|
+
const ack = change.requiresAcknowledgment
|
|
476
|
+
? " requires acknowledgment"
|
|
477
|
+
: "";
|
|
478
|
+
console.log(` • ${change.message} → ${change.subscriberAction}${ack}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const warning of analysis.warnings)
|
|
482
|
+
output.warn(warning);
|
|
483
|
+
for (const err of analysis.errors)
|
|
484
|
+
output.error(err);
|
|
485
|
+
for (const hint of analysis.agentHints)
|
|
486
|
+
output.info(`hint: ${hint}`);
|
|
487
|
+
}
|
|
488
|
+
function printValidationFailure(errors, format) {
|
|
489
|
+
if (format === "json") {
|
|
490
|
+
console.log(output.json({ valid: false, errors }));
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
output.error("Proposed product.yaml is invalid");
|
|
494
|
+
for (const err of errors)
|
|
495
|
+
console.log(` • ${err}`);
|
|
496
|
+
}
|
|
497
|
+
process.exitCode = 1;
|
|
498
|
+
}
|
|
499
|
+
function fail(message) {
|
|
500
|
+
if (CI)
|
|
501
|
+
console.log(`::error file=product.yaml::${message}`);
|
|
502
|
+
output.error(message);
|
|
503
|
+
process.exitCode = 1;
|
|
504
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { findPlan, loadAcceptedSpec, plans, printResult, resolveProductId, stringArrayAt, updateSpec, } from "./helpers.js";
|
|
2
|
+
export function registerPlanCommands(program, getClient) {
|
|
3
|
+
const plan = program.command("plan").description("Manage product plans");
|
|
4
|
+
plan
|
|
5
|
+
.command("list")
|
|
6
|
+
.option("--product <product>", "Product id or name")
|
|
7
|
+
.action(async (opts) => {
|
|
8
|
+
const client = getClient();
|
|
9
|
+
const productId = await resolveProductId(client, opts.product);
|
|
10
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
11
|
+
printResult(program, "Plans loaded", {
|
|
12
|
+
ok: true,
|
|
13
|
+
productId,
|
|
14
|
+
plans: plans(spec),
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
plan
|
|
18
|
+
.command("add <key>")
|
|
19
|
+
.option("--product <product>", "Product id or name")
|
|
20
|
+
.option("--name <name>", "Plan display name")
|
|
21
|
+
.option("--free", "Create a free hard-limited plan")
|
|
22
|
+
.option("--price-monthly <cents>", "Monthly price in cents", parseInteger)
|
|
23
|
+
.option("--credits-monthly-micros <micros>", "Monthly included credits", parseInteger)
|
|
24
|
+
.option("--meter <meter>", "Included meter; repeatable", collect, [])
|
|
25
|
+
.option("--feature <feature>", "Enabled feature; repeatable", collect, [])
|
|
26
|
+
.option("--limit <limit>", "Limit meter:window:capacity:enforcement; repeatable", collect, [])
|
|
27
|
+
.option("--dry-run", "Validate without mutating")
|
|
28
|
+
.action(async (key, opts) => {
|
|
29
|
+
const client = getClient();
|
|
30
|
+
const productId = await resolveProductId(client, opts.product);
|
|
31
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
32
|
+
const existing = plans(spec);
|
|
33
|
+
const index = existing.findIndex((candidate) => candidate.key === key);
|
|
34
|
+
const nextPlan = buildPlan(key, opts);
|
|
35
|
+
if (index === -1)
|
|
36
|
+
existing.push(nextPlan);
|
|
37
|
+
else
|
|
38
|
+
existing[index] = { ...existing[index], ...nextPlan };
|
|
39
|
+
await updateSpec(program, client, productId, spec, {
|
|
40
|
+
dryRun: opts.dryRun,
|
|
41
|
+
message: `Plan "${key}" saved`,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
plan
|
|
45
|
+
.command("set-price <key>")
|
|
46
|
+
.option("--product <product>", "Product id or name")
|
|
47
|
+
.requiredOption("--monthly <cents>", "Monthly price in cents", parseInteger)
|
|
48
|
+
.option("--currency <currency>", "Currency", "USD")
|
|
49
|
+
.option("--dry-run", "Validate without mutating")
|
|
50
|
+
.action(async (key, opts) => {
|
|
51
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
52
|
+
plan.free = false;
|
|
53
|
+
plan.price = { monthly: opts.monthly, currency: opts.currency };
|
|
54
|
+
plan.pricing = {
|
|
55
|
+
model: "flat_rate",
|
|
56
|
+
monthlyPriceCents: opts.monthly,
|
|
57
|
+
billingInterval: "month",
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
plan
|
|
62
|
+
.command("set-limit <key>")
|
|
63
|
+
.option("--product <product>", "Product id or name")
|
|
64
|
+
.requiredOption("--limit <limit>", "Limit meter:window:capacity:enforcement")
|
|
65
|
+
.option("--dry-run", "Validate without mutating")
|
|
66
|
+
.action(async (key, opts) => {
|
|
67
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
68
|
+
const next = parseLimit(opts.limit);
|
|
69
|
+
const limits = Array.isArray(plan.limits)
|
|
70
|
+
? plan.limits
|
|
71
|
+
: [];
|
|
72
|
+
const index = limits.findIndex((limit) => limit.meter === next.meter || limit.dimension === next.meter);
|
|
73
|
+
if (index === -1)
|
|
74
|
+
limits.push(next);
|
|
75
|
+
else
|
|
76
|
+
limits[index] = next;
|
|
77
|
+
plan.limits = limits;
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
plan
|
|
81
|
+
.command("set-credits <key>")
|
|
82
|
+
.option("--product <product>", "Product id or name")
|
|
83
|
+
.requiredOption("--monthly-micros <micros>", "Monthly included credits", parseInteger)
|
|
84
|
+
.option("--dry-run", "Validate without mutating")
|
|
85
|
+
.action(async (key, opts) => {
|
|
86
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
87
|
+
plan.credits = { monthlyIncludedMicros: opts.monthlyMicros };
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
plan
|
|
91
|
+
.command("add-feature <key>")
|
|
92
|
+
.argument("<feature>")
|
|
93
|
+
.option("--product <product>", "Product id or name")
|
|
94
|
+
.option("--dry-run", "Validate without mutating")
|
|
95
|
+
.action(async (key, feature, opts) => {
|
|
96
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
97
|
+
const features = stringArrayAt(plan, "features");
|
|
98
|
+
if (!features.includes(feature))
|
|
99
|
+
features.push(feature);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
plan
|
|
103
|
+
.command("remove-feature <key>")
|
|
104
|
+
.argument("<feature>")
|
|
105
|
+
.option("--product <product>", "Product id or name")
|
|
106
|
+
.option("--dry-run", "Validate without mutating")
|
|
107
|
+
.action(async (key, feature, opts) => {
|
|
108
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
109
|
+
plan.features = stringArrayAt(plan, "features").filter((candidate) => candidate !== feature);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
plan
|
|
113
|
+
.command("remove <key>")
|
|
114
|
+
.option("--product <product>", "Product id or name")
|
|
115
|
+
.option("--dry-run", "Validate without mutating")
|
|
116
|
+
.action(async (key, opts) => {
|
|
117
|
+
const client = getClient();
|
|
118
|
+
const productId = await resolveProductId(client, opts.product);
|
|
119
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
120
|
+
spec.plans = plans(spec).filter((candidate) => candidate.key !== key);
|
|
121
|
+
await updateSpec(program, client, productId, spec, {
|
|
122
|
+
dryRun: opts.dryRun,
|
|
123
|
+
message: `Plan "${key}" removed`,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function buildPlan(key, opts) {
|
|
128
|
+
const monthly = opts.free ? 0 : (opts.priceMonthly ?? 0);
|
|
129
|
+
const plan = {
|
|
130
|
+
key,
|
|
131
|
+
name: opts.name ?? titleize(key),
|
|
132
|
+
free: opts.free === true,
|
|
133
|
+
price: { monthly, currency: "USD" },
|
|
134
|
+
pricing: {
|
|
135
|
+
model: "flat_rate",
|
|
136
|
+
monthlyPriceCents: monthly,
|
|
137
|
+
billingInterval: "month",
|
|
138
|
+
},
|
|
139
|
+
usage: { meters: opts.meter },
|
|
140
|
+
features: opts.feature,
|
|
141
|
+
limits: opts.limit.map(parseLimit),
|
|
142
|
+
};
|
|
143
|
+
if (opts.creditsMonthlyMicros !== undefined) {
|
|
144
|
+
plan.credits = { monthlyIncludedMicros: opts.creditsMonthlyMicros };
|
|
145
|
+
}
|
|
146
|
+
return plan;
|
|
147
|
+
}
|
|
148
|
+
async function mutatePlan(program, client, productArg, key, dryRun, mutate) {
|
|
149
|
+
const productId = await resolveProductId(client, productArg);
|
|
150
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
151
|
+
mutate(findPlan(spec, key));
|
|
152
|
+
await updateSpec(program, client, productId, spec, {
|
|
153
|
+
dryRun,
|
|
154
|
+
message: `Plan "${key}" updated`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function parseLimit(value) {
|
|
158
|
+
const [meter, windowName, capacity, enforcement] = value.split(":");
|
|
159
|
+
if (!meter || !windowName || !capacity) {
|
|
160
|
+
throw new Error("Limit must be meter:window:capacity[:enforce|track]");
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
meter,
|
|
164
|
+
window: { type: "named", name: windowName },
|
|
165
|
+
capacity: Number(capacity),
|
|
166
|
+
enforcement: enforcement ?? "enforce",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function collect(value, previous) {
|
|
170
|
+
previous.push(value);
|
|
171
|
+
return previous;
|
|
172
|
+
}
|
|
173
|
+
function parseInteger(value) {
|
|
174
|
+
const parsed = Number.parseInt(value, 10);
|
|
175
|
+
if (!Number.isFinite(parsed))
|
|
176
|
+
throw new Error(`Invalid integer: ${value}`);
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|
|
179
|
+
function titleize(value) {
|
|
180
|
+
return value
|
|
181
|
+
.split(/[-_]/)
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
184
|
+
.join(" ");
|
|
185
|
+
}
|