@farthershore/cli 0.3.7 → 0.3.9
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/auth.js +17 -0
- package/dist/build-info.js +10 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +82 -0
- package/dist/commands/apply.js +296 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +99 -0
- package/dist/commands/feature.d.ts +3 -0
- package/dist/commands/feature.js +109 -0
- package/dist/commands/helpers.d.ts +15 -0
- package/dist/commands/helpers.js +93 -0
- package/dist/commands/init.js +43 -0
- package/dist/commands/login.js +144 -0
- package/dist/commands/meter.d.ts +3 -0
- package/dist/commands/meter.js +121 -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 +234 -0
- package/dist/commands/product.d.ts +3 -0
- package/dist/commands/product.js +137 -0
- package/dist/commands/transition.d.ts +3 -0
- package/dist/commands/transition.js +80 -0
- package/dist/commands/validate.js +216 -0
- package/dist/config.js +58 -0
- package/dist/index.js +102 -1233
- package/dist/output.js +28 -0
- package/dist/remediation.js +53 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +23 -0
- package/package.json +5 -8
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { findPlan, loadAcceptedSpec, plans, printResult, resolveProductId, stringArrayAt, updateSpec, } from "./helpers.js";
|
|
2
|
+
export function registerPlanCommands(program, getClient) {
|
|
3
|
+
const plan = program
|
|
4
|
+
.command("plan")
|
|
5
|
+
.description("Manage free and paid plans in the accepted product config")
|
|
6
|
+
.addHelpText("after", `
|
|
7
|
+
Agent notes:
|
|
8
|
+
Plans are keyed by plan name/key, not by usage type.
|
|
9
|
+
Only one free plan is allowed. Free plans should include a hard enforced limit.
|
|
10
|
+
Duplicate paid prices are rejected server-side. Use --dry-run before risky changes.
|
|
11
|
+
Limit format is meter:window:capacity:enforcement, for example ai_tokens:month:100000:enforce.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
farthershore plan add free --free --limit ai_tokens:month:100000:enforce --feature chat_completions --format json
|
|
15
|
+
farthershore plan add pro --price-monthly 2900 --credits-monthly-micros 500000000 --meter ai_tokens --feature chat_completions --format json
|
|
16
|
+
farthershore plan list --format json
|
|
17
|
+
`);
|
|
18
|
+
plan
|
|
19
|
+
.command("list")
|
|
20
|
+
.option("--product <product>", "Product id or name")
|
|
21
|
+
.addHelpText("after", `
|
|
22
|
+
Example:
|
|
23
|
+
farthershore plan list --format json
|
|
24
|
+
`)
|
|
25
|
+
.action(async (opts) => {
|
|
26
|
+
const client = getClient();
|
|
27
|
+
const productId = await resolveProductId(client, opts.product);
|
|
28
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
29
|
+
printResult(program, "Plans loaded", {
|
|
30
|
+
ok: true,
|
|
31
|
+
productId,
|
|
32
|
+
plans: plans(spec),
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
plan
|
|
36
|
+
.command("add <key>")
|
|
37
|
+
.option("--product <product>", "Product id or name")
|
|
38
|
+
.option("--name <name>", "Plan display name")
|
|
39
|
+
.option("--free", "Create a free hard-limited plan")
|
|
40
|
+
.option("--price-monthly <cents>", "Monthly price in cents", parseInteger)
|
|
41
|
+
.option("--credits-monthly-micros <micros>", "Monthly included credits", parseInteger)
|
|
42
|
+
.option("--meter <meter>", "Included meter; repeatable", collect, [])
|
|
43
|
+
.option("--feature <feature>", "Enabled feature; repeatable", collect, [])
|
|
44
|
+
.option("--limit <limit>", "Limit meter:window:capacity:enforcement; repeatable", collect, [])
|
|
45
|
+
.option("--dry-run", "Validate without mutating")
|
|
46
|
+
.addHelpText("after", `
|
|
47
|
+
Examples:
|
|
48
|
+
farthershore plan add free --free --limit ai_tokens:month:100000:enforce --feature chat_completions --format json
|
|
49
|
+
farthershore plan add pro --price-monthly 2900 --credits-monthly-micros 500000000 --meter ai_tokens --feature chat_completions --format json
|
|
50
|
+
farthershore plan add starter --price-monthly 900 --meter requests --feature weather_read --dry-run --format json
|
|
51
|
+
`)
|
|
52
|
+
.action(async (key, opts) => {
|
|
53
|
+
const client = getClient();
|
|
54
|
+
const productId = await resolveProductId(client, opts.product);
|
|
55
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
56
|
+
const existing = plans(spec);
|
|
57
|
+
const index = existing.findIndex((candidate) => candidate.key === key);
|
|
58
|
+
const nextPlan = buildPlan(key, opts);
|
|
59
|
+
if (index === -1)
|
|
60
|
+
existing.push(nextPlan);
|
|
61
|
+
else
|
|
62
|
+
existing[index] = { ...existing[index], ...nextPlan };
|
|
63
|
+
await updateSpec(program, client, productId, spec, {
|
|
64
|
+
dryRun: opts.dryRun,
|
|
65
|
+
message: `Plan "${key}" saved`,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
plan
|
|
69
|
+
.command("set-price <key>")
|
|
70
|
+
.option("--product <product>", "Product id or name")
|
|
71
|
+
.requiredOption("--monthly <cents>", "Monthly price in cents", parseInteger)
|
|
72
|
+
.option("--currency <currency>", "Currency", "USD")
|
|
73
|
+
.option("--dry-run", "Validate without mutating")
|
|
74
|
+
.addHelpText("after", `
|
|
75
|
+
Example:
|
|
76
|
+
farthershore plan set-price pro --monthly 4900 --dry-run --format json
|
|
77
|
+
`)
|
|
78
|
+
.action(async (key, opts) => {
|
|
79
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
80
|
+
plan.free = false;
|
|
81
|
+
plan.price = { monthly: opts.monthly, currency: opts.currency };
|
|
82
|
+
plan.pricing = {
|
|
83
|
+
model: "flat_rate",
|
|
84
|
+
monthlyPriceCents: opts.monthly,
|
|
85
|
+
billingInterval: "month",
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
plan
|
|
90
|
+
.command("set-limit <key>")
|
|
91
|
+
.option("--product <product>", "Product id or name")
|
|
92
|
+
.requiredOption("--limit <limit>", "Limit meter:window:capacity:enforcement")
|
|
93
|
+
.option("--dry-run", "Validate without mutating")
|
|
94
|
+
.addHelpText("after", `
|
|
95
|
+
Examples:
|
|
96
|
+
farthershore plan set-limit free --limit ai_tokens:month:100000:enforce --format json
|
|
97
|
+
farthershore plan set-limit pro --limit ai_tokens:month:1000000:track --dry-run --format json
|
|
98
|
+
`)
|
|
99
|
+
.action(async (key, opts) => {
|
|
100
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
101
|
+
const next = parseLimit(opts.limit);
|
|
102
|
+
const limits = Array.isArray(plan.limits)
|
|
103
|
+
? plan.limits
|
|
104
|
+
: [];
|
|
105
|
+
const index = limits.findIndex((limit) => limit.meter === next.meter || limit.dimension === next.meter);
|
|
106
|
+
if (index === -1)
|
|
107
|
+
limits.push(next);
|
|
108
|
+
else
|
|
109
|
+
limits[index] = next;
|
|
110
|
+
plan.limits = limits;
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
plan
|
|
114
|
+
.command("set-credits <key>")
|
|
115
|
+
.option("--product <product>", "Product id or name")
|
|
116
|
+
.requiredOption("--monthly-micros <micros>", "Monthly included credits", parseInteger)
|
|
117
|
+
.option("--dry-run", "Validate without mutating")
|
|
118
|
+
.addHelpText("after", `
|
|
119
|
+
Example:
|
|
120
|
+
farthershore plan set-credits pro --monthly-micros 500000000 --format json
|
|
121
|
+
`)
|
|
122
|
+
.action(async (key, opts) => {
|
|
123
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
124
|
+
plan.credits = { monthlyIncludedMicros: opts.monthlyMicros };
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
plan
|
|
128
|
+
.command("add-feature <key>")
|
|
129
|
+
.argument("<feature>")
|
|
130
|
+
.option("--product <product>", "Product id or name")
|
|
131
|
+
.option("--dry-run", "Validate without mutating")
|
|
132
|
+
.addHelpText("after", `
|
|
133
|
+
Example:
|
|
134
|
+
farthershore plan add-feature pro chat_completions --format json
|
|
135
|
+
`)
|
|
136
|
+
.action(async (key, feature, opts) => {
|
|
137
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
138
|
+
const features = stringArrayAt(plan, "features");
|
|
139
|
+
if (!features.includes(feature))
|
|
140
|
+
features.push(feature);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
plan
|
|
144
|
+
.command("remove-feature <key>")
|
|
145
|
+
.argument("<feature>")
|
|
146
|
+
.option("--product <product>", "Product id or name")
|
|
147
|
+
.option("--dry-run", "Validate without mutating")
|
|
148
|
+
.addHelpText("after", `
|
|
149
|
+
Example:
|
|
150
|
+
farthershore plan remove-feature starter chat_completions --dry-run --format json
|
|
151
|
+
`)
|
|
152
|
+
.action(async (key, feature, opts) => {
|
|
153
|
+
await mutatePlan(program, getClient(), opts.product, key, opts.dryRun, (plan) => {
|
|
154
|
+
plan.features = stringArrayAt(plan, "features").filter((candidate) => candidate !== feature);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
plan
|
|
158
|
+
.command("remove <key>")
|
|
159
|
+
.option("--product <product>", "Product id or name")
|
|
160
|
+
.option("--dry-run", "Validate without mutating")
|
|
161
|
+
.addHelpText("after", `
|
|
162
|
+
Example:
|
|
163
|
+
farthershore plan remove starter --dry-run --format json
|
|
164
|
+
`)
|
|
165
|
+
.action(async (key, opts) => {
|
|
166
|
+
const client = getClient();
|
|
167
|
+
const productId = await resolveProductId(client, opts.product);
|
|
168
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
169
|
+
spec.plans = plans(spec).filter((candidate) => candidate.key !== key);
|
|
170
|
+
await updateSpec(program, client, productId, spec, {
|
|
171
|
+
dryRun: opts.dryRun,
|
|
172
|
+
message: `Plan "${key}" removed`,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function buildPlan(key, opts) {
|
|
177
|
+
const monthly = opts.free ? 0 : (opts.priceMonthly ?? 0);
|
|
178
|
+
const plan = {
|
|
179
|
+
key,
|
|
180
|
+
name: opts.name ?? titleize(key),
|
|
181
|
+
free: opts.free === true,
|
|
182
|
+
price: { monthly, currency: "USD" },
|
|
183
|
+
pricing: {
|
|
184
|
+
model: "flat_rate",
|
|
185
|
+
monthlyPriceCents: monthly,
|
|
186
|
+
billingInterval: "month",
|
|
187
|
+
},
|
|
188
|
+
usage: { meters: opts.meter },
|
|
189
|
+
features: opts.feature,
|
|
190
|
+
limits: opts.limit.map(parseLimit),
|
|
191
|
+
};
|
|
192
|
+
if (opts.creditsMonthlyMicros !== undefined) {
|
|
193
|
+
plan.credits = { monthlyIncludedMicros: opts.creditsMonthlyMicros };
|
|
194
|
+
}
|
|
195
|
+
return plan;
|
|
196
|
+
}
|
|
197
|
+
async function mutatePlan(program, client, productArg, key, dryRun, mutate) {
|
|
198
|
+
const productId = await resolveProductId(client, productArg);
|
|
199
|
+
const spec = await loadAcceptedSpec(client, productId);
|
|
200
|
+
mutate(findPlan(spec, key));
|
|
201
|
+
await updateSpec(program, client, productId, spec, {
|
|
202
|
+
dryRun,
|
|
203
|
+
message: `Plan "${key}" updated`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function parseLimit(value) {
|
|
207
|
+
const [meter, windowName, capacity, enforcement] = value.split(":");
|
|
208
|
+
if (!meter || !windowName || !capacity) {
|
|
209
|
+
throw new Error("Limit must be meter:window:capacity[:enforce|track]");
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
meter,
|
|
213
|
+
window: { type: "named", name: windowName },
|
|
214
|
+
capacity: Number(capacity),
|
|
215
|
+
enforcement: enforcement ?? "enforce",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function collect(value, previous) {
|
|
219
|
+
previous.push(value);
|
|
220
|
+
return previous;
|
|
221
|
+
}
|
|
222
|
+
function parseInteger(value) {
|
|
223
|
+
const parsed = Number.parseInt(value, 10);
|
|
224
|
+
if (!Number.isFinite(parsed))
|
|
225
|
+
throw new Error(`Invalid integer: ${value}`);
|
|
226
|
+
return parsed;
|
|
227
|
+
}
|
|
228
|
+
function titleize(value) {
|
|
229
|
+
return value
|
|
230
|
+
.split(/[-_]/)
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
233
|
+
.join(" ");
|
|
234
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { saveConfig } from "../config.js";
|
|
2
|
+
import * as output from "../output.js";
|
|
3
|
+
import { commandFormat, printResult, resolveProductId } from "./helpers.js";
|
|
4
|
+
export function registerProductCommands(program, getClient) {
|
|
5
|
+
const product = program
|
|
6
|
+
.command("product")
|
|
7
|
+
.description("Manage products and inspect the latest accepted internal product config")
|
|
8
|
+
.addHelpText("after", `
|
|
9
|
+
Agent notes:
|
|
10
|
+
Product commands use core APIs, not local files. After product create, the product is selected in ~/.farthershore/config.json.
|
|
11
|
+
GitHub, UI, and CLI edits are proposals. product config/status read the accepted internal config.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
farthershore product create weather-api --base-url https://api.example.com --strategy prepaid_credits --format json
|
|
15
|
+
farthershore product status --format json
|
|
16
|
+
farthershore product config --format yaml
|
|
17
|
+
farthershore product attempts --format json
|
|
18
|
+
`);
|
|
19
|
+
product
|
|
20
|
+
.command("create <name>")
|
|
21
|
+
.description("Create a product, initialize accepted config, and select it for later commands")
|
|
22
|
+
.requiredOption("--base-url <url>", "Backend API base URL")
|
|
23
|
+
.option("--strategy <strategy>", "Product billing strategy: free_trial, flat_subscription, included_usage, subscription_overage, pay_as_you_go, prepaid_credits", "flat_subscription")
|
|
24
|
+
.option("--description <desc>", "Product description")
|
|
25
|
+
.option("--display-name <name>", "Display name")
|
|
26
|
+
.option("--dry-run", "Print the create request without mutating")
|
|
27
|
+
.addHelpText("after", `
|
|
28
|
+
Examples:
|
|
29
|
+
farthershore product create weather-api --base-url https://api.example.com --format json
|
|
30
|
+
farthershore product create ai-gateway --base-url https://api.example.com --strategy prepaid_credits --format json
|
|
31
|
+
|
|
32
|
+
Next commands:
|
|
33
|
+
farthershore meter add --help
|
|
34
|
+
farthershore feature add --help
|
|
35
|
+
farthershore plan add --help
|
|
36
|
+
`)
|
|
37
|
+
.action(async (name, opts) => {
|
|
38
|
+
if (opts.dryRun) {
|
|
39
|
+
printResult(program, "Product create dry run", {
|
|
40
|
+
ok: true,
|
|
41
|
+
dryRun: true,
|
|
42
|
+
proposal: {
|
|
43
|
+
name,
|
|
44
|
+
baseUrl: opts.baseUrl,
|
|
45
|
+
description: opts.description,
|
|
46
|
+
displayName: opts.displayName,
|
|
47
|
+
billingStrategy: opts.strategy,
|
|
48
|
+
subscriberChangePolicy: {
|
|
49
|
+
default: "preserve_current_period",
|
|
50
|
+
proration: "none",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
nextActions: ["Run without --dry-run to create the product"],
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const client = getClient();
|
|
58
|
+
const result = await client.initProduct({
|
|
59
|
+
name,
|
|
60
|
+
baseUrl: opts.baseUrl,
|
|
61
|
+
description: opts.description,
|
|
62
|
+
displayName: opts.displayName,
|
|
63
|
+
billingStrategy: opts.strategy,
|
|
64
|
+
subscriberChangePolicy: {
|
|
65
|
+
default: "preserve_current_period",
|
|
66
|
+
proration: "none",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
saveConfig({
|
|
70
|
+
activeProductId: result.product.id,
|
|
71
|
+
activeProductName: result.product.name,
|
|
72
|
+
});
|
|
73
|
+
printResult(program, `Created product "${result.product.name}"`, {
|
|
74
|
+
ok: true,
|
|
75
|
+
productId: result.product.id,
|
|
76
|
+
product: result.product,
|
|
77
|
+
repo: result.repo,
|
|
78
|
+
githubSyncStatus: result.product
|
|
79
|
+
.acceptedProductSpecGithubSyncStatus ?? null,
|
|
80
|
+
nextActions: [
|
|
81
|
+
"Add meters, features, and plans",
|
|
82
|
+
"Run farthershore apply --dry-run --format json",
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
product
|
|
87
|
+
.command("status")
|
|
88
|
+
.description("Show selected product status")
|
|
89
|
+
.option("--product <product>", "Product id or name")
|
|
90
|
+
.addHelpText("after", `
|
|
91
|
+
Example:
|
|
92
|
+
farthershore product status --format json
|
|
93
|
+
`)
|
|
94
|
+
.action(async (opts) => {
|
|
95
|
+
const client = getClient();
|
|
96
|
+
const productId = await resolveProductId(client, opts.product);
|
|
97
|
+
const config = await client.getProductConfig(productId);
|
|
98
|
+
printResult(program, "Product status loaded", {
|
|
99
|
+
ok: true,
|
|
100
|
+
productId,
|
|
101
|
+
...(typeof config === "object" && config ? config : {}),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
product
|
|
105
|
+
.command("config")
|
|
106
|
+
.description("Show latest accepted internal product config as json or yaml")
|
|
107
|
+
.option("--product <product>", "Product id or name")
|
|
108
|
+
.option("--format <format>", "Output format: json or yaml")
|
|
109
|
+
.addHelpText("after", `
|
|
110
|
+
Examples:
|
|
111
|
+
farthershore product config --format json
|
|
112
|
+
farthershore product config --format yaml
|
|
113
|
+
`)
|
|
114
|
+
.action(async (opts) => {
|
|
115
|
+
const client = getClient();
|
|
116
|
+
const productId = await resolveProductId(client, opts.product);
|
|
117
|
+
const format = opts.format ?? commandFormat(program);
|
|
118
|
+
if (format === "yaml") {
|
|
119
|
+
console.log(await client.getProductConfigYaml(productId));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
console.log(output.json(await client.getProductConfig(productId)));
|
|
123
|
+
});
|
|
124
|
+
product
|
|
125
|
+
.command("attempts")
|
|
126
|
+
.description("Show rejected product config attempts")
|
|
127
|
+
.option("--product <product>", "Product id or name")
|
|
128
|
+
.addHelpText("after", `
|
|
129
|
+
Example:
|
|
130
|
+
farthershore product attempts --format json
|
|
131
|
+
`)
|
|
132
|
+
.action(async (opts) => {
|
|
133
|
+
const client = getClient();
|
|
134
|
+
const productId = await resolveProductId(client, opts.product);
|
|
135
|
+
console.log(output.json(await client.getProductAttempts(productId)));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import * as output from "../output.js";
|
|
5
|
+
import { loadAcceptedSpec, resolveProductId } from "./helpers.js";
|
|
6
|
+
import { analyzePlanTransition } from "./plan-transition.js";
|
|
7
|
+
export function registerTransitionCommands(program, getClient) {
|
|
8
|
+
const transition = program
|
|
9
|
+
.command("transition")
|
|
10
|
+
.description("Preview subscriber impact for product config changes")
|
|
11
|
+
.addHelpText("after", `
|
|
12
|
+
Agent notes:
|
|
13
|
+
New subscribers use accepted config immediately. Existing subscribers follow billing.subscriberChangePolicy.
|
|
14
|
+
Preview before price, feature, limit, credit, rating, or strategy changes.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
farthershore transition preview --format json
|
|
18
|
+
farthershore transition preview --to product.yaml --format json
|
|
19
|
+
`);
|
|
20
|
+
transition
|
|
21
|
+
.command("preview")
|
|
22
|
+
.description("Compare the latest accepted config to a proposed config and show subscriber transition impact")
|
|
23
|
+
.option("--product <product>", "Product id or name")
|
|
24
|
+
.option("--to <file>", "Proposed product YAML file")
|
|
25
|
+
.option("--format <format>", "Output format: table or json")
|
|
26
|
+
.addHelpText("after", `
|
|
27
|
+
Examples:
|
|
28
|
+
farthershore transition preview --format json
|
|
29
|
+
farthershore transition preview --to product.yaml --format json
|
|
30
|
+
|
|
31
|
+
Output includes:
|
|
32
|
+
ok, productId, transitionPreview, errors, warnings, nextActions
|
|
33
|
+
`)
|
|
34
|
+
.action(async (opts) => {
|
|
35
|
+
const client = getClient();
|
|
36
|
+
const productId = await resolveProductId(client, opts.product);
|
|
37
|
+
const accepted = await loadAcceptedSpec(client, productId);
|
|
38
|
+
const proposed = loadProposedSpec(opts.to) ?? accepted;
|
|
39
|
+
const analysis = analyzePlanTransition({
|
|
40
|
+
fromSpec: accepted,
|
|
41
|
+
toSpec: proposed,
|
|
42
|
+
fromLabel: "acceptedProductSpec",
|
|
43
|
+
toLabel: opts.to ?? "acceptedProductSpec",
|
|
44
|
+
});
|
|
45
|
+
const globalOpts = program.opts();
|
|
46
|
+
const format = opts.format ?? output.outputFormat(globalOpts.format);
|
|
47
|
+
if (format === "json") {
|
|
48
|
+
console.log(output.json({
|
|
49
|
+
ok: analysis.valid,
|
|
50
|
+
productId,
|
|
51
|
+
transitionPreview: analysis,
|
|
52
|
+
errors: analysis.errors,
|
|
53
|
+
warnings: analysis.warnings,
|
|
54
|
+
nextActions: analysis.agentHints,
|
|
55
|
+
}));
|
|
56
|
+
if (!analysis.valid)
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (analysis.valid)
|
|
61
|
+
output.success("Transition preview passed");
|
|
62
|
+
else
|
|
63
|
+
output.error("Transition preview failed");
|
|
64
|
+
for (const change of analysis.changes) {
|
|
65
|
+
console.log(` • ${change.message} -> ${change.subscriberAction}`);
|
|
66
|
+
}
|
|
67
|
+
for (const warning of analysis.warnings)
|
|
68
|
+
output.warn(warning);
|
|
69
|
+
for (const error of analysis.errors)
|
|
70
|
+
output.error(error);
|
|
71
|
+
if (!analysis.valid)
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function loadProposedSpec(file) {
|
|
76
|
+
const path = resolve(file ?? "product.yaml");
|
|
77
|
+
if (!existsSync(path))
|
|
78
|
+
return null;
|
|
79
|
+
return YAML.parse(readFileSync(path, "utf-8"));
|
|
80
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { productSpecSchema, } from "@farther-shore/shared-types/plans";
|
|
6
|
+
import * as output from "../output.js";
|
|
7
|
+
const CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
8
|
+
/**
|
|
9
|
+
* Read product.yaml from the main branch using git, without checking it out.
|
|
10
|
+
* Returns null if not in a git repo or main doesn't have the file.
|
|
11
|
+
*/
|
|
12
|
+
function readMainBranchProductName() {
|
|
13
|
+
for (const branch of ["main", "master"]) {
|
|
14
|
+
try {
|
|
15
|
+
const yaml = execSync(`git show ${branch}:product.yaml 2>/dev/null`, {
|
|
16
|
+
encoding: "utf-8",
|
|
17
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
18
|
+
});
|
|
19
|
+
const spec = YAML.parse(yaml);
|
|
20
|
+
const product = spec.product;
|
|
21
|
+
return product?.name ?? null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validate a parsed YAML object against the canonical Zod schema.
|
|
31
|
+
*
|
|
32
|
+
* Errors are returned in the same `Plan "key": message` shape the previous
|
|
33
|
+
* hand-rolled checks produced, so existing CI output stays grep-friendly.
|
|
34
|
+
* Warnings cover product-quality nudges (no free plan, missing display name)
|
|
35
|
+
* that don't fail the schema parse but are worth flagging.
|
|
36
|
+
*/
|
|
37
|
+
export function validateProductYaml(spec) {
|
|
38
|
+
const result = productSpecSchema.safeParse(spec);
|
|
39
|
+
if (!result.success) {
|
|
40
|
+
return {
|
|
41
|
+
valid: false,
|
|
42
|
+
errors: result.error.issues.map((issue) => formatIssue(issue, spec)),
|
|
43
|
+
warnings: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const warnings = deriveWarnings(result.data);
|
|
47
|
+
// Schema doesn't enforce duplicate plan keys (it would conflict with the
|
|
48
|
+
// discriminated-union design). Detect here as a separate check so the CLI
|
|
49
|
+
// surfaces this footgun before the server rejects the apply.
|
|
50
|
+
const dupeKeys = findDuplicatePlanKeys(result.data);
|
|
51
|
+
if (dupeKeys.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
errors: [`Duplicate plan keys: ${dupeKeys.join(", ")}`],
|
|
55
|
+
warnings,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { valid: true, errors: [], warnings };
|
|
59
|
+
}
|
|
60
|
+
function formatIssue(issue, spec) {
|
|
61
|
+
const path = issue.path;
|
|
62
|
+
// Plans are addressed positionally in path (e.g. ["plans", 0, "key"]). Look
|
|
63
|
+
// up the plan key from the spec so the error message reads
|
|
64
|
+
// `Plan "starter": pricing is required` instead of `plans.0.pricing: ...`.
|
|
65
|
+
if (path.length >= 2 && path[0] === "plans" && typeof path[1] === "number") {
|
|
66
|
+
const plans = spec?.plans ?? [];
|
|
67
|
+
const plan = plans[path[1]];
|
|
68
|
+
const planLabel = plan?.key ?? plan?.name ?? `#${path[1]}`;
|
|
69
|
+
const fieldPath = path.slice(2).join(".");
|
|
70
|
+
if (fieldPath) {
|
|
71
|
+
return `Plan "${planLabel}": ${fieldPath} — ${issue.message}`;
|
|
72
|
+
}
|
|
73
|
+
return `Plan "${planLabel}": ${issue.message}`;
|
|
74
|
+
}
|
|
75
|
+
const dotted = path.length > 0 ? path.join(".") : "(root)";
|
|
76
|
+
return `${dotted}: ${issue.message}`;
|
|
77
|
+
}
|
|
78
|
+
function deriveWarnings(parsed) {
|
|
79
|
+
const warnings = [];
|
|
80
|
+
if (parsed.plans.length === 0) {
|
|
81
|
+
// Schema accepts an empty plan list (default), but a product with no
|
|
82
|
+
// plans can't go live — surface as a warning so the builder sees the
|
|
83
|
+
// requirement before pushing.
|
|
84
|
+
warnings.push("No plans declared — product cannot accept signups yet");
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const hasFree = parsed.plans.some((plan) => {
|
|
88
|
+
const monthly = "monthlyPriceCents" in plan.pricing
|
|
89
|
+
? plan.pricing.monthlyPriceCents
|
|
90
|
+
: undefined;
|
|
91
|
+
return monthly === 0;
|
|
92
|
+
});
|
|
93
|
+
if (!hasFree) {
|
|
94
|
+
warnings.push("No free plan — consider adding one for developer adoption");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!parsed.product.displayName ||
|
|
98
|
+
parsed.product.displayName === parsed.product.name) {
|
|
99
|
+
warnings.push("product.displayName not set — using product.name on the pricing page");
|
|
100
|
+
}
|
|
101
|
+
return warnings;
|
|
102
|
+
}
|
|
103
|
+
function findDuplicatePlanKeys(parsed) {
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const dupes = new Set();
|
|
106
|
+
for (const plan of parsed.plans) {
|
|
107
|
+
if (seen.has(plan.key)) {
|
|
108
|
+
dupes.add(plan.key);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
seen.add(plan.key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...dupes];
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read + parse a product.yaml file. Returns either the parsed spec or
|
|
118
|
+
* a structured error result. Extracted from the validate command so
|
|
119
|
+
* the action handler stays under the cognitive-complexity threshold.
|
|
120
|
+
*/
|
|
121
|
+
export function loadProductYaml(filePath) {
|
|
122
|
+
if (!existsSync(filePath)) {
|
|
123
|
+
return { ok: false, reason: "missing", message: filePath };
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const content = readFileSync(filePath, "utf-8");
|
|
127
|
+
return { ok: true, spec: YAML.parse(content) };
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: "parse",
|
|
133
|
+
message: err instanceof Error ? err.message : String(err),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Print a ValidationResult to stdout, optionally emitting GitHub
|
|
139
|
+
* Actions annotations. Sets `process.exitCode = 1` on validation
|
|
140
|
+
* failure so the CLI exits non-zero.
|
|
141
|
+
*/
|
|
142
|
+
function reportValidationResult(result) {
|
|
143
|
+
if (CI) {
|
|
144
|
+
for (const err of result.errors) {
|
|
145
|
+
console.log(`::error file=product.yaml::${err}`);
|
|
146
|
+
}
|
|
147
|
+
for (const w of result.warnings) {
|
|
148
|
+
console.log(`::warning file=product.yaml::${w}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (result.errors.length === 0) {
|
|
152
|
+
output.success("product.yaml is valid");
|
|
153
|
+
for (const w of result.warnings) {
|
|
154
|
+
output.warn(w);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
output.error(`product.yaml has ${result.errors.length} error(s):\n`);
|
|
159
|
+
for (const err of result.errors) {
|
|
160
|
+
console.log(` • ${err}`);
|
|
161
|
+
}
|
|
162
|
+
if (result.warnings.length > 0) {
|
|
163
|
+
console.log();
|
|
164
|
+
for (const w of result.warnings) {
|
|
165
|
+
output.warn(w);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
}
|
|
170
|
+
export function registerValidateCommand(program) {
|
|
171
|
+
program
|
|
172
|
+
.command("validate [file]")
|
|
173
|
+
.description("Validate a local product.yaml file without API calls")
|
|
174
|
+
.addHelpText("after", `
|
|
175
|
+
Agent notes:
|
|
176
|
+
Use validate before committing product.yaml changes. This is local schema validation only.
|
|
177
|
+
Use apply --dry-run for remote compiler validation against pushed branch state.
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
farthershore validate
|
|
181
|
+
farthershore validate product.yaml
|
|
182
|
+
farthershore apply --dry-run --format json
|
|
183
|
+
`)
|
|
184
|
+
// Action body is sync; commander only requires a thenable on
|
|
185
|
+
// .action when await is used. Drop `async` keyword.
|
|
186
|
+
.action((file) => {
|
|
187
|
+
const filePath = resolve(file ?? "product.yaml");
|
|
188
|
+
const loaded = loadProductYaml(filePath);
|
|
189
|
+
if (!loaded.ok) {
|
|
190
|
+
if (loaded.reason === "missing") {
|
|
191
|
+
if (CI)
|
|
192
|
+
console.log(`::error file=product.yaml::File not found: ${loaded.message}`);
|
|
193
|
+
output.error(`File not found: ${loaded.message}`);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
if (CI)
|
|
197
|
+
console.log(`::error file=product.yaml::YAML parse error: ${loaded.message}`);
|
|
198
|
+
output.error(`Failed to parse YAML: ${loaded.message}`);
|
|
199
|
+
}
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const result = validateProductYaml(loaded.spec);
|
|
204
|
+
// Check product.name consistency against main branch
|
|
205
|
+
const currentName = loaded.spec
|
|
206
|
+
?.product?.name;
|
|
207
|
+
if (currentName) {
|
|
208
|
+
const mainName = readMainBranchProductName();
|
|
209
|
+
if (mainName && mainName !== currentName) {
|
|
210
|
+
result.errors.push(`product.name "${currentName}" differs from main branch "${mainName}" — product name must not change`);
|
|
211
|
+
result.valid = false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
reportValidationResult(result);
|
|
215
|
+
});
|
|
216
|
+
}
|