@farthershore/cli 0.3.5 → 0.3.7
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 +30 -4
- package/dist/client.d.ts +17 -29
- package/dist/commands/validate.d.ts +26 -0
- package/dist/config.d.ts +0 -3
- package/dist/index.js +1237 -46
- package/dist/output.d.ts +1 -4
- package/dist/remediation.d.ts +6 -0
- package/dist/types.d.ts +36 -11
- package/package.json +14 -5
- package/dist/auth.js +0 -17
- package/dist/build-info.js +0 -10
- package/dist/client.js +0 -36
- package/dist/commands/apply.js +0 -127
- package/dist/commands/init.js +0 -41
- package/dist/commands/login.js +0 -95
- package/dist/commands/validate.js +0 -190
- package/dist/config.js +0 -76
- package/dist/output.js +0 -47
- package/dist/types.js +0 -9
package/dist/index.js
CHANGED
|
@@ -1,60 +1,1251 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
2
4
|
import { Command } from "commander";
|
|
3
5
|
import { readFile } from "node:fs/promises";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
7
|
+
import { dirname, join as join2 } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/types.ts
|
|
10
|
+
var CliError = class extends Error {
|
|
11
|
+
constructor(message, status, extra) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.name = "CliError";
|
|
15
|
+
this.code = extra?.code;
|
|
16
|
+
this.details = extra?.details;
|
|
17
|
+
}
|
|
18
|
+
status;
|
|
19
|
+
code;
|
|
20
|
+
details;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/client.ts
|
|
24
|
+
function createClient(opts) {
|
|
25
|
+
async function request(method, path, body) {
|
|
26
|
+
const res = await fetch(`${opts.apiUrl}${path}`, {
|
|
27
|
+
method,
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Bearer ${opts.token}`,
|
|
30
|
+
"Content-Type": "application/json"
|
|
31
|
+
},
|
|
32
|
+
body: body ? JSON.stringify(body) : void 0
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const parsed = await res.json().catch(() => null);
|
|
36
|
+
const errEnvelope = parsed && typeof parsed === "object" && parsed.error;
|
|
37
|
+
let message = res.statusText;
|
|
38
|
+
let code;
|
|
39
|
+
let details;
|
|
40
|
+
if (typeof errEnvelope === "string") {
|
|
41
|
+
message = errEnvelope;
|
|
42
|
+
} else if (errEnvelope && typeof errEnvelope === "object") {
|
|
43
|
+
message = errEnvelope.message ?? message;
|
|
44
|
+
code = errEnvelope.code;
|
|
45
|
+
details = errEnvelope.details;
|
|
46
|
+
}
|
|
47
|
+
throw new CliError(message, res.status, { code, details });
|
|
48
|
+
}
|
|
49
|
+
if (res.status === 204) return void 0;
|
|
50
|
+
return res.json();
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
// --- Auth ---
|
|
54
|
+
bootstrap: () => request("POST", "/builder/context/bootstrap"),
|
|
55
|
+
// --- Products ---
|
|
56
|
+
listProducts: () => request("GET", "/products"),
|
|
57
|
+
initProduct: (data) => request("POST", "/products/init", data),
|
|
58
|
+
// --- Compile ---
|
|
59
|
+
compileProduct: (productId, opts2) => request(
|
|
60
|
+
"POST",
|
|
61
|
+
`/products/${productId}/compile`,
|
|
62
|
+
opts2?.branch ? { branch: opts2.branch } : void 0
|
|
63
|
+
),
|
|
64
|
+
// --- Management (maker token) ---
|
|
65
|
+
// Compile the product associated with the token — no product ID needed.
|
|
66
|
+
// Pass `branch` to scope compilation to an env branch's plans.
|
|
67
|
+
managementCompileSelf: (opts2) => request(
|
|
68
|
+
"POST",
|
|
69
|
+
"/management/compile",
|
|
70
|
+
opts2?.branch ? { branch: opts2.branch } : void 0
|
|
71
|
+
),
|
|
72
|
+
managementCompile: (productId, opts2) => request(
|
|
73
|
+
"POST",
|
|
74
|
+
`/management/products/${productId}/compile`,
|
|
75
|
+
opts2?.branch ? { branch: opts2.branch } : void 0
|
|
76
|
+
),
|
|
77
|
+
managementListProducts: () => request("GET", "/management/products"),
|
|
78
|
+
isMakerToken: () => opts.token.startsWith("mk_")
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/config.ts
|
|
83
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
84
|
+
import { homedir } from "node:os";
|
|
85
|
+
import { join } from "node:path";
|
|
86
|
+
|
|
87
|
+
// src/build-info.ts
|
|
88
|
+
var BUILD_API_URL = "https://core.farthershore.com";
|
|
89
|
+
|
|
90
|
+
// src/config.ts
|
|
91
|
+
var CONFIG_DIR = join(homedir(), ".farthershore");
|
|
92
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
93
|
+
var CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
94
|
+
function ensureDir(dir) {
|
|
95
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 448 });
|
|
96
|
+
}
|
|
97
|
+
var DEFAULT_CONFIG = {
|
|
98
|
+
apiUrl: BUILD_API_URL,
|
|
99
|
+
defaultFormat: "table"
|
|
100
|
+
};
|
|
101
|
+
function loadConfig() {
|
|
102
|
+
ensureDir(CONFIG_DIR);
|
|
103
|
+
if (!existsSync(CONFIG_FILE)) return DEFAULT_CONFIG;
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(
|
|
106
|
+
readFileSync(CONFIG_FILE, "utf-8")
|
|
107
|
+
);
|
|
108
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
109
|
+
} catch {
|
|
110
|
+
return DEFAULT_CONFIG;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function saveConfig(config) {
|
|
114
|
+
ensureDir(CONFIG_DIR);
|
|
115
|
+
const current = loadConfig();
|
|
116
|
+
writeFileSync(
|
|
117
|
+
CONFIG_FILE,
|
|
118
|
+
JSON.stringify({ ...current, ...config }, null, 2) + "\n"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
function loadCredentials() {
|
|
122
|
+
if (!existsSync(CREDENTIALS_FILE)) return null;
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8"));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function saveCredentials(creds) {
|
|
130
|
+
ensureDir(CONFIG_DIR);
|
|
131
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2) + "\n", {
|
|
132
|
+
mode: 384
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function clearCredentials() {
|
|
136
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
137
|
+
writeFileSync(CREDENTIALS_FILE, "{}");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/auth.ts
|
|
142
|
+
function resolveToken(overrideToken) {
|
|
143
|
+
if (overrideToken) return overrideToken;
|
|
144
|
+
const envToken = process.env.FARTHERSHORE_TOKEN;
|
|
145
|
+
if (envToken) return envToken;
|
|
146
|
+
const creds = loadCredentials();
|
|
147
|
+
if (creds?.token) return creds.token;
|
|
148
|
+
throw new CliError(
|
|
149
|
+
"Not authenticated. Run `farthershore set-key` or set FARTHERSHORE_TOKEN environment variable."
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/commands/login.ts
|
|
154
|
+
import * as readline from "node:readline/promises";
|
|
155
|
+
|
|
156
|
+
// src/output.ts
|
|
157
|
+
import chalk from "chalk";
|
|
158
|
+
function json(data) {
|
|
159
|
+
return JSON.stringify(data, null, 2);
|
|
160
|
+
}
|
|
161
|
+
function success(msg) {
|
|
162
|
+
console.log(chalk.green(`\u2713 ${msg}`));
|
|
163
|
+
}
|
|
164
|
+
function error(msg) {
|
|
165
|
+
console.error(chalk.red(`\u2717 ${msg}`));
|
|
166
|
+
}
|
|
167
|
+
function warn(msg) {
|
|
168
|
+
console.warn(chalk.yellow(`\u26A0 ${msg}`));
|
|
169
|
+
}
|
|
170
|
+
function info(msg) {
|
|
171
|
+
console.log(chalk.dim(msg));
|
|
172
|
+
}
|
|
173
|
+
function heading(msg) {
|
|
174
|
+
console.log(chalk.bold(msg));
|
|
175
|
+
}
|
|
176
|
+
function isTTY() {
|
|
177
|
+
return process.stdout.isTTY === true;
|
|
178
|
+
}
|
|
179
|
+
function outputFormat(flagFormat) {
|
|
180
|
+
if (flagFormat === "json" || flagFormat === "table") return flagFormat;
|
|
181
|
+
return isTTY() ? "table" : "json";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/commands/login.ts
|
|
185
|
+
function registerAuthCommands(program2) {
|
|
186
|
+
program2.command("set-key [token]").description("Set your API token (interactive or pass as argument)").action(async (tokenArg) => {
|
|
187
|
+
let token = tokenArg?.trim();
|
|
188
|
+
if (!token) {
|
|
189
|
+
if (!process.stdin.isTTY) {
|
|
190
|
+
error(
|
|
191
|
+
"No token provided. Pass it as an argument: farthershore set-key <token>"
|
|
192
|
+
);
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
console.log("Set your FartherShore API token\n");
|
|
197
|
+
console.log(
|
|
198
|
+
" Create a token at https://farthershore.com/settings/tokens\n"
|
|
199
|
+
);
|
|
200
|
+
const rl = readline.createInterface({
|
|
201
|
+
input: process.stdin,
|
|
202
|
+
output: process.stdout
|
|
203
|
+
});
|
|
204
|
+
token = (await rl.question("Token: ")).trim();
|
|
205
|
+
rl.close();
|
|
206
|
+
}
|
|
207
|
+
if (!token) {
|
|
208
|
+
error("No token provided.");
|
|
209
|
+
process.exitCode = 1;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
26
212
|
const config = loadConfig();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
213
|
+
try {
|
|
214
|
+
const client = createClient({ apiUrl: config.apiUrl, token });
|
|
215
|
+
const ctx = await client.bootstrap();
|
|
216
|
+
saveCredentials({
|
|
217
|
+
token,
|
|
218
|
+
orgId: ctx.activeOrganization.id,
|
|
219
|
+
userId: ctx.user.id
|
|
220
|
+
});
|
|
221
|
+
success("Authenticated");
|
|
222
|
+
console.log(
|
|
223
|
+
` Organization: ${ctx.activeOrganization.name ?? ctx.activeOrganization.id}`
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
error("Invalid token. Check it and try again.");
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
program2.command("logout").description("Clear stored credentials").action(() => {
|
|
231
|
+
clearCredentials();
|
|
232
|
+
success("Credentials cleared.");
|
|
233
|
+
});
|
|
234
|
+
program2.command("whoami").description("Show current authentication context").action(async () => {
|
|
235
|
+
const config = loadConfig();
|
|
236
|
+
const formatOpt = program2.opts().format;
|
|
237
|
+
const format = outputFormat(formatOpt);
|
|
238
|
+
try {
|
|
239
|
+
const token = resolveToken();
|
|
240
|
+
const client = createClient({ apiUrl: config.apiUrl, token });
|
|
241
|
+
const ctx = await client.bootstrap();
|
|
242
|
+
const authSource = process.env.FARTHERSHORE_TOKEN ? "env:FARTHERSHORE_TOKEN" : "credentials-file";
|
|
243
|
+
if (format === "json") {
|
|
244
|
+
console.log(
|
|
245
|
+
json({
|
|
246
|
+
organization: {
|
|
247
|
+
id: ctx.activeOrganization.id,
|
|
248
|
+
name: ctx.activeOrganization.name ?? null,
|
|
249
|
+
slug: ctx.activeOrganization.slug ?? null
|
|
250
|
+
},
|
|
251
|
+
user: { id: ctx.user.id },
|
|
252
|
+
apiUrl: config.apiUrl,
|
|
253
|
+
authSource
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
heading("Current Context");
|
|
259
|
+
console.log(
|
|
260
|
+
` Organization: ${ctx.activeOrganization.name ?? ctx.activeOrganization.id}`
|
|
261
|
+
);
|
|
262
|
+
console.log(` API URL: ${config.apiUrl}`);
|
|
263
|
+
if (authSource === "env:FARTHERSHORE_TOKEN") {
|
|
264
|
+
info(" Auth: FARTHERSHORE_TOKEN env var");
|
|
265
|
+
} else {
|
|
266
|
+
info(" Auth: ~/.farthershore/credentials.json");
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
if (format === "json") {
|
|
270
|
+
console.log(json({ authenticated: false }));
|
|
271
|
+
} else {
|
|
272
|
+
error(
|
|
273
|
+
"Not authenticated. Run `farthershore set-key` or set FARTHERSHORE_TOKEN."
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
process.exitCode = 1;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
program2.command("set-url <url>").description("Override the API base URL (for staging/testing)").action((url) => {
|
|
280
|
+
saveConfig({ apiUrl: url });
|
|
281
|
+
success(`API URL set to ${url}`);
|
|
282
|
+
});
|
|
283
|
+
program2.command("reset-url").description("Reset the API URL to production default").action(() => {
|
|
284
|
+
saveConfig({ apiUrl: "https://core.farthershore.com" });
|
|
285
|
+
success("API URL reset to https://core.farthershore.com");
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/commands/init.ts
|
|
290
|
+
function registerInitCommand(program2, getClient2) {
|
|
291
|
+
program2.command("init <name>").description(
|
|
292
|
+
"Create a new product with a GitHub repo for agent-first configuration"
|
|
293
|
+
).option("--base-url <url>", "Backend API base URL").option("--description <desc>", "Product description").option("--display-name <name>", "Display name").action(
|
|
294
|
+
async (name, opts) => {
|
|
295
|
+
const client = getClient2();
|
|
296
|
+
const result = await client.initProduct({
|
|
297
|
+
name,
|
|
298
|
+
baseUrl: opts.baseUrl,
|
|
299
|
+
description: opts.description,
|
|
300
|
+
displayName: opts.displayName
|
|
301
|
+
});
|
|
302
|
+
const formatOpt = program2.opts().format;
|
|
303
|
+
const format = outputFormat(formatOpt);
|
|
304
|
+
if (format === "json") {
|
|
305
|
+
console.log(json(result));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
success(`Created product "${result.product.name}" (DRAFT)`);
|
|
309
|
+
console.log();
|
|
310
|
+
if (result.repo) {
|
|
311
|
+
console.log(` Repository: ${result.repo.htmlUrl}`);
|
|
312
|
+
console.log(` Clone: git clone ${result.repo.cloneUrl}`);
|
|
313
|
+
console.log();
|
|
314
|
+
}
|
|
315
|
+
console.log(" Next steps:");
|
|
316
|
+
console.log(" 1. Clone the repository");
|
|
317
|
+
console.log(
|
|
318
|
+
" 2. Read AGENTS.md for the full configuration reference"
|
|
319
|
+
);
|
|
320
|
+
console.log(
|
|
321
|
+
" 3. Edit product.yaml \u2014 add your base URL, plans, and meters"
|
|
322
|
+
);
|
|
323
|
+
console.log(
|
|
324
|
+
" 4. Push to main \u2014 a valid config goes live automatically"
|
|
325
|
+
);
|
|
326
|
+
console.log();
|
|
327
|
+
if (result.agent.agentsMdUrl) {
|
|
328
|
+
console.log(` Docs: ${result.agent.agentsMdUrl}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/commands/validate.ts
|
|
335
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
|
|
336
|
+
import { execSync } from "node:child_process";
|
|
337
|
+
import { resolve } from "node:path";
|
|
338
|
+
import YAML from "yaml";
|
|
339
|
+
|
|
340
|
+
// node_modules/@farther-shore/shared-types/dist/plans/limits-schema.js
|
|
341
|
+
import { z } from "zod";
|
|
342
|
+
var limitDimensionSchema = z.string().min(1);
|
|
343
|
+
var namedWindowSchema = z.object({
|
|
344
|
+
type: z.literal("named"),
|
|
345
|
+
name: z.enum(["second", "minute", "hour", "day", "week", "month"])
|
|
346
|
+
});
|
|
347
|
+
var customWindowSchema = z.object({
|
|
348
|
+
type: z.literal("custom"),
|
|
349
|
+
seconds: z.number().int().positive(),
|
|
350
|
+
label: z.string().optional()
|
|
351
|
+
});
|
|
352
|
+
var limitWindowSpecSchema = z.discriminatedUnion("type", [
|
|
353
|
+
namedWindowSchema,
|
|
354
|
+
customWindowSchema
|
|
355
|
+
]);
|
|
356
|
+
var planLimitRuleSchema = z.object({
|
|
357
|
+
dimension: limitDimensionSchema,
|
|
358
|
+
window: limitWindowSpecSchema,
|
|
359
|
+
capacity: z.number().nonnegative(),
|
|
360
|
+
enforcement: z.enum(["enforce", "track"]).optional()
|
|
361
|
+
});
|
|
362
|
+
var planLimitsSchema = z.array(planLimitRuleSchema).max(20);
|
|
363
|
+
|
|
364
|
+
// node_modules/@farther-shore/shared-types/dist/plans/spec.js
|
|
365
|
+
import { z as z3 } from "zod";
|
|
366
|
+
|
|
367
|
+
// node_modules/@farther-shore/shared-types/dist/webhooks/events.js
|
|
368
|
+
import { z as z2 } from "zod";
|
|
369
|
+
var WEBHOOK_EVENT_NAMES = [
|
|
370
|
+
"subscription.created",
|
|
371
|
+
"subscription.updated",
|
|
372
|
+
"subscription.canceled",
|
|
373
|
+
"payment.succeeded",
|
|
374
|
+
"payment.failed",
|
|
375
|
+
"rate_limit.exceeded",
|
|
376
|
+
"entitlement.changed",
|
|
377
|
+
"usage.threshold_reached"
|
|
378
|
+
];
|
|
379
|
+
var webhookEventNameSchema = z2.enum(WEBHOOK_EVENT_NAMES);
|
|
380
|
+
|
|
381
|
+
// node_modules/@farther-shore/shared-types/dist/plans/spec.js
|
|
382
|
+
var productIdentitySchema = z3.object({
|
|
383
|
+
subdomain: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "Subdomain must be lowercase alphanumeric with optional hyphens")
|
|
384
|
+
});
|
|
385
|
+
var meterDefinitionSchema = z3.object({
|
|
386
|
+
key: z3.string().min(1).max(64).regex(/^[a-z0-9_]+$/, "Meter key must be lowercase alphanumeric with underscores"),
|
|
387
|
+
display: z3.string().min(1).max(100),
|
|
388
|
+
type: z3.enum(["built-in", "custom"]).default("custom"),
|
|
389
|
+
unit: z3.string().max(20).optional()
|
|
390
|
+
});
|
|
391
|
+
var pricingTierSchema = z3.object({
|
|
392
|
+
upToAmount: z3.number().int().positive().nullable(),
|
|
393
|
+
unitPrice: z3.number().nonnegative(),
|
|
394
|
+
flatPrice: z3.number().nonnegative().optional()
|
|
395
|
+
});
|
|
396
|
+
var meteredDimensionSchema = z3.object({
|
|
397
|
+
dimension: z3.string().min(1),
|
|
398
|
+
includedUnits: z3.number().int().nonnegative().default(0),
|
|
399
|
+
overagePerUnitMicrocents: z3.number().int().nonnegative().default(0),
|
|
400
|
+
tiers: z3.array(pricingTierSchema).optional(),
|
|
401
|
+
pricingMode: z3.enum(["graduated", "volume"]).optional()
|
|
402
|
+
});
|
|
403
|
+
var legacyPlanPricingBaseSchema = {
|
|
404
|
+
monthlyPriceCents: z3.number().int().nonnegative().default(0),
|
|
405
|
+
billingInterval: z3.enum(["month", "year"]).default("month"),
|
|
406
|
+
trialDays: z3.number().int().nonnegative().optional(),
|
|
407
|
+
monthlyBudgetCents: z3.number().int().positive().optional(),
|
|
408
|
+
// F5 (Verification Loop 1): explicit Stripe price ref. When set, the
|
|
409
|
+
// compiler's `enforce-variant-rules` invariant 7 verifies that a
|
|
410
|
+
// variant's stripePriceId differs from its parent's — a variant
|
|
411
|
+
// sharing the parent's Stripe price would cause double-charges on
|
|
412
|
+
// the variant cohort. Optional because variants can also be
|
|
413
|
+
// ensure-or-create'd by the Stripe-publish step (Phase B), in which
|
|
414
|
+
// case the lineage uses the auto-generated price refs.
|
|
415
|
+
stripePriceId: z3.string().min(1).optional()
|
|
416
|
+
};
|
|
417
|
+
var measureExpressionSchema = z3.object({
|
|
418
|
+
expr: z3.string().min(1).max(1e3)
|
|
419
|
+
});
|
|
420
|
+
var configurableUsageTierSchema = z3.object({
|
|
421
|
+
upTo: z3.number().positive().nullable(),
|
|
422
|
+
unitAmountMicros: z3.number().int().nonnegative()
|
|
423
|
+
});
|
|
424
|
+
var configurableUsagePriceSchema = z3.discriminatedUnion("kind", [
|
|
425
|
+
z3.object({
|
|
426
|
+
kind: z3.literal("flat"),
|
|
427
|
+
amountMicros: z3.number().int().nonnegative()
|
|
428
|
+
}),
|
|
429
|
+
z3.object({
|
|
430
|
+
kind: z3.literal("per_unit"),
|
|
431
|
+
unitAmountMicros: z3.number().int().nonnegative(),
|
|
432
|
+
unit: z3.string().max(20).optional()
|
|
433
|
+
}),
|
|
434
|
+
z3.object({
|
|
435
|
+
kind: z3.literal("graduated"),
|
|
436
|
+
intraRequest: z3.boolean().default(false),
|
|
437
|
+
tiers: z3.array(configurableUsageTierSchema).min(1)
|
|
438
|
+
}),
|
|
439
|
+
z3.object({
|
|
440
|
+
kind: z3.literal("volume_retroactive"),
|
|
441
|
+
tiers: z3.array(configurableUsageTierSchema).min(1)
|
|
442
|
+
}),
|
|
443
|
+
z3.object({
|
|
444
|
+
kind: z3.literal("formula"),
|
|
445
|
+
expr: z3.string().min(1).max(1e3)
|
|
446
|
+
})
|
|
447
|
+
]);
|
|
448
|
+
var configurableUsageDiscountSchema = z3.discriminatedUnion("kind", [
|
|
449
|
+
z3.object({
|
|
450
|
+
kind: z3.literal("percentage"),
|
|
451
|
+
basisPoints: z3.number().int().positive().max(1e4),
|
|
452
|
+
appliesToDimensions: z3.array(z3.string().min(1)).optional()
|
|
453
|
+
}),
|
|
454
|
+
z3.object({
|
|
455
|
+
kind: z3.literal("fixed_micros"),
|
|
456
|
+
amountMicros: z3.number().int().nonnegative(),
|
|
457
|
+
appliesToDimensions: z3.array(z3.string().min(1)).optional()
|
|
458
|
+
})
|
|
459
|
+
]);
|
|
460
|
+
var configurableUsageCommitmentsSchema = z3.object({
|
|
461
|
+
minimumMonthlySpendMicros: z3.number().int().nonnegative().optional(),
|
|
462
|
+
includedCreditMicros: z3.number().int().nonnegative().optional()
|
|
463
|
+
}).refine((value) => value.minimumMonthlySpendMicros !== void 0 || value.includedCreditMicros !== void 0, {
|
|
464
|
+
message: "commitments must declare at least one of minimumMonthlySpendMicros or includedCreditMicros"
|
|
465
|
+
});
|
|
466
|
+
var configurableUsageReportingSchema = z3.object({
|
|
467
|
+
mode: z3.literal("builder_attested"),
|
|
468
|
+
header: z3.string().min(1).max(100).optional(),
|
|
469
|
+
signatureHeader: z3.string().min(1).max(100).optional(),
|
|
470
|
+
schema: z3.record(z3.string(), z3.enum(["int", "float", "string", "boolean"]))
|
|
471
|
+
});
|
|
472
|
+
var configurableUsageRateCardEntrySchema = z3.object({
|
|
473
|
+
dimension: z3.string().min(1),
|
|
474
|
+
unit: z3.string().max(20).optional(),
|
|
475
|
+
measure: measureExpressionSchema,
|
|
476
|
+
price: configurableUsagePriceSchema
|
|
477
|
+
});
|
|
478
|
+
var planPricingSchema = z3.discriminatedUnion("model", [
|
|
479
|
+
z3.object({
|
|
480
|
+
model: z3.literal("flat_rate"),
|
|
481
|
+
...legacyPlanPricingBaseSchema
|
|
482
|
+
}),
|
|
483
|
+
z3.object({
|
|
484
|
+
model: z3.literal("included_usage"),
|
|
485
|
+
...legacyPlanPricingBaseSchema
|
|
486
|
+
}),
|
|
487
|
+
z3.object({
|
|
488
|
+
model: z3.literal("pay_as_you_go"),
|
|
489
|
+
...legacyPlanPricingBaseSchema
|
|
490
|
+
}),
|
|
491
|
+
z3.object({
|
|
492
|
+
model: z3.literal("configurable_usage"),
|
|
493
|
+
billingInterval: z3.enum(["month", "year"]).default("month"),
|
|
494
|
+
trialDays: z3.number().int().nonnegative().optional(),
|
|
495
|
+
rateCard: z3.array(configurableUsageRateCardEntrySchema).min(1),
|
|
496
|
+
discounts: z3.array(configurableUsageDiscountSchema).default([]),
|
|
497
|
+
commitments: configurableUsageCommitmentsSchema.optional(),
|
|
498
|
+
reporting: configurableUsageReportingSchema.optional(),
|
|
499
|
+
// F5 (Verification Loop 1): see legacyPlanPricingBaseSchema —
|
|
500
|
+
// configurable_usage plans expose the same optional Stripe price
|
|
501
|
+
// ref so invariant 7 has something to compare on variants of
|
|
502
|
+
// configurable_usage parents.
|
|
503
|
+
stripePriceId: z3.string().min(1).optional()
|
|
504
|
+
})
|
|
505
|
+
]);
|
|
506
|
+
var proRationOnRollbackSchema = z3.enum(["NONE", "PRORATE", "CREDIT"]).default("NONE");
|
|
507
|
+
var planVariantSchema = z3.object({
|
|
508
|
+
/**
|
|
509
|
+
* Stable variant id — used as the second half of the CompiledPlan
|
|
510
|
+
* lineage key, so changing it counts as create-new + archive-old.
|
|
511
|
+
* Lower-case kebab-case to match URL-share-link readability.
|
|
512
|
+
*/
|
|
513
|
+
id: z3.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Variant id must be lowercase alphanumeric with hyphens/underscores"),
|
|
514
|
+
/** Human-readable label for dashboards / observability. */
|
|
515
|
+
label: z3.string().max(200).optional(),
|
|
516
|
+
/**
|
|
517
|
+
* Rollout percentage (0-100). 0 = paused (variant exists but no new
|
|
518
|
+
* assignments); 100 = full takeover (effectively a forced graduation
|
|
519
|
+
* for new subscribers, but legacy subs stay on parent until period end).
|
|
520
|
+
*/
|
|
521
|
+
rolloutPercent: z3.number().int().min(0).max(100),
|
|
522
|
+
/**
|
|
523
|
+
* Seed for the deterministic hash function. Rotating the seed
|
|
524
|
+
* invalidates existing variant assignments — useful for re-running an
|
|
525
|
+
* experiment with a fresh cohort.
|
|
526
|
+
*/
|
|
527
|
+
assignmentSeed: z3.string().min(1).max(100).default("default"),
|
|
528
|
+
/**
|
|
529
|
+
* What happens to billing when this variant gets rolled back AND the
|
|
530
|
+
* subscriber has already been billed for the experimental price:
|
|
531
|
+
* - NONE (default): next period bills at parent price; no refund
|
|
532
|
+
* - PRORATE: Stripe `proration_behavior=create_prorations` → mid-period credit
|
|
533
|
+
* - CREDIT: full credit for the variant-priced charge as customer balance
|
|
534
|
+
* Out-of-scope: auto-refund. Builders use `billing.refund` if needed.
|
|
535
|
+
*/
|
|
536
|
+
prorationOnRollback: proRationOnRollbackSchema,
|
|
537
|
+
// Optional overrides — missing fields inherit from the parent plan.
|
|
538
|
+
pricing: planPricingSchema.optional(),
|
|
539
|
+
limits: z3.array(planLimitRuleSchema).max(20).optional(),
|
|
540
|
+
featureGates: z3.record(z3.string(), z3.boolean()).optional(),
|
|
541
|
+
overageBehavior: z3.enum(["block", "allow_and_bill"]).optional(),
|
|
542
|
+
meteredDimensions: z3.array(meteredDimensionSchema).optional()
|
|
543
|
+
});
|
|
544
|
+
var planSpecSchema = z3.object({
|
|
545
|
+
key: z3.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Plan key must be lowercase alphanumeric with hyphens/underscores"),
|
|
546
|
+
name: z3.string().min(1).max(100),
|
|
547
|
+
description: z3.string().max(500).optional(),
|
|
548
|
+
details: z3.array(z3.string().max(200)).max(10).optional(),
|
|
549
|
+
pricing: planPricingSchema,
|
|
550
|
+
limits: z3.array(planLimitRuleSchema).max(20).default([]),
|
|
551
|
+
featureGates: z3.record(z3.string(), z3.boolean()).optional(),
|
|
552
|
+
overageBehavior: z3.enum(["block", "allow_and_bill"]).default("block"),
|
|
553
|
+
selfServeEnabled: z3.boolean().default(true),
|
|
554
|
+
meteredDimensions: z3.array(meteredDimensionSchema).optional(),
|
|
555
|
+
/**
|
|
556
|
+
* Phase A0 — multi-stable plan support.
|
|
557
|
+
*
|
|
558
|
+
* `legacy: true` marks a plan version that should be kept alive
|
|
559
|
+
* indefinitely for the existing subscriber cohort, alongside a new
|
|
560
|
+
* ACTIVE plan in the same lineage. Compiles into `CompiledPlan` with
|
|
561
|
+
* status `LEGACY_STABLE`. The plan is hidden from the public Pricing
|
|
562
|
+
* page; new subscribers cannot join. Removing a `legacy: true` plan
|
|
563
|
+
* from YAML while subs are pinned to it is a compile error (would
|
|
564
|
+
* orphan the cohort).
|
|
565
|
+
*/
|
|
566
|
+
legacy: z3.boolean().optional().default(false),
|
|
567
|
+
/**
|
|
568
|
+
* Phase A0 — A/B testing variants of this plan. Each variant compiles
|
|
569
|
+
* into a sibling `CompiledPlan` with status `EXPERIMENTAL` and
|
|
570
|
+
* `experimentParentId` pointing at this plan's CompiledPlan.
|
|
571
|
+
*
|
|
572
|
+
* Constraints (enforced at compile time):
|
|
573
|
+
* - Cannot coexist with `legacy: true` on the same plan
|
|
574
|
+
* - Variant `id` must be unique within the variants array
|
|
575
|
+
* - Variant `pricing.stripePriceId` must differ from parent's
|
|
576
|
+
*/
|
|
577
|
+
variants: z3.array(planVariantSchema).max(4).optional(),
|
|
578
|
+
archive: z3.object({
|
|
579
|
+
at: z3.string().datetime().optional(),
|
|
580
|
+
transitionTo: z3.string().optional(),
|
|
581
|
+
strategy: z3.enum(["auto", "explicit", "block"]).default("auto")
|
|
582
|
+
}).optional()
|
|
583
|
+
});
|
|
584
|
+
var WEBHOOK_SECRET_PLACEHOLDER_PATTERN = /^\$\{[A-Z][A-Z0-9_]{0,127}\}$/;
|
|
585
|
+
var webhookSecretSchema = z3.string().min(3).max(200).refine((value) => WEBHOOK_SECRET_PLACEHOLDER_PATTERN.test(value), {
|
|
586
|
+
message: "secret must use ${VAR} interpolation syntax (e.g. ${WEBHOOK_SECRET}); raw secrets in YAML are rejected. Define the env var in the per-product secret store and reference it here."
|
|
587
|
+
});
|
|
588
|
+
var webhookRetryPolicySchema = z3.object({
|
|
589
|
+
maxAttempts: z3.number().int().min(1).max(20).default(5),
|
|
590
|
+
backoff: z3.enum(["exponential", "fixed"]).default("exponential")
|
|
591
|
+
});
|
|
592
|
+
var webhookEndpointSchema = z3.object({
|
|
593
|
+
/**
|
|
594
|
+
* Stable endpoint id — used as the third key of the
|
|
595
|
+
* `(productId, environmentId, id)` uniqueness tuple. Idempotent upsert
|
|
596
|
+
* keys on this id; renaming an id is delete + recreate.
|
|
597
|
+
*/
|
|
598
|
+
id: z3.string().min(1).max(64).regex(/^[a-z0-9_-]+$/, "Webhook endpoint id must be lowercase alphanumeric with hyphens/underscores"),
|
|
599
|
+
/** Public HTTPS URL the dispatcher POSTs to. */
|
|
600
|
+
url: z3.string().url("webhooks.endpoints[].url must be a valid URL"),
|
|
601
|
+
/**
|
|
602
|
+
* Signing secret. MUST be a `${VAR}` placeholder; raw secrets in YAML
|
|
603
|
+
* are rejected by invariant 8. The seal pass resolves this against the
|
|
604
|
+
* per-product secret store at compile time and stamps the resolved
|
|
605
|
+
* value onto the WebhookEndpoint row.
|
|
606
|
+
*/
|
|
607
|
+
secret: webhookSecretSchema,
|
|
608
|
+
/**
|
|
609
|
+
* Subset of the central event catalog this endpoint subscribes to.
|
|
610
|
+
* Each value is validated against `webhookEventNameSchema` —
|
|
611
|
+
* unknown events fail invariant 9.
|
|
612
|
+
*/
|
|
613
|
+
events: z3.array(webhookEventNameSchema).min(1, "webhooks.endpoints[].events must subscribe to \u2265 1 event"),
|
|
614
|
+
enabled: z3.boolean().default(true),
|
|
615
|
+
retryPolicy: webhookRetryPolicySchema.default({
|
|
616
|
+
maxAttempts: 5,
|
|
617
|
+
backoff: "exponential"
|
|
618
|
+
})
|
|
619
|
+
});
|
|
620
|
+
var webhooksBlockSchema = z3.object({
|
|
621
|
+
endpoints: z3.array(webhookEndpointSchema).max(50).default([])
|
|
622
|
+
});
|
|
623
|
+
var planOverrideSchema = z3.object({
|
|
624
|
+
pricing: planPricingSchema.optional(),
|
|
625
|
+
limits: z3.array(planLimitRuleSchema).max(20).optional(),
|
|
626
|
+
featureGates: z3.record(z3.string(), z3.boolean()).optional(),
|
|
627
|
+
overageBehavior: z3.enum(["block", "allow_and_bill"]).optional(),
|
|
628
|
+
selfServeEnabled: z3.boolean().optional(),
|
|
629
|
+
meteredDimensions: z3.array(meteredDimensionSchema).optional(),
|
|
630
|
+
legacy: z3.boolean().optional()
|
|
631
|
+
}).strict();
|
|
632
|
+
var webhookEndpointOverrideSchema = z3.object({
|
|
633
|
+
url: z3.string().url().optional(),
|
|
634
|
+
secret: webhookSecretSchema.optional(),
|
|
635
|
+
events: z3.array(webhookEventNameSchema).optional(),
|
|
636
|
+
enabled: z3.boolean().optional(),
|
|
637
|
+
retryPolicy: webhookRetryPolicySchema.partial().optional()
|
|
638
|
+
}).strict();
|
|
639
|
+
var environmentOverrideBlockSchema = z3.object({
|
|
640
|
+
plans: z3.record(z3.string(), planOverrideSchema).optional(),
|
|
641
|
+
webhooks: z3.object({
|
|
642
|
+
endpoints: z3.record(z3.string(), webhookEndpointOverrideSchema).optional()
|
|
643
|
+
}).strict().optional()
|
|
644
|
+
}).strict();
|
|
645
|
+
var environmentsBlockSchema = z3.record(z3.string().min(1).max(64), environmentOverrideBlockSchema);
|
|
646
|
+
var productSpecSchema = z3.object({
|
|
647
|
+
product: z3.object({
|
|
648
|
+
name: z3.string().min(1).max(100),
|
|
649
|
+
displayName: z3.string().max(200).optional(),
|
|
650
|
+
description: z3.string().max(2e3).optional(),
|
|
651
|
+
baseUrl: z3.string().url("baseUrl must be a valid URL"),
|
|
652
|
+
sandboxBaseUrl: z3.string().url("sandboxBaseUrl must be a valid URL").optional(),
|
|
653
|
+
visibility: z3.enum(["public", "private"]).default("public"),
|
|
654
|
+
// Branding
|
|
655
|
+
logoUrl: z3.string().url().optional(),
|
|
656
|
+
primaryColor: z3.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
657
|
+
// Environment
|
|
658
|
+
envBranchPrefix: z3.string().max(50).nullable().optional()
|
|
659
|
+
}),
|
|
660
|
+
gateway: z3.object({
|
|
661
|
+
authHeader: z3.string().min(1).max(100).default("x-api-key"),
|
|
662
|
+
upstreamAuth: z3.object({
|
|
663
|
+
type: z3.enum(["none", "static_bearer"]),
|
|
664
|
+
token: z3.string().optional()
|
|
665
|
+
}).default({ type: "none" })
|
|
666
|
+
}),
|
|
667
|
+
metering: z3.object({
|
|
668
|
+
meters: z3.array(meterDefinitionSchema).min(1).max(10),
|
|
669
|
+
billOn4xx: z3.boolean().default(false)
|
|
670
|
+
}),
|
|
671
|
+
billing: z3.object({
|
|
672
|
+
strategy: z3.enum(["subscription", "usage_based", "hybrid"]),
|
|
673
|
+
gracePeriodDays: z3.number().int().nonnegative().default(3)
|
|
674
|
+
}),
|
|
675
|
+
plans: z3.array(planSpecSchema).max(4).default([]),
|
|
676
|
+
/**
|
|
677
|
+
* Phase B0 — Platform-as-Backend mode. Optional top-level webhook
|
|
678
|
+
* subscription block. Compiles into `WebhookEndpoint` rows via the
|
|
679
|
+
* `emit-webhooks` pass (idempotent upsert, soft-delete on absence).
|
|
680
|
+
*
|
|
681
|
+
* Once a product has compiled with a `webhooks` block, API mutations
|
|
682
|
+
* on those endpoints fail with `409 MANAGED_BY_YAML` — see
|
|
683
|
+
* `core/src/routes/management-webhooks.ts`. Tie-breaker: YAML wins
|
|
684
|
+
* over API state if an endpoint id appears in both.
|
|
685
|
+
*/
|
|
686
|
+
webhooks: webhooksBlockSchema.optional(),
|
|
687
|
+
/**
|
|
688
|
+
* Phase B0 — Per-environment overrides. Deep-merges onto the base
|
|
689
|
+
* spec at compile time, scoped by environment id (resolved from the
|
|
690
|
+
* pushing branch via `branch-environment-resolver.ts`).
|
|
691
|
+
*
|
|
692
|
+
* Out-of-scope: overriding `customerAuthStrategy` is not allowed —
|
|
693
|
+
* that field stays provisioner-controlled.
|
|
694
|
+
*/
|
|
695
|
+
environments: environmentsBlockSchema.optional()
|
|
696
|
+
});
|
|
697
|
+
var productPhaseSchema = z3.object({
|
|
698
|
+
product: productSpecSchema.shape.product
|
|
699
|
+
});
|
|
700
|
+
var gatewayPhaseSchema = z3.object({
|
|
701
|
+
gateway: productSpecSchema.shape.gateway
|
|
702
|
+
});
|
|
703
|
+
var meteringPhaseSchema = z3.object({
|
|
704
|
+
metering: productSpecSchema.shape.metering
|
|
705
|
+
});
|
|
706
|
+
var plansPhaseSchema = z3.object({
|
|
707
|
+
plans: productSpecSchema.shape.plans
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// src/commands/validate.ts
|
|
711
|
+
var CI = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
712
|
+
function readMainBranchProductName() {
|
|
713
|
+
for (const branch of ["main", "master"]) {
|
|
714
|
+
try {
|
|
715
|
+
const yaml = execSync(`git show ${branch}:product.yaml 2>/dev/null`, {
|
|
716
|
+
encoding: "utf-8",
|
|
717
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
718
|
+
});
|
|
719
|
+
const spec = YAML.parse(yaml);
|
|
720
|
+
const product = spec.product;
|
|
721
|
+
return product?.name ?? null;
|
|
722
|
+
} catch {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
function validateProductYaml(spec) {
|
|
729
|
+
const rawErrors = validateBillingPolicyGuards(spec);
|
|
730
|
+
if (rawErrors.length > 0) {
|
|
731
|
+
return { valid: false, errors: rawErrors, warnings: [] };
|
|
732
|
+
}
|
|
733
|
+
const result = productSpecSchema.safeParse(spec);
|
|
734
|
+
if (!result.success) {
|
|
735
|
+
return {
|
|
736
|
+
valid: false,
|
|
737
|
+
errors: result.error.issues.map((issue) => formatIssue(issue, spec)),
|
|
738
|
+
warnings: []
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
const warnings = deriveWarnings(result.data);
|
|
742
|
+
const dupeKeys = findDuplicatePlanKeys(result.data);
|
|
743
|
+
if (dupeKeys.length > 0) {
|
|
744
|
+
return {
|
|
745
|
+
valid: false,
|
|
746
|
+
errors: [`Duplicate plan keys: ${dupeKeys.join(", ")}`],
|
|
747
|
+
warnings
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const duplicatePrices = findDuplicatePaidPrices(spec);
|
|
751
|
+
if (duplicatePrices.length > 0) {
|
|
752
|
+
return {
|
|
753
|
+
valid: false,
|
|
754
|
+
errors: duplicatePrices.map(
|
|
755
|
+
(price) => `Duplicate paid plan price: ${price}`
|
|
756
|
+
),
|
|
757
|
+
warnings
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
return { valid: true, errors: [], warnings };
|
|
761
|
+
}
|
|
762
|
+
function validateBillingPolicyGuards(spec) {
|
|
763
|
+
const errors = [];
|
|
764
|
+
if (!isRecord(spec)) return errors;
|
|
765
|
+
if ("usagePricing" in spec) {
|
|
766
|
+
errors.push(
|
|
767
|
+
"usagePricing is not supported; define usage.meters.*.rating instead"
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
const billing = isRecord(spec.billing) ? spec.billing : void 0;
|
|
771
|
+
const policy = isRecord(billing?.subscriberChangePolicy) ? billing.subscriberChangePolicy : void 0;
|
|
772
|
+
const when = isRecord(policy?.when) ? policy.when : {};
|
|
773
|
+
if (isImmediateAction(when.price_increase) && policy?.allowImmediatePriceIncrease !== true) {
|
|
774
|
+
errors.push(
|
|
775
|
+
"billing.subscriberChangePolicy.when.price_increase cannot switch immediately without allowImmediatePriceIncrease: true"
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
for (const key of [
|
|
779
|
+
"feature_removed",
|
|
780
|
+
"limit_reduced",
|
|
781
|
+
"credit_reduced"
|
|
782
|
+
]) {
|
|
783
|
+
if (isImmediateAction(when[key]) && policy?.allowImmediateEntitlementReduction !== true) {
|
|
784
|
+
errors.push(
|
|
785
|
+
`billing.subscriberChangePolicy.when.${key} cannot switch immediately without allowImmediateEntitlementReduction: true`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const plans = Array.isArray(spec.plans) ? spec.plans : [];
|
|
790
|
+
const freePlans = plans.filter(
|
|
791
|
+
(plan) => isRecord(plan) && plan.free === true
|
|
792
|
+
);
|
|
793
|
+
if (freePlans.length > 1) {
|
|
794
|
+
errors.push("Only one free plan is allowed per product");
|
|
795
|
+
}
|
|
796
|
+
for (const plan of freePlans) {
|
|
797
|
+
if (!isRecord(plan)) continue;
|
|
798
|
+
const key = typeof plan.key === "string" ? plan.key : "free";
|
|
799
|
+
if (effectivePlanPrice(plan) > 0) {
|
|
800
|
+
errors.push(`Plan "${key}": free plan must have zero price`);
|
|
801
|
+
}
|
|
802
|
+
if (!hasHardEnforcedLimit(plan)) {
|
|
803
|
+
errors.push(
|
|
804
|
+
`Plan "${key}": free plan must define at least one enforced hard limit`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return errors;
|
|
809
|
+
}
|
|
810
|
+
function isImmediateAction(action) {
|
|
811
|
+
return action === "switch_immediately" || action === "switch_immediately_prorate";
|
|
812
|
+
}
|
|
813
|
+
function isRecord(value) {
|
|
814
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
815
|
+
}
|
|
816
|
+
function effectivePlanPrice(plan) {
|
|
817
|
+
const compactPrice = isRecord(plan.price) ? plan.price : void 0;
|
|
818
|
+
const pricing = isRecord(plan.pricing) ? plan.pricing : void 0;
|
|
819
|
+
const compactMonthly = compactPrice?.monthly;
|
|
820
|
+
if (typeof compactMonthly === "number") return compactMonthly;
|
|
821
|
+
const legacyMonthly = pricing?.monthlyPriceCents;
|
|
822
|
+
return typeof legacyMonthly === "number" ? legacyMonthly : 0;
|
|
823
|
+
}
|
|
824
|
+
function hasHardEnforcedLimit(plan) {
|
|
825
|
+
const limits = Array.isArray(plan.limits) ? plan.limits : [];
|
|
826
|
+
return limits.some((limit) => {
|
|
827
|
+
if (!isRecord(limit)) return false;
|
|
828
|
+
const enforcement = limit.enforcement;
|
|
829
|
+
const hard = limit.hard;
|
|
830
|
+
return enforcement === "enforce" || hard === true;
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
function findDuplicatePaidPrices(spec) {
|
|
834
|
+
if (!isRecord(spec) || !Array.isArray(spec.plans)) return [];
|
|
835
|
+
const seen = /* @__PURE__ */ new Map();
|
|
836
|
+
const dupes = /* @__PURE__ */ new Set();
|
|
837
|
+
for (const plan of spec.plans) {
|
|
838
|
+
if (!isRecord(plan) || plan.free === true) continue;
|
|
839
|
+
const amount = effectivePlanPrice(plan);
|
|
840
|
+
if (amount <= 0) continue;
|
|
841
|
+
const pricing = isRecord(plan.pricing) ? plan.pricing : {};
|
|
842
|
+
const price = isRecord(plan.price) ? plan.price : {};
|
|
843
|
+
const currency = typeof price.currency === "string" ? price.currency : typeof pricing.currency === "string" ? pricing.currency : "usd";
|
|
844
|
+
const interval = "monthly" in price ? "month" : typeof pricing.billingInterval === "string" ? pricing.billingInterval : "month";
|
|
845
|
+
const key = `${currency}:${interval}:${amount}`;
|
|
846
|
+
if (seen.has(key)) dupes.add(key);
|
|
847
|
+
else seen.set(key, typeof plan.key === "string" ? plan.key : key);
|
|
848
|
+
}
|
|
849
|
+
return [...dupes];
|
|
850
|
+
}
|
|
851
|
+
function formatIssue(issue, spec) {
|
|
852
|
+
const path = issue.path;
|
|
853
|
+
if (path.length >= 2 && path[0] === "plans" && typeof path[1] === "number") {
|
|
854
|
+
const plans = spec?.plans ?? [];
|
|
855
|
+
const plan = plans[path[1]];
|
|
856
|
+
const planLabel = plan?.key ?? plan?.name ?? `#${path[1]}`;
|
|
857
|
+
const fieldPath = path.slice(2).join(".");
|
|
858
|
+
if (fieldPath) {
|
|
859
|
+
return `Plan "${planLabel}": ${fieldPath} \u2014 ${issue.message}`;
|
|
860
|
+
}
|
|
861
|
+
return `Plan "${planLabel}": ${issue.message}`;
|
|
862
|
+
}
|
|
863
|
+
const dotted = path.length > 0 ? path.join(".") : "(root)";
|
|
864
|
+
return `${dotted}: ${issue.message}`;
|
|
865
|
+
}
|
|
866
|
+
function deriveWarnings(parsed) {
|
|
867
|
+
const warnings = [];
|
|
868
|
+
if (parsed.plans.length === 0) {
|
|
869
|
+
warnings.push("No plans declared \u2014 product cannot accept signups yet");
|
|
870
|
+
} else {
|
|
871
|
+
const hasFree = parsed.plans.some((plan) => {
|
|
872
|
+
const monthly = "monthlyPriceCents" in plan.pricing ? plan.pricing.monthlyPriceCents : void 0;
|
|
873
|
+
return monthly === 0;
|
|
874
|
+
});
|
|
875
|
+
if (!hasFree) {
|
|
876
|
+
warnings.push(
|
|
877
|
+
"No free plan \u2014 consider adding one for developer adoption"
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (!parsed.product.displayName || parsed.product.displayName === parsed.product.name) {
|
|
882
|
+
warnings.push(
|
|
883
|
+
"product.displayName not set \u2014 using product.name on the pricing page"
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
return warnings;
|
|
887
|
+
}
|
|
888
|
+
function findDuplicatePlanKeys(parsed) {
|
|
889
|
+
const seen = /* @__PURE__ */ new Set();
|
|
890
|
+
const dupes = /* @__PURE__ */ new Set();
|
|
891
|
+
for (const plan of parsed.plans) {
|
|
892
|
+
if (seen.has(plan.key)) {
|
|
893
|
+
dupes.add(plan.key);
|
|
894
|
+
} else {
|
|
895
|
+
seen.add(plan.key);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return [...dupes];
|
|
899
|
+
}
|
|
900
|
+
function loadProductYaml(filePath) {
|
|
901
|
+
if (!existsSync2(filePath)) {
|
|
902
|
+
return { ok: false, reason: "missing", message: filePath };
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
const content = readFileSync2(filePath, "utf-8");
|
|
906
|
+
return { ok: true, spec: YAML.parse(content) };
|
|
907
|
+
} catch (err) {
|
|
908
|
+
return {
|
|
909
|
+
ok: false,
|
|
910
|
+
reason: "parse",
|
|
911
|
+
message: err instanceof Error ? err.message : String(err)
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function reportValidationResult(result) {
|
|
916
|
+
if (CI) {
|
|
917
|
+
for (const err of result.errors) {
|
|
918
|
+
console.log(`::error file=product.yaml::${err}`);
|
|
919
|
+
}
|
|
920
|
+
for (const w of result.warnings) {
|
|
921
|
+
console.log(`::warning file=product.yaml::${w}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (result.errors.length === 0) {
|
|
925
|
+
success("product.yaml is valid");
|
|
926
|
+
for (const w of result.warnings) {
|
|
927
|
+
warn(w);
|
|
928
|
+
}
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
error(`product.yaml has ${result.errors.length} error(s):
|
|
932
|
+
`);
|
|
933
|
+
for (const err of result.errors) {
|
|
934
|
+
console.log(` \u2022 ${err}`);
|
|
935
|
+
}
|
|
936
|
+
if (result.warnings.length > 0) {
|
|
937
|
+
console.log();
|
|
938
|
+
for (const w of result.warnings) {
|
|
939
|
+
warn(w);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
process.exitCode = 1;
|
|
943
|
+
}
|
|
944
|
+
function registerValidateCommand(program2) {
|
|
945
|
+
program2.command("validate [file]").description("Validate a local product.yaml file").action((file) => {
|
|
946
|
+
const filePath = resolve(file ?? "product.yaml");
|
|
947
|
+
const loaded = loadProductYaml(filePath);
|
|
948
|
+
if (!loaded.ok) {
|
|
949
|
+
if (loaded.reason === "missing") {
|
|
950
|
+
if (CI)
|
|
951
|
+
console.log(
|
|
952
|
+
`::error file=product.yaml::File not found: ${loaded.message}`
|
|
953
|
+
);
|
|
954
|
+
error(`File not found: ${loaded.message}`);
|
|
955
|
+
} else {
|
|
956
|
+
if (CI)
|
|
957
|
+
console.log(
|
|
958
|
+
`::error file=product.yaml::YAML parse error: ${loaded.message}`
|
|
959
|
+
);
|
|
960
|
+
error(`Failed to parse YAML: ${loaded.message}`);
|
|
961
|
+
}
|
|
962
|
+
process.exitCode = 1;
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const result = validateProductYaml(loaded.spec);
|
|
966
|
+
const currentName = loaded.spec?.product?.name;
|
|
967
|
+
if (currentName) {
|
|
968
|
+
const mainName = readMainBranchProductName();
|
|
969
|
+
if (mainName && mainName !== currentName) {
|
|
970
|
+
result.errors.push(
|
|
971
|
+
`product.name "${currentName}" differs from main branch "${mainName}" \u2014 product name must not change`
|
|
972
|
+
);
|
|
973
|
+
result.valid = false;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
reportValidationResult(result);
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/commands/apply.ts
|
|
981
|
+
import { execSync as execSync2 } from "node:child_process";
|
|
982
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
|
|
983
|
+
import { resolve as resolve2 } from "node:path";
|
|
984
|
+
import YAML2 from "yaml";
|
|
985
|
+
var CI2 = !!process.env.CI || !!process.env.GITHUB_ACTIONS;
|
|
986
|
+
function detectBranch(explicit) {
|
|
987
|
+
if (explicit) return explicit;
|
|
988
|
+
const ghHead = process.env.GITHUB_HEAD_REF;
|
|
989
|
+
if (ghHead) return ghHead;
|
|
990
|
+
const ghRef = process.env.GITHUB_REF_NAME;
|
|
991
|
+
if (ghRef) return ghRef;
|
|
992
|
+
try {
|
|
993
|
+
const local = execSync2("git rev-parse --abbrev-ref HEAD", {
|
|
994
|
+
encoding: "utf-8",
|
|
995
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
996
|
+
}).trim();
|
|
997
|
+
return local && local !== "HEAD" ? local : void 0;
|
|
998
|
+
} catch {
|
|
999
|
+
return void 0;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function readSlugFromProductYaml() {
|
|
1003
|
+
const yamlPath = resolve2("product.yaml");
|
|
1004
|
+
if (!existsSync3(yamlPath)) return null;
|
|
1005
|
+
try {
|
|
1006
|
+
const spec = YAML2.parse(readFileSync3(yamlPath, "utf-8"));
|
|
1007
|
+
const product = spec.product;
|
|
1008
|
+
return product?.name ?? null;
|
|
1009
|
+
} catch {
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function validateLocalProductYamlBeforeRemoteCompile() {
|
|
1014
|
+
const filePath = resolve2("product.yaml");
|
|
1015
|
+
if (!existsSync3(filePath)) return true;
|
|
1016
|
+
const loaded = loadProductYaml(filePath);
|
|
1017
|
+
if (!loaded.ok) {
|
|
1018
|
+
const message = loaded.reason === "parse" ? `Local product.yaml failed to parse; remote compile was not started.
|
|
1019
|
+
${loaded.message}` : `Local product.yaml could not be read; remote compile was not started.
|
|
1020
|
+
${loaded.message}`;
|
|
1021
|
+
if (CI2) {
|
|
1022
|
+
console.log(`::error file=product.yaml::${message}`);
|
|
1023
|
+
}
|
|
1024
|
+
error(message);
|
|
1025
|
+
process.exitCode = 1;
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
const result = validateProductYaml(loaded.spec);
|
|
1029
|
+
if (!result.valid) {
|
|
1030
|
+
if (CI2) {
|
|
1031
|
+
for (const err of result.errors) {
|
|
1032
|
+
console.log(`::error file=product.yaml::${err}`);
|
|
1033
|
+
}
|
|
1034
|
+
for (const warning of result.warnings) {
|
|
1035
|
+
console.log(`::warning file=product.yaml::${warning}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
error(
|
|
1039
|
+
"Local product.yaml failed validation; remote compile was not started.\n"
|
|
1040
|
+
);
|
|
1041
|
+
for (const err of result.errors) {
|
|
1042
|
+
console.log(` \u2022 ${err}`);
|
|
1043
|
+
}
|
|
1044
|
+
if (result.warnings.length > 0) {
|
|
1045
|
+
console.log();
|
|
1046
|
+
for (const warning of result.warnings) {
|
|
1047
|
+
warn(warning);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
process.exitCode = 1;
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
success("Local product.yaml passed validation");
|
|
1054
|
+
for (const warning of result.warnings) {
|
|
1055
|
+
warn(warning);
|
|
1056
|
+
}
|
|
1057
|
+
info(
|
|
1058
|
+
"Remote compile checks the pushed branch state; unpushed local edits are not included."
|
|
1059
|
+
);
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
function shouldValidateLocalProductYaml(productArg) {
|
|
1063
|
+
const filePath = resolve2("product.yaml");
|
|
1064
|
+
if (!existsSync3(filePath)) return false;
|
|
1065
|
+
if (!productArg) return true;
|
|
1066
|
+
const localSlug = readSlugFromProductYaml();
|
|
1067
|
+
return typeof localSlug === "string" && localSlug.toLowerCase() === productArg.toLowerCase();
|
|
1068
|
+
}
|
|
1069
|
+
async function resolveProductId(client, arg) {
|
|
1070
|
+
const slug = arg ?? readSlugFromProductYaml();
|
|
1071
|
+
if (!slug) return null;
|
|
1072
|
+
if (slug.length === 36 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(slug)) {
|
|
1073
|
+
return slug;
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
const products = client.isMakerToken() ? await client.managementListProducts() : await client.listProducts();
|
|
1077
|
+
const match = products.find(
|
|
1078
|
+
(p) => p.name === slug || p.name.toLowerCase() === slug.toLowerCase()
|
|
1079
|
+
);
|
|
1080
|
+
return match?.id ?? null;
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1083
|
+
process.stderr.write(`Warning: Failed to resolve product slug: ${msg}
|
|
1084
|
+
`);
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
function formatDiag(d) {
|
|
1089
|
+
const prefix = d.code ? `[${d.code}] ` : "";
|
|
1090
|
+
const planLabel = d.planKey ? `(plan: ${d.planKey}) ` : "";
|
|
1091
|
+
return `${prefix}${planLabel}${d.message}`;
|
|
1092
|
+
}
|
|
1093
|
+
function handleResult(result) {
|
|
1094
|
+
if (CI2) {
|
|
1095
|
+
for (const err of result.errors ?? []) {
|
|
1096
|
+
console.log(`::error file=product.yaml::${formatDiag(err)}`);
|
|
1097
|
+
}
|
|
1098
|
+
for (const w of result.warnings ?? []) {
|
|
1099
|
+
console.log(`::warning file=product.yaml::${formatDiag(w)}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (result.success) {
|
|
1103
|
+
success("Remote compile passed");
|
|
1104
|
+
if (result.warnings?.length) {
|
|
1105
|
+
console.log();
|
|
1106
|
+
for (const w of result.warnings) {
|
|
1107
|
+
warn(formatDiag(w));
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
} else {
|
|
1111
|
+
error("Remote compile failed\n");
|
|
1112
|
+
for (const err of result.errors ?? []) {
|
|
1113
|
+
console.log(` \u2022 ${formatDiag(err)}`);
|
|
1114
|
+
}
|
|
1115
|
+
process.exitCode = 1;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function registerApplyCommand(program2, getClient2) {
|
|
1119
|
+
program2.command("apply [product]").description(
|
|
1120
|
+
"Validate the current repo's product.yaml before remote compile when applying that repo's product, then run the server-side compiler against the pushed branch state for this product. Unpushed local edits are not included. Pass a product slug, or run inside a product repo to auto-detect from product.yaml. Automatically scopes to the current git branch so env branches compile against their own plans."
|
|
1121
|
+
).option(
|
|
1122
|
+
"--branch <branch>",
|
|
1123
|
+
"Override the branch used for env-scoped compilation (default: auto-detected)"
|
|
1124
|
+
).action(
|
|
1125
|
+
async (productArg, opts) => {
|
|
1126
|
+
const client = getClient2();
|
|
1127
|
+
const branch = detectBranch(opts.branch);
|
|
1128
|
+
if (shouldValidateLocalProductYaml(productArg) && !validateLocalProductYamlBeforeRemoteCompile()) {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (branch && CI2) {
|
|
1132
|
+
console.log(`::notice::Compiling against branch '${branch}'`);
|
|
1133
|
+
}
|
|
1134
|
+
if (client.isMakerToken() && !productArg) {
|
|
1135
|
+
try {
|
|
1136
|
+
const result = await client.managementCompileSelf({ branch });
|
|
1137
|
+
handleResult(result);
|
|
1138
|
+
return;
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
const msg = err instanceof Error ? err.message : "Compilation check failed";
|
|
1141
|
+
if (CI2) console.log(`::error::${msg}`);
|
|
1142
|
+
error(msg);
|
|
1143
|
+
process.exitCode = 1;
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const productId = await resolveProductId(client, productArg);
|
|
1148
|
+
if (!productId) {
|
|
1149
|
+
const hint = productArg ? `Product "${productArg}" not found. Check the name and try again.` : "No product specified and no product.yaml found.\n Run from inside a product repo, or pass the slug: farthershore apply my-api";
|
|
1150
|
+
error(hint);
|
|
1151
|
+
process.exitCode = 1;
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
try {
|
|
1155
|
+
const result = client.isMakerToken() ? await client.managementCompile(productId, { branch }) : await client.compileProduct(productId, { branch });
|
|
1156
|
+
handleResult(result);
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
const msg = err instanceof Error ? err.message : "Compilation check failed";
|
|
1159
|
+
if (CI2) console.log(`::error::${msg}`);
|
|
1160
|
+
error(msg);
|
|
1161
|
+
process.exitCode = 1;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// src/remediation.ts
|
|
1168
|
+
var REMEDIATIONS = {
|
|
1169
|
+
// --- Auth ---
|
|
1170
|
+
UNAUTHORIZED: "Token is invalid or revoked. Run `farthershore set-key` to update it.",
|
|
1171
|
+
FORBIDDEN: "Your token doesn't have access to this resource. Check the org / product scope.",
|
|
1172
|
+
INVALID_ACCESS_KEY: "Token format is wrong. Generate a new one at https://farthershore.com/settings/tokens.",
|
|
1173
|
+
MAKER_TOKEN_REVOKED: "This maker token was revoked. Mint a new one in the product settings.",
|
|
1174
|
+
MAKER_TOKEN_NO_PRODUCT: "Maker token is not bound to a product. Re-create it from the product page.",
|
|
1175
|
+
// --- Stripe ---
|
|
1176
|
+
STRIPE_NOT_CONFIGURED: "Stripe isn't connected on this product. Connect it in the dashboard before running billing operations.",
|
|
1177
|
+
STRIPE_BALANCE_OUTSTANDING: "Customer has an outstanding balance. Resolve the invoice in Stripe before retrying.",
|
|
1178
|
+
CHECKOUT_SESSION_FAILED: "Stripe rejected the checkout request. Check that the plan exists and Stripe credentials are valid.",
|
|
1179
|
+
// --- Product / config ---
|
|
1180
|
+
PRODUCT_NOT_FOUND: "Check the product slug. Run `farthershore` (no args) for a list of products you can see.",
|
|
1181
|
+
PRODUCT_REPO_NOT_LINKED: "Link a GitHub repo to this product before running `apply`.",
|
|
1182
|
+
PRODUCT_YAML_NOT_FOUND: "No product.yaml on the target branch. Push a config file before running `apply`.",
|
|
1183
|
+
YAML_PARSE_ERROR: "product.yaml is not valid YAML. Run `farthershore validate` locally to see the parse error.",
|
|
1184
|
+
GITHUB_NOT_CONNECTED: "Connect GitHub on the org page before running `init` (it provisions the repo).",
|
|
1185
|
+
BRANCH_NO_MATCHING_ENV: "The current branch isn't mapped to an environment. Add a branch rule in product settings or pass --branch explicitly.",
|
|
1186
|
+
// --- Plans / pricing ---
|
|
1187
|
+
PLAN_NOT_FOUND: "Plan key doesn't exist on this product. Check `plans:` in product.yaml.",
|
|
1188
|
+
PLAN_HAS_ACTIVE_SUBSCRIPTIONS: "Plan has active subscribers and can't be deleted. Migrate them to another plan first.",
|
|
1189
|
+
PLAN_SLUG_CONFLICT: "Another plan already uses this key. Pick a unique key in product.yaml.",
|
|
1190
|
+
SLUG_CONFLICT: "This product slug is taken. Pick a different name.",
|
|
1191
|
+
SLUG_BLOCKED: "This slug is reserved or blocked. Pick a different name.",
|
|
1192
|
+
SLUG_RESERVED: "This slug is reserved by Farther Shore. Pick a different name.",
|
|
1193
|
+
SLUG_INVALID_FORMAT: "Slug must be lowercase letters, digits, and hyphens (no leading/trailing hyphen).",
|
|
1194
|
+
// --- Generic ---
|
|
1195
|
+
RATE_LIMIT_EXCEEDED: "You've hit the rate limit. Wait a moment and retry.",
|
|
1196
|
+
VALIDATION_ERROR: "Request is malformed. The `details` field has the field-level errors.",
|
|
1197
|
+
CONFLICT: "The resource is in a state that conflicts with the request. Inspect `details` to learn more."
|
|
1198
|
+
};
|
|
1199
|
+
function getRemediation(code) {
|
|
1200
|
+
if (!code) return void 0;
|
|
1201
|
+
return REMEDIATIONS[code];
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// src/index.ts
|
|
1205
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1206
|
+
var pkg = JSON.parse(
|
|
1207
|
+
await readFile(join2(__dirname, "..", "package.json"), "utf-8")
|
|
1208
|
+
);
|
|
1209
|
+
var program = new Command();
|
|
1210
|
+
program.name("farthershore").description("FartherShore CLI \u2014 create and manage API products").version(pkg.version).option("--token <token>", "Override auth token").option("--api-url <url>", "Override API base URL").option("--format <format>", "Output format: table, json");
|
|
1211
|
+
function getClient() {
|
|
1212
|
+
const config = loadConfig();
|
|
1213
|
+
const globalOpts = program.opts();
|
|
1214
|
+
const token = resolveToken(globalOpts.token);
|
|
1215
|
+
const apiUrl = globalOpts.apiUrl ?? process.env.FARTHERSHORE_API_URL ?? config.apiUrl;
|
|
1216
|
+
return createClient({ apiUrl, token });
|
|
31
1217
|
}
|
|
32
|
-
// Register commands
|
|
33
1218
|
registerAuthCommands(program);
|
|
34
1219
|
registerInitCommand(program, getClient);
|
|
35
1220
|
registerValidateCommand(program);
|
|
36
1221
|
registerApplyCommand(program, getClient);
|
|
37
|
-
// Global error handler
|
|
38
1222
|
program.exitOverride();
|
|
1223
|
+
function reportCliError(err) {
|
|
1224
|
+
const codeSuffix = err.code ? ` [${err.code}]` : "";
|
|
1225
|
+
process.stderr.write(`Error${codeSuffix}: ${err.message}
|
|
1226
|
+
`);
|
|
1227
|
+
const hint = getRemediation(err.code);
|
|
1228
|
+
if (hint) {
|
|
1229
|
+
process.stderr.write(`Hint: ${hint}
|
|
1230
|
+
`);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
39
1233
|
async function main() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
55
|
-
process.exitCode = 1;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
1234
|
+
try {
|
|
1235
|
+
await program.parseAsync(process.argv);
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
if (err instanceof CliError) {
|
|
1238
|
+
reportCliError(err);
|
|
1239
|
+
process.exitCode = 1;
|
|
1240
|
+
} else if (err instanceof Error) {
|
|
1241
|
+
const code = err.code;
|
|
1242
|
+
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
1243
|
+
} else if (err.message !== "(outputHelp)") {
|
|
1244
|
+
process.stderr.write(`Error: ${err.message}
|
|
1245
|
+
`);
|
|
1246
|
+
process.exitCode = 1;
|
|
1247
|
+
}
|
|
58
1248
|
}
|
|
1249
|
+
}
|
|
59
1250
|
}
|
|
60
|
-
main();
|
|
1251
|
+
void main();
|