@farthershore/cli 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +49 -0
  2. package/dist/index.js +433 -94
  3. package/dist/mcp.js +201 -0
  4. package/package.json +17 -12
  5. package/dist/auth.d.ts +0 -1
  6. package/dist/auth.js +0 -17
  7. package/dist/build-info.d.ts +0 -1
  8. package/dist/build-info.js +0 -10
  9. package/dist/client.d.ts +0 -89
  10. package/dist/client.js +0 -82
  11. package/dist/commands/apply.d.ts +0 -3
  12. package/dist/commands/apply.js +0 -296
  13. package/dist/commands/billing.d.ts +0 -3
  14. package/dist/commands/billing.js +0 -99
  15. package/dist/commands/feature.d.ts +0 -3
  16. package/dist/commands/feature.js +0 -109
  17. package/dist/commands/helpers.d.ts +0 -15
  18. package/dist/commands/helpers.js +0 -93
  19. package/dist/commands/init.d.ts +0 -3
  20. package/dist/commands/init.js +0 -43
  21. package/dist/commands/login.d.ts +0 -2
  22. package/dist/commands/login.js +0 -144
  23. package/dist/commands/meter.d.ts +0 -3
  24. package/dist/commands/meter.js +0 -121
  25. package/dist/commands/plan-transition.d.ts +0 -40
  26. package/dist/commands/plan-transition.js +0 -504
  27. package/dist/commands/plan.d.ts +0 -3
  28. package/dist/commands/plan.js +0 -234
  29. package/dist/commands/product.d.ts +0 -3
  30. package/dist/commands/product.js +0 -137
  31. package/dist/commands/transition.d.ts +0 -3
  32. package/dist/commands/transition.js +0 -80
  33. package/dist/commands/validate.d.ts +0 -28
  34. package/dist/commands/validate.js +0 -216
  35. package/dist/config.d.ts +0 -6
  36. package/dist/config.js +0 -58
  37. package/dist/index.d.ts +0 -2
  38. package/dist/output.d.ts +0 -8
  39. package/dist/output.js +0 -28
  40. package/dist/remediation.d.ts +0 -6
  41. package/dist/remediation.js +0 -53
  42. package/dist/types.d.ts +0 -75
  43. package/dist/types.js +0 -23
@@ -1,504 +0,0 @@
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
- }
@@ -1,3 +0,0 @@
1
- import { Command } from "commander";
2
- import type { ApiClient } from "../client.js";
3
- export declare function registerPlanCommands(program: Command, getClient: () => ApiClient): void;