@farthershore/cli 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,1251 +1,93 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/index.ts
4
2
  import { Command } from "commander";
5
3
  import { readFile } from "node:fs/promises";
6
4
  import { fileURLToPath } from "node:url";
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
- }
212
- const config = loadConfig();
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");
5
+ import { dirname, join } from "node:path";
6
+ import { createClient } from "./client.js";
7
+ import { resolveToken } from "./auth.js";
8
+ import { loadConfig } from "./config.js";
9
+ import { registerAuthCommands } from "./commands/login.js";
10
+ import { registerInitCommand } from "./commands/init.js";
11
+ import { registerValidateCommand } from "./commands/validate.js";
12
+ import { registerApplyCommand } from "./commands/apply.js";
13
+ import { registerBillingCommands } from "./commands/billing.js";
14
+ import { registerFeatureCommands } from "./commands/feature.js";
15
+ import { registerMeterCommands } from "./commands/meter.js";
16
+ import { registerPlanTransitionCommand } from "./commands/plan-transition.js";
17
+ import { registerPlanCommands } from "./commands/plan.js";
18
+ import { registerProductCommands } from "./commands/product.js";
19
+ import { registerTransitionCommands } from "./commands/transition.js";
20
+ import { CliError } from "./types.js";
21
+ import { getRemediation } from "./remediation.js";
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf-8"));
24
+ const program = new Command();
25
+ program
26
+ .name("farthershore")
27
+ .description("FartherShore CLI — create and manage API products")
28
+ .version(pkg.version)
29
+ .option("--token <token>", "Override auth token")
30
+ .option("--api-url <url>", "Override API base URL")
31
+ .option("--format <format>", "Output format: table, json");
32
+ // Lazy client — created on first use with resolved auth
1211
33
  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 });
34
+ const config = loadConfig();
35
+ const globalOpts = program.opts();
36
+ const token = resolveToken(globalOpts.token);
37
+ const apiUrl = globalOpts.apiUrl ?? process.env.FARTHERSHORE_API_URL ?? config.apiUrl;
38
+ return createClient({ apiUrl, token });
1217
39
  }
40
+ // Register commands
1218
41
  registerAuthCommands(program);
1219
42
  registerInitCommand(program, getClient);
43
+ registerProductCommands(program, getClient);
44
+ registerBillingCommands(program, getClient);
45
+ registerMeterCommands(program, getClient);
46
+ registerFeatureCommands(program, getClient);
47
+ registerPlanCommands(program, getClient);
48
+ registerTransitionCommands(program, getClient);
1220
49
  registerValidateCommand(program);
50
+ registerPlanTransitionCommand(program);
1221
51
  registerApplyCommand(program, getClient);
52
+ // Global error handler
1222
53
  program.exitOverride();
54
+ /**
55
+ * Pretty-print a CliError to stderr, surfacing the canonical `code` (when
56
+ * present) and a remediation hint registered in `./remediation.ts`. The
57
+ * code is shown in brackets so support engineers can ask "what was the
58
+ * code?" without parsing the message.
59
+ */
1223
60
  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
- }
61
+ const codeSuffix = err.code ? ` [${err.code}]` : "";
62
+ process.stderr.write(`Error${codeSuffix}: ${err.message}\n`);
63
+ const hint = getRemediation(err.code);
64
+ if (hint) {
65
+ process.stderr.write(`Hint: ${hint}\n`);
66
+ }
1232
67
  }
1233
68
  async function main() {
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
- }
69
+ try {
70
+ await program.parseAsync(process.argv);
71
+ }
72
+ catch (err) {
73
+ if (err instanceof CliError) {
74
+ reportCliError(err);
75
+ process.exitCode = 1;
76
+ }
77
+ else if (err instanceof Error) {
78
+ const code = err.code;
79
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
80
+ // Not an error — normal exit
81
+ }
82
+ else if (err.message !== "(outputHelp)") {
83
+ process.stderr.write(`Error: ${err.message}\n`);
84
+ process.exitCode = 1;
85
+ }
86
+ }
1248
87
  }
1249
- }
1250
88
  }
89
+ // Top-level invocation; CLI entry-point. `void` discards the returned
90
+ // promise explicitly — `main` already handles its own errors and sets
91
+ // `process.exitCode`, so a bare floating call is safe but the marker
92
+ // silences the lint and documents intent.
1251
93
  void main();